@fishdivinity/dsh-local-search 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -1,506 +1,498 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import { HarnessError } from '@deepseek-ai/dsh-llm'
3
- import { EngineError } from './errors.js'
4
- import { engineNames, resolveContentEngine, resolveFilesEngine } from './engines.js'
5
- import { formatContent, formatFiles, formatFragments, formatRead, formatStatus } from './format.js'
6
- import { parseRef, assertRefEngine } from './ref.js'
7
- import { defaultAllRoots, parseAllRoots, rootsFor } from './roots.js'
8
-
9
- const STATUS_ENGINES = ['auto', ...new Set([...engineNames().files, ...engineNames().content])]
10
- const MAX_LIMIT = 100
11
- const MAX_READ = 20000
12
- const MAX_FRAGMENTS = 20
13
-
14
- function toHarnessError(error) {
15
- if (error instanceof HarnessError) return error
16
- if (error instanceof Error) {
17
- return new HarnessError(error.message, error.code || 'LOCAL_SEARCH_ERROR', { cause: error })
18
- }
19
- return new HarnessError(String(error), 'LOCAL_SEARCH_ERROR')
20
- }
21
-
22
- async function guard(fn) {
23
- try {
24
- return await fn()
25
- } catch (error) {
26
- throw toHarnessError(error)
27
- }
28
- }
29
-
30
- function clampLimit(value, fallback) {
31
- const number = Number.isFinite(value) ? Math.floor(value) : fallback
32
- return Math.max(1, Math.min(MAX_LIMIT, number))
33
- }
34
-
35
- function requireQuery(query) {
36
- const value = typeof query === 'string' ? query.trim() : ''
37
- if (value.length === 0) throw new Error('query must be a non-empty string')
38
- return value
39
- }
40
-
41
- function validateEngine(engine, allowed) {
42
- const value = engine || 'auto'
43
- if (!allowed.includes(value)) {
44
- throw new Error(`engine must be one of ${allowed.join(', ')}`)
45
- }
46
- return value
47
- }
48
-
49
- function presentSearchCall(label) {
50
- return (args) => ({
51
- card: 'generic',
52
- title: `${label}${args?.query ? `: ${args.query}` : ''}`,
53
- kind: 'search',
54
- rawInput: args?.query,
55
- })
56
- }
57
-
58
- function presentSearchResult(_args, result) {
59
- if (result.isError) return undefined
60
- const meta = result.meta
61
- if (!meta || !Array.isArray(meta.paths)) return undefined
62
- return {
63
- card: 'search',
64
- shape: 'paths',
65
- paths: meta.paths,
66
- truncated: Boolean(meta.truncated),
67
- total: typeof meta.total === 'number' ? meta.total : meta.paths.length,
68
- }
69
- }
70
-
71
- const filesOutput = {
72
- schema: {
73
- type: 'object',
74
- additionalProperties: false,
75
- properties: {
76
- items: {
77
- type: 'array',
78
- required: true,
79
- items: {
80
- type: 'object',
81
- additionalProperties: false,
82
- properties: {
83
- path: { type: 'string', required: true },
84
- size: { type: 'number' },
85
- modified: { type: 'string' },
86
- },
87
- },
88
- },
89
- truncated: { type: 'boolean', required: true },
90
- },
91
- },
92
- render: (_args, value) => [{ type: 'text', text: formatFiles(value) }],
93
- presentationMeta: (_args, value) => ({
94
- paths: value.items.map((item) => item.path),
95
- truncated: value.truncated,
96
- total: value.items.length,
97
- }),
98
- }
99
-
100
- const contentOutput = {
101
- schema: {
102
- type: 'object',
103
- additionalProperties: false,
104
- properties: {
105
- items: {
106
- type: 'array',
107
- required: true,
108
- items: {
109
- type: 'object',
110
- additionalProperties: false,
111
- properties: {
112
- ref: { type: 'string', required: true },
113
- engine: { type: 'string', required: true },
114
- path: { type: 'string', required: true },
115
- size: { type: 'number' },
116
- modified: { type: 'string' },
117
- },
118
- },
119
- },
120
- total: { type: 'number', required: true },
121
- truncated: { type: 'boolean', required: true },
122
- },
123
- },
124
- render: (_args, value) => [{ type: 'text', text: formatContent(value) }],
125
- presentationMeta: (_args, value) => ({
126
- paths: value.items.map((item) => item.path),
127
- truncated: value.truncated,
128
- total: value.total,
129
- }),
130
- }
131
-
132
- const fragmentsOutput = {
133
- schema: {
134
- type: 'object',
135
- additionalProperties: false,
136
- properties: {
137
- fragments: {
138
- type: 'array',
139
- required: true,
140
- items: { type: 'string' },
141
- },
142
- },
143
- },
144
- render: (_args, value) => [{ type: 'text', text: formatFragments(value) }],
145
- }
146
-
147
- const readOutput = {
148
- schema: {
149
- type: 'object',
150
- additionalProperties: false,
151
- properties: {
152
- text: { type: 'string', required: true },
153
- totalChars: { type: 'number', required: true },
154
- offset: { type: 'number', required: true },
155
- returned: { type: 'number', required: true },
156
- },
157
- },
158
- render: (_args, value) => [{ type: 'text', text: formatRead(value) }],
159
- }
160
-
161
- const statusOutput = {
162
- schema: {
163
- type: 'object',
164
- additionalProperties: false,
165
- properties: {
166
- text: { type: 'string', required: true },
167
- },
168
- },
169
- render: (_args, value) => [{ type: 'text', text: value.text }],
170
- }
171
-
172
- /**
173
- * Build and register the five model-facing tools.
174
- * @param {import('@deepseek-ai/cordis').Context} ctx
175
- * @param {object} config
176
- * @returns {Array<() => void>} tool disposers
177
- */
178
- export function createTools(ctx, config) {
179
- const timeoutMs = config.timeoutMs
180
- const maxResults = config.maxResults
181
- const readMaxChars = config.readMaxChars
182
- const names = engineNames()
183
-
184
- async function resolveWorkspace(exec) {
185
- if (config.workspaceRoot) return config.workspaceRoot
186
- if (exec.agent && ctx.workingDirectory?.ensure) {
187
- try {
188
- return await ctx.workingDirectory.ensure(exec.agent, exec.signal)
189
- } catch (error) {
190
- const wrapped = new Error(`cannot resolve the session working directory: ${error.message}`)
191
- wrapped.code = 'WORKSPACE_UNAVAILABLE'
192
- wrapped.cause = error
193
- throw wrapped
194
- }
195
- }
196
- return ctx.workingDirectory?.defaultDirectory || process.cwd()
197
- }
198
-
199
- const filesTool = defineTool({
200
- name: 'local_search_files',
201
- description:
202
- 'Use this when the user asks for files or folders by name/path, especially outside the current workspace or across drives. ' +
203
- 'It queries the Everything index (NTFS metadata only: names, paths, size, dates; it does NOT search file contents). ' +
204
- 'Do not use it for text inside PDF/Office documents (use local_search_content) or for source-code content search (use grep). ' +
205
- 'Default scope is the workspace; set scope="all" only when the user explicitly asks for a cross-drive/global search.',
206
- parameters: {
207
- query: {
208
- type: 'string',
209
- required: true,
210
- description: 'Everything search text: wildcards, ext:, size:, dm:, path:, | and ! operators.',
211
- },
212
- scope: {
213
- type: 'string',
214
- description: 'workspace (default), work, personal, or all.',
215
- },
216
- path: {
217
- type: 'string',
218
- description: 'Optional absolute directory; overrides scope.',
219
- },
220
- engine: {
221
- type: 'string',
222
- description: `auto (default) or ${names.files.join(', ')}. Use auto unless the user asks for a specific engine.`,
223
- },
224
- limit: {
225
- type: 'number',
226
- description: `Max results (default ${maxResults}; max ${MAX_LIMIT}).`,
227
- },
228
- },
229
- timeoutMs,
230
- output: filesOutput,
231
- async execute(args, exec) {
232
- return guard(async () => {
233
- const query = requireQuery(args.query)
234
- const engine = resolveFilesEngine(args.engine)
235
- const scope = args.scope || 'workspace'
236
- const workspace = await resolveWorkspace(exec)
237
- let dir
238
- if (scope === 'all' && !args.path) {
239
- dir = undefined
240
- } else {
241
- dir = rootsFor(scope, args.path, config, workspace)[0]
242
- }
243
- return await engine.search({
244
- query,
245
- dir,
246
- limit: clampLimit(args.limit, maxResults),
247
- esPath: config.esPath,
248
- timeoutMs,
249
- signal: exec.signal,
250
- encoding: config.esEncoding,
251
- })
252
- })
253
- },
254
- presentCall: presentSearchCall('Everything search'),
255
- presentResult: presentSearchResult,
256
- })
257
-
258
- const contentTool = defineTool({
259
- name: 'local_search_content',
260
- description:
261
- 'Use this when the user asks for text inside PDF, DOCX, XLSX, PPTX, EPUB, RTF, TXT or MD documents, or for a whole-disk content search. ' +
262
- 'It queries the Anytxt index and returns refs for local_search_fragments/local_search_read. ' +
263
- 'Do not use it for source-code search in the workspace (use grep; the index can be stale). ' +
264
- 'Default scope is the workspace; set scope="all" only when the user explicitly asks for a cross-drive/global search.',
265
- parameters: {
266
- query: {
267
- type: 'string',
268
- required: true,
269
- description: 'Keywords; supports & | ! ( ) and "exact phrase".',
270
- },
271
- scope: {
272
- type: 'string',
273
- description: 'workspace (default), work, personal, or all.',
274
- },
275
- path: {
276
- type: 'string',
277
- description: 'Optional absolute directory; overrides scope.',
278
- },
279
- ext: {
280
- type: 'string',
281
- description: 'Optional extension filter, e.g. "pdf;docx".',
282
- },
283
- engine: {
284
- type: 'string',
285
- description: `auto (default) or ${names.content.join(', ')}. Use auto unless the user asks for a specific engine.`,
286
- },
287
- limit: {
288
- type: 'number',
289
- description: `Max files (default ${maxResults}; max ${MAX_LIMIT}).`,
290
- },
291
- },
292
- timeoutMs,
293
- output: contentOutput,
294
- async execute(args, exec) {
295
- return guard(async () => {
296
- const query = requireQuery(args.query)
297
- const engine = resolveContentEngine(args.engine)
298
- const workspace = await resolveWorkspace(exec)
299
- const roots = rootsFor(args.scope || 'workspace', args.path, config, workspace)
300
- const result = await engine.search({
301
- query,
302
- roots,
303
- ext: args.ext,
304
- limit: clampLimit(args.limit, maxResults),
305
- url: config.anytxtUrl,
306
- timeoutMs,
307
- signal: exec.signal,
308
- })
309
- return result
310
- })
311
- },
312
- presentCall: presentSearchCall('Anytxt content search'),
313
- presentResult: presentSearchResult,
314
- })
315
-
316
- const fragmentsTool = defineTool({
317
- name: 'local_search_fragments',
318
- description:
319
- 'Use this after local_search_content to get the text around a match inside one indexed file. ' +
320
- 'Returns a few snippets, not the whole document; use local_search_read for the full extracted text.',
321
- parameters: {
322
- ref: {
323
- type: 'string',
324
- required: true,
325
- description: 'Reference returned by local_search_content.',
326
- },
327
- engine: {
328
- type: 'string',
329
- description: 'auto (default) or a content engine name; refs are self-describing, so usually omit.',
330
- },
331
- query: {
332
- type: 'string',
333
- required: true,
334
- description: 'Keyword whose surrounding text should be returned.',
335
- },
336
- max: {
337
- type: 'number',
338
- description: `Max snippets (default 8; max ${MAX_FRAGMENTS}).`,
339
- },
340
- },
341
- timeoutMs,
342
- output: fragmentsOutput,
343
- async execute(args, exec) {
344
- return guard(async () => {
345
- if (typeof args.ref !== 'string' || args.ref.trim().length === 0) {
346
- throw new Error('ref must be a non-empty string')
347
- }
348
- const query = requireQuery(args.query)
349
- const parsed = parseRef(args.ref)
350
- const requested = args.engine && args.engine !== 'auto' ? args.engine : parsed.engine
351
- const engine = resolveContentEngine(requested)
352
- assertRefEngine(parsed, engine.name)
353
- if (typeof engine.fragments !== 'function') {
354
- throw new EngineError(`content engine "${engine.name}" does not support fragments`, 'ENGINE_READ_UNSUPPORTED', {
355
- hint: 'Use local_search_content or a different content engine.',
356
- })
357
- }
358
- const max = Math.max(1, Math.min(MAX_FRAGMENTS, Math.floor(args.max ?? 8)))
359
- const fragments = await engine.fragments({
360
- ref: args.ref,
361
- query,
362
- url: config.anytxtUrl,
363
- timeoutMs,
364
- signal: exec.signal,
365
- })
366
- return { fragments: fragments.slice(0, max) }
367
- })
368
- },
369
- })
370
-
371
- const readTool = defineTool({
372
- name: 'local_search_read',
373
- description:
374
- 'Use this to read the extracted plain text of one indexed document by ref. The result is capped; ' +
375
- 'for normal text files in the workspace use read instead of this tool.',
376
- parameters: {
377
- ref: {
378
- type: 'string',
379
- required: true,
380
- description: 'Reference returned by local_search_content.',
381
- },
382
- engine: {
383
- type: 'string',
384
- description: 'auto (default) or a content engine name; refs are self-describing, so usually omit.',
385
- },
386
- offset: {
387
- type: 'number',
388
- description: 'Character offset (default 0).',
389
- },
390
- max: {
391
- type: 'number',
392
- description: `Max characters (default ${readMaxChars}; max ${MAX_READ}).`,
393
- },
394
- },
395
- timeoutMs,
396
- output: readOutput,
397
- async execute(args, exec) {
398
- return guard(async () => {
399
- if (typeof args.ref !== 'string' || args.ref.trim().length === 0) {
400
- throw new Error('ref must be a non-empty string')
401
- }
402
- const max = Math.max(1, Math.min(MAX_READ, Math.floor(args.max ?? readMaxChars)))
403
- const offset = Math.max(0, Math.floor(args.offset ?? 0))
404
- const parsed = parseRef(args.ref)
405
- const requested = args.engine && args.engine !== 'auto' ? args.engine : parsed.engine
406
- const engine = resolveContentEngine(requested)
407
- assertRefEngine(parsed, engine.name)
408
- if (typeof engine.read !== 'function') {
409
- throw new EngineError(`content engine "${engine.name}" does not support read`, 'ENGINE_READ_UNSUPPORTED', {
410
- hint: 'Use local_search_content or a different content engine.',
411
- })
412
- }
413
- const text = await engine.read({
414
- ref: args.ref,
415
- url: config.anytxtUrl,
416
- timeoutMs,
417
- signal: exec.signal,
418
- })
419
- const slice = text.slice(offset, offset + max)
420
- return { text: slice, totalChars: text.length, offset, returned: slice.length }
421
- })
422
- },
423
- })
424
-
425
- const statusTool = defineTool({
426
- name: 'local_search_status',
427
- description:
428
- 'Use this when a search reports EVERYTHING_DOWN or ANYTXT_DOWN, or before a large batch, to check whether the Everything index and the Anytxt JSON-RPC service are reachable. ' +
429
- 'Returns both statuses plus the configured search roots.',
430
- parameters: {
431
- engine: {
432
- type: 'string',
433
- description: `auto (default), ${[...names.files, ...names.content].join(', ')}. Limit the probe to one engine.`,
434
- },
435
- },
436
- timeoutMs,
437
- output: statusOutput,
438
- async execute(args, exec) {
439
- return guard(async () => {
440
- const engine = validateEngine(args.engine, STATUS_ENGINES)
441
- const everything = { ok: false }
442
- const anytxt = { ok: false }
443
- let workspace = config.workspaceRoot || ctx.workingDirectory?.defaultDirectory || process.cwd()
444
- try {
445
- workspace = await resolveWorkspace(exec)
446
- } catch {
447
- // Status must still report the engines even when the workspace is gone.
448
- }
449
-
450
- if (engine !== 'anytxt') {
451
- try {
452
- const status = await resolveFilesEngine('everything').status({
453
- config,
454
- esPath: config.esPath,
455
- timeoutMs: Math.min(timeoutMs, 5000),
456
- signal: exec.signal,
457
- encoding: config.esEncoding,
458
- })
459
- Object.assign(everything, status)
460
- } catch (error) {
461
- everything.code = error.code || 'EVERYTHING_DOWN'
462
- everything.message = error.message
463
- }
464
- } else {
465
- everything.code = 'SKIPPED'
466
- everything.message = 'not checked because engine=anytxt'
467
- }
468
-
469
- if (engine !== 'everything') {
470
- try {
471
- const probe = await resolveContentEngine('anytxt').status({
472
- url: config.anytxtUrl,
473
- root: workspace,
474
- timeoutMs: Math.min(timeoutMs, 5000),
475
- signal: exec.signal,
476
- })
477
- Object.assign(anytxt, probe)
478
- } catch (error) {
479
- anytxt.code = error.code || 'ANYTXT_DOWN'
480
- anytxt.message = error.message
481
- }
482
- } else {
483
- anytxt.code = 'SKIPPED'
484
- anytxt.message = 'not checked because engine=everything'
485
- }
486
-
487
- const all = config.allRoots ? parseAllRoots(config.allRoots) : defaultAllRoots()
488
- return {
489
- text: formatStatus({
490
- everything,
491
- anytxt,
492
- engines: engineNames(),
493
- roots: {
494
- workspace,
495
- work: config.workRoot,
496
- personal: config.personalRoot,
497
- all,
498
- },
499
- }),
500
- }
501
- })
502
- },
503
- })
504
-
505
- return [filesTool, contentTool, fragmentsTool, readTool, statusTool]
506
- }
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { HarnessError } from '@deepseek-ai/dsh-llm'
3
+ import { EngineError } from './errors.js'
4
+ import { engineNames, resolveContentEngine, resolveFilesEngine } from './engines.js'
5
+ import { formatContent, formatFiles, formatFragments, formatRead, formatStatus } from './format.js'
6
+ import { parseRef, assertRefEngine } from './ref.js'
7
+ import { defaultAllRoots, parseAllRoots, rootsFor } from './roots.js'
8
+ import { readConfig } from './config.js'
9
+ import { probeEngines } from './probe.js'
10
+ import { MAX_LIMIT, MAX_READ } from './limits.js'
11
+
12
+ const STATUS_ENGINES = ['auto', ...new Set([...engineNames().files, ...engineNames().content])]
13
+ const MAX_FRAGMENTS = 20
14
+
15
+ function toHarnessError(error) {
16
+ if (error instanceof HarnessError) return error
17
+ if (error instanceof Error) {
18
+ return new HarnessError(error.message, error.code || 'LOCAL_SEARCH_ERROR', { cause: error })
19
+ }
20
+ return new HarnessError(String(error), 'LOCAL_SEARCH_ERROR')
21
+ }
22
+
23
+ async function guard(fn) {
24
+ try {
25
+ return await fn()
26
+ } catch (error) {
27
+ throw toHarnessError(error)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Coerce an optional numeric argument into `[1, cap]`. A non-numeric argument
33
+ * falls back to the configured default instead of turning the window into NaN,
34
+ * which would silently return nothing.
35
+ * @param value - the argument as the model sent it
36
+ * @param fallback - default to use when it is not a finite number
37
+ * @param cap - inclusive upper bound
38
+ * @returns the usable count
39
+ */
40
+ function clampCount(value, fallback, cap) {
41
+ const number = Number.isFinite(value) ? Math.floor(value) : fallback
42
+ return Math.max(1, Math.min(cap, number))
43
+ }
44
+
45
+ function clampLimit(value, fallback) {
46
+ return clampCount(value, fallback, MAX_LIMIT)
47
+ }
48
+
49
+ function clampOffset(value) {
50
+ return Math.max(0, Number.isFinite(value) ? Math.floor(value) : 0)
51
+ }
52
+
53
+ function requireQuery(query) {
54
+ const value = typeof query === 'string' ? query.trim() : ''
55
+ if (value.length === 0) throw new Error('query must be a non-empty string')
56
+ return value
57
+ }
58
+
59
+ function validateEngine(engine, allowed) {
60
+ const value = engine || 'auto'
61
+ if (!allowed.includes(value)) {
62
+ throw new Error(`engine must be one of ${allowed.join(', ')}`)
63
+ }
64
+ return value
65
+ }
66
+
67
+ function presentSearchCall(label) {
68
+ return (args) => ({
69
+ card: 'generic',
70
+ title: `${label}${args?.query ? `: ${args.query}` : ''}`,
71
+ kind: 'search',
72
+ rawInput: args?.query,
73
+ })
74
+ }
75
+
76
+ function presentSearchResult(_args, result) {
77
+ if (result.isError) return undefined
78
+ const meta = result.meta
79
+ if (!meta || !Array.isArray(meta.paths)) return undefined
80
+ return {
81
+ card: 'search',
82
+ shape: 'paths',
83
+ paths: meta.paths,
84
+ truncated: Boolean(meta.truncated),
85
+ total: typeof meta.total === 'number' ? meta.total : meta.paths.length,
86
+ }
87
+ }
88
+
89
+ const filesOutput = {
90
+ schema: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ properties: {
94
+ items: {
95
+ type: 'array',
96
+ required: true,
97
+ items: {
98
+ type: 'object',
99
+ additionalProperties: false,
100
+ properties: {
101
+ path: { type: 'string', required: true },
102
+ size: { type: 'number' },
103
+ modified: { type: 'string' },
104
+ },
105
+ },
106
+ },
107
+ truncated: { type: 'boolean', required: true },
108
+ },
109
+ },
110
+ render: (_args, value) => [{ type: 'text', text: formatFiles(value) }],
111
+ presentationMeta: (_args, value) => ({
112
+ paths: value.items.map((item) => item.path),
113
+ truncated: value.truncated,
114
+ total: value.items.length,
115
+ }),
116
+ }
117
+
118
+ const contentOutput = {
119
+ schema: {
120
+ type: 'object',
121
+ additionalProperties: false,
122
+ properties: {
123
+ items: {
124
+ type: 'array',
125
+ required: true,
126
+ items: {
127
+ type: 'object',
128
+ additionalProperties: false,
129
+ properties: {
130
+ ref: { type: 'string', required: true },
131
+ engine: { type: 'string', required: true },
132
+ path: { type: 'string', required: true },
133
+ size: { type: 'number' },
134
+ modified: { type: 'string' },
135
+ },
136
+ },
137
+ },
138
+ total: { type: 'number', required: true },
139
+ truncated: { type: 'boolean', required: true },
140
+ },
141
+ },
142
+ render: (_args, value) => [{ type: 'text', text: formatContent(value) }],
143
+ presentationMeta: (_args, value) => ({
144
+ paths: value.items.map((item) => item.path),
145
+ truncated: value.truncated,
146
+ total: value.total,
147
+ }),
148
+ }
149
+
150
+ const fragmentsOutput = {
151
+ schema: {
152
+ type: 'object',
153
+ additionalProperties: false,
154
+ properties: {
155
+ fragments: {
156
+ type: 'array',
157
+ required: true,
158
+ items: { type: 'string' },
159
+ },
160
+ },
161
+ },
162
+ render: (_args, value) => [{ type: 'text', text: formatFragments(value) }],
163
+ }
164
+
165
+ const readOutput = {
166
+ schema: {
167
+ type: 'object',
168
+ additionalProperties: false,
169
+ properties: {
170
+ text: { type: 'string', required: true },
171
+ totalChars: { type: 'number', required: true },
172
+ offset: { type: 'number', required: true },
173
+ returned: { type: 'number', required: true },
174
+ },
175
+ },
176
+ render: (_args, value) => [{ type: 'text', text: formatRead(value) }],
177
+ }
178
+
179
+ const statusOutput = {
180
+ schema: {
181
+ type: 'object',
182
+ additionalProperties: false,
183
+ properties: {
184
+ text: { type: 'string', required: true },
185
+ },
186
+ },
187
+ render: (_args, value) => [{ type: 'text', text: value.text }],
188
+ }
189
+
190
+ /**
191
+ * Build and register the five model-facing tools.
192
+ * @param {import('@deepseek-ai/cordis').Context} ctx
193
+ * @param {object} config
194
+ * @returns {Array<() => void>} tool disposers
195
+ */
196
+ export function createTools(ctx, config) {
197
+ // Registration fixes the tool deadlines and the text describing defaults;
198
+ // everything a call acts on is read per operation, so a save on the config
199
+ // page applies to the next call instead of the next reload.
200
+ const initial = readConfig(config)
201
+ const names = engineNames()
202
+
203
+ async function resolveWorkspace(exec, cfg) {
204
+ if (cfg.workspaceRoot) return cfg.workspaceRoot
205
+ if (exec.agent && ctx.workingDirectory?.ensure) {
206
+ try {
207
+ return await ctx.workingDirectory.ensure(exec.agent, exec.signal)
208
+ } catch (error) {
209
+ const wrapped = new Error(`cannot resolve the session working directory: ${error.message}`)
210
+ wrapped.code = 'WORKSPACE_UNAVAILABLE'
211
+ wrapped.cause = error
212
+ throw wrapped
213
+ }
214
+ }
215
+ return ctx.workingDirectory?.defaultDirectory || process.cwd()
216
+ }
217
+
218
+ const filesTool = defineTool({
219
+ name: 'local_search_files',
220
+ description:
221
+ 'Use this when the user asks for files or folders by name/path, especially outside the current workspace or across drives. ' +
222
+ 'It queries the Everything index (NTFS metadata only: names, paths, size, dates; it does NOT search file contents). ' +
223
+ 'Do not use it for text inside PDF/Office documents (use local_search_content) or for source-code content search (use grep). ' +
224
+ 'Default scope is the workspace; set scope="all" only when the user explicitly asks for a cross-drive/global search.',
225
+ parameters: {
226
+ query: {
227
+ type: 'string',
228
+ required: true,
229
+ description: 'Everything search text: wildcards, ext:, size:, dm:, path:, | and ! operators.',
230
+ },
231
+ scope: {
232
+ type: 'string',
233
+ description: 'workspace (default), work, personal, or all.',
234
+ },
235
+ path: {
236
+ type: 'string',
237
+ description: 'Optional absolute directory; overrides scope.',
238
+ },
239
+ engine: {
240
+ type: 'string',
241
+ description: `auto (default) or ${names.files.join(', ')}. Use auto unless the user asks for a specific engine.`,
242
+ },
243
+ limit: {
244
+ type: 'number',
245
+ description: `Max results (default ${initial.maxResults}; max ${MAX_LIMIT}).`,
246
+ },
247
+ },
248
+ timeoutMs: initial.timeoutMs,
249
+ output: filesOutput,
250
+ async execute(args, exec) {
251
+ return guard(async () => {
252
+ const cfg = readConfig(config)
253
+ const query = requireQuery(args.query)
254
+ const engine = resolveFilesEngine(args.engine)
255
+ const scope = args.scope || 'workspace'
256
+ const workspace = await resolveWorkspace(exec, cfg)
257
+ let dir
258
+ if (scope === 'all' && !args.path) {
259
+ dir = undefined
260
+ } else {
261
+ dir = rootsFor(scope, args.path, cfg, workspace)[0]
262
+ }
263
+ return await engine.search({
264
+ query,
265
+ dir,
266
+ limit: clampLimit(args.limit, cfg.maxResults),
267
+ esPath: cfg.esPath,
268
+ timeoutMs: initial.timeoutMs,
269
+ signal: exec.signal,
270
+ encoding: cfg.esEncoding,
271
+ })
272
+ })
273
+ },
274
+ presentCall: presentSearchCall('Everything search'),
275
+ presentResult: presentSearchResult,
276
+ })
277
+
278
+ const contentTool = defineTool({
279
+ name: 'local_search_content',
280
+ description:
281
+ 'Use this when the user asks for text inside PDF, DOCX, XLSX, PPTX, EPUB, RTF, TXT or MD documents, or for a whole-disk content search. ' +
282
+ 'It queries the Anytxt index and returns refs for local_search_fragments/local_search_read. ' +
283
+ 'Do not use it for source-code search in the workspace (use grep; the index can be stale). ' +
284
+ 'Default scope is the workspace; set scope="all" only when the user explicitly asks for a cross-drive/global search.',
285
+ parameters: {
286
+ query: {
287
+ type: 'string',
288
+ required: true,
289
+ description: 'Keywords; supports & | ! ( ) and "exact phrase".',
290
+ },
291
+ scope: {
292
+ type: 'string',
293
+ description: 'workspace (default), work, personal, or all.',
294
+ },
295
+ path: {
296
+ type: 'string',
297
+ description: 'Optional absolute directory; overrides scope.',
298
+ },
299
+ ext: {
300
+ type: 'string',
301
+ description: 'Optional extension filter, e.g. "pdf;docx".',
302
+ },
303
+ engine: {
304
+ type: 'string',
305
+ description: `auto (default) or ${names.content.join(', ')}. Use auto unless the user asks for a specific engine.`,
306
+ },
307
+ limit: {
308
+ type: 'number',
309
+ description: `Max files (default ${initial.maxResults}; max ${MAX_LIMIT}).`,
310
+ },
311
+ },
312
+ timeoutMs: initial.timeoutMs,
313
+ output: contentOutput,
314
+ async execute(args, exec) {
315
+ return guard(async () => {
316
+ const cfg = readConfig(config)
317
+ const query = requireQuery(args.query)
318
+ const engine = resolveContentEngine(args.engine)
319
+ const workspace = await resolveWorkspace(exec, cfg)
320
+ const roots = rootsFor(args.scope || 'workspace', args.path, cfg, workspace)
321
+ const result = await engine.search({
322
+ query,
323
+ roots,
324
+ ext: args.ext,
325
+ limit: clampLimit(args.limit, cfg.maxResults),
326
+ url: cfg.anytxtUrl,
327
+ timeoutMs: initial.timeoutMs,
328
+ signal: exec.signal,
329
+ })
330
+ return result
331
+ })
332
+ },
333
+ presentCall: presentSearchCall('Anytxt content search'),
334
+ presentResult: presentSearchResult,
335
+ })
336
+
337
+ const fragmentsTool = defineTool({
338
+ name: 'local_search_fragments',
339
+ description:
340
+ 'Use this after local_search_content to get the text around a match inside one indexed file. ' +
341
+ 'Returns a few snippets, not the whole document; use local_search_read for the full extracted text.',
342
+ parameters: {
343
+ ref: {
344
+ type: 'string',
345
+ required: true,
346
+ description: 'Reference returned by local_search_content.',
347
+ },
348
+ engine: {
349
+ type: 'string',
350
+ description: 'auto (default) or a content engine name; refs are self-describing, so usually omit.',
351
+ },
352
+ query: {
353
+ type: 'string',
354
+ required: true,
355
+ description: 'Keyword whose surrounding text should be returned.',
356
+ },
357
+ max: {
358
+ type: 'number',
359
+ description: `Max snippets (default 8; max ${MAX_FRAGMENTS}).`,
360
+ },
361
+ },
362
+ timeoutMs: initial.timeoutMs,
363
+ output: fragmentsOutput,
364
+ async execute(args, exec) {
365
+ return guard(async () => {
366
+ const cfg = readConfig(config)
367
+ if (typeof args.ref !== 'string' || args.ref.trim().length === 0) {
368
+ throw new Error('ref must be a non-empty string')
369
+ }
370
+ const query = requireQuery(args.query)
371
+ const parsed = parseRef(args.ref)
372
+ const requested = args.engine && args.engine !== 'auto' ? args.engine : parsed.engine
373
+ const engine = resolveContentEngine(requested)
374
+ assertRefEngine(parsed, engine.name)
375
+ if (typeof engine.fragments !== 'function') {
376
+ throw new EngineError(`content engine "${engine.name}" does not support fragments`, 'ENGINE_READ_UNSUPPORTED', {
377
+ hint: 'Use local_search_content or a different content engine.',
378
+ })
379
+ }
380
+ const max = clampCount(args.max, 8, MAX_FRAGMENTS)
381
+ const fragments = await engine.fragments({
382
+ ref: args.ref,
383
+ query,
384
+ url: cfg.anytxtUrl,
385
+ timeoutMs: initial.timeoutMs,
386
+ signal: exec.signal,
387
+ })
388
+ return { fragments: fragments.slice(0, max) }
389
+ })
390
+ },
391
+ })
392
+
393
+ const readTool = defineTool({
394
+ name: 'local_search_read',
395
+ description:
396
+ 'Use this to read the extracted plain text of one indexed document by ref. The result is capped; ' +
397
+ 'for normal text files in the workspace use read instead of this tool.',
398
+ parameters: {
399
+ ref: {
400
+ type: 'string',
401
+ required: true,
402
+ description: 'Reference returned by local_search_content.',
403
+ },
404
+ engine: {
405
+ type: 'string',
406
+ description: 'auto (default) or a content engine name; refs are self-describing, so usually omit.',
407
+ },
408
+ offset: {
409
+ type: 'number',
410
+ description: 'Character offset (default 0).',
411
+ },
412
+ max: {
413
+ type: 'number',
414
+ description: `Max characters (default ${initial.readMaxChars}; max ${MAX_READ}).`,
415
+ },
416
+ },
417
+ timeoutMs: initial.timeoutMs,
418
+ output: readOutput,
419
+ async execute(args, exec) {
420
+ return guard(async () => {
421
+ const cfg = readConfig(config)
422
+ if (typeof args.ref !== 'string' || args.ref.trim().length === 0) {
423
+ throw new Error('ref must be a non-empty string')
424
+ }
425
+ const max = clampCount(args.max, cfg.readMaxChars, MAX_READ)
426
+ const offset = clampOffset(args.offset)
427
+ const parsed = parseRef(args.ref)
428
+ const requested = args.engine && args.engine !== 'auto' ? args.engine : parsed.engine
429
+ const engine = resolveContentEngine(requested)
430
+ assertRefEngine(parsed, engine.name)
431
+ if (typeof engine.read !== 'function') {
432
+ throw new EngineError(`content engine "${engine.name}" does not support read`, 'ENGINE_READ_UNSUPPORTED', {
433
+ hint: 'Use local_search_content or a different content engine.',
434
+ })
435
+ }
436
+ const text = await engine.read({
437
+ ref: args.ref,
438
+ url: cfg.anytxtUrl,
439
+ timeoutMs: initial.timeoutMs,
440
+ signal: exec.signal,
441
+ })
442
+ const slice = text.slice(offset, offset + max)
443
+ return { text: slice, totalChars: text.length, offset, returned: slice.length }
444
+ })
445
+ },
446
+ })
447
+
448
+ const statusTool = defineTool({
449
+ name: 'local_search_status',
450
+ description:
451
+ 'Use this when a search reports EVERYTHING_DOWN or ANYTXT_DOWN, or before a large batch, to check whether the Everything index and the Anytxt JSON-RPC service are reachable. ' +
452
+ 'Returns both statuses plus the configured search roots.',
453
+ parameters: {
454
+ engine: {
455
+ type: 'string',
456
+ description: `auto (default), ${[...names.files, ...names.content].join(', ')}. Limit the probe to one engine.`,
457
+ },
458
+ },
459
+ timeoutMs: initial.timeoutMs,
460
+ output: statusOutput,
461
+ async execute(args, exec) {
462
+ return guard(async () => {
463
+ const cfg = readConfig(config)
464
+ const engine = validateEngine(args.engine, STATUS_ENGINES)
465
+ let workspace = cfg.workspaceRoot || ctx.workingDirectory?.defaultDirectory || process.cwd()
466
+ try {
467
+ workspace = await resolveWorkspace(exec, cfg)
468
+ } catch {
469
+ // Status must still report the engines even when the workspace is gone.
470
+ }
471
+
472
+ // The same probe the configuration page's Test control asks for.
473
+ const { everything, anytxt } = await probeEngines(config, {
474
+ only: engine,
475
+ workspace,
476
+ signal: exec.signal,
477
+ })
478
+
479
+ const all = cfg.allRoots ? parseAllRoots(cfg.allRoots) : defaultAllRoots()
480
+ return {
481
+ text: formatStatus({
482
+ everything,
483
+ anytxt,
484
+ engines: engineNames(),
485
+ roots: {
486
+ workspace,
487
+ work: cfg.workRoot,
488
+ personal: cfg.personalRoot,
489
+ all,
490
+ },
491
+ }),
492
+ }
493
+ })
494
+ },
495
+ })
496
+
497
+ return [filesTool, contentTool, fragmentsTool, readTool, statusTool]
498
+ }