@fishdivinity/dsh-local-search 0.2.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/client.js +56 -9
- package/lib/everything.js +64 -6
- package/lib/format.js +31 -24
- package/lib/index.js +10 -6
- package/lib/limits.js +14 -0
- package/lib/probe.js +20 -1
- package/lib/roots.js +22 -1
- package/lib/route.js +3 -1
- package/lib/tools.js +1 -2
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -57,6 +57,16 @@ window.__ModuleLoader__.load({
|
|
|
57
57
|
/** Code pages the page offers. The Host accepts any TextDecoder label. */
|
|
58
58
|
const ENCODINGS = ['gbk', 'utf-8'];
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Hosts a saved endpoint may name. The bundle cannot import `lib/config.js`
|
|
62
|
+
* (a classic script has no ESM imports), so this list duplicates the Host's;
|
|
63
|
+
* its test compares the two.
|
|
64
|
+
*/
|
|
65
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
|
|
66
|
+
|
|
67
|
+
/** Hard caps the Host enforces in its schema; the page mirrors them so a save cannot be rejected. */
|
|
68
|
+
const LIMITS = { maxResults: 100, readMaxChars: 20000 };
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* The segment a saved code page selects. 0.1 accepted any label, so an
|
|
62
72
|
* old `utf8` (or `gb18030`) must land on the right segment instead of
|
|
@@ -78,8 +88,8 @@ window.__ModuleLoader__.load({
|
|
|
78
88
|
workRoot: { key: 'workRoot', kind: 'text' },
|
|
79
89
|
personalRoot: { key: 'personalRoot', kind: 'text' },
|
|
80
90
|
allRoots: { key: 'allRoots', kind: 'text' },
|
|
81
|
-
maxResults: { key: 'maxResults', kind: 'count', max:
|
|
82
|
-
readMaxChars: { key: 'readMaxChars', kind: 'count', max:
|
|
91
|
+
maxResults: { key: 'maxResults', kind: 'count', max: LIMITS.maxResults },
|
|
92
|
+
readMaxChars: { key: 'readMaxChars', kind: 'count', max: LIMITS.readMaxChars },
|
|
83
93
|
};
|
|
84
94
|
|
|
85
95
|
/** Per-field copy: [label, hint]. */
|
|
@@ -113,12 +123,12 @@ window.__ModuleLoader__.load({
|
|
|
113
123
|
zh: ['全局检索根', 'scope=all 使用的目录,分号分隔;留空使用存在的 C: 与 D:。'],
|
|
114
124
|
},
|
|
115
125
|
maxResults: {
|
|
116
|
-
en: ['Results per call',
|
|
117
|
-
zh: ['每次结果数',
|
|
126
|
+
en: ['Results per call', `Default result cap for both search tools; 1–${LIMITS.maxResults}.`],
|
|
127
|
+
zh: ['每次结果数', `两个检索工具的默认结果上限;1–${LIMITS.maxResults}。`],
|
|
118
128
|
},
|
|
119
129
|
readMaxChars: {
|
|
120
|
-
en: ['Read window (characters)',
|
|
121
|
-
zh: ['读取窗口(字符)',
|
|
130
|
+
en: ['Read window (characters)', `Default window for local_search_read; 1–${LIMITS.readMaxChars}.`],
|
|
131
|
+
zh: ['读取窗口(字符)', `local_search_read 的默认窗口;1–${LIMITS.readMaxChars}。`],
|
|
122
132
|
},
|
|
123
133
|
};
|
|
124
134
|
|
|
@@ -186,6 +196,18 @@ window.__ModuleLoader__.load({
|
|
|
186
196
|
anytxtDown: copy('Anytxt 不可达', 'Anytxt: DOWN'),
|
|
187
197
|
downloadEverything: copy('下载 Everything', 'Download Everything'),
|
|
188
198
|
downloadAnytxt: copy('下载 Anytxt', 'Download Anytxt'),
|
|
199
|
+
fixEverythingNotRunning: copy(
|
|
200
|
+
'Everything 已安装但没在运行:启动它。',
|
|
201
|
+
'Everything is installed but not running: start it.',
|
|
202
|
+
),
|
|
203
|
+
fixEverythingNotInstalled: copy(
|
|
204
|
+
'没有检测到 Everything:先安装它,再安装随附的 es.exe。',
|
|
205
|
+
'Everything is not installed: install it, then the es.exe that ships with it.',
|
|
206
|
+
),
|
|
207
|
+
fixEverythingEsMissing: copy(
|
|
208
|
+
'缺 es.exe:从 voidtools 下载后填到上面的 es.exe 路径。',
|
|
209
|
+
'es.exe is missing: download it and set the path above.',
|
|
210
|
+
),
|
|
189
211
|
};
|
|
190
212
|
for (const [key, entry] of Object.entries(FIELD_COPY)) {
|
|
191
213
|
words[`${key}Label`] = copy(entry.zh[0], entry.en[0]);
|
|
@@ -253,7 +275,7 @@ window.__ModuleLoader__.load({
|
|
|
253
275
|
}
|
|
254
276
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
255
277
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
256
|
-
return
|
|
278
|
+
return LOOPBACK_HOSTS.includes(host);
|
|
257
279
|
}
|
|
258
280
|
|
|
259
281
|
/**
|
|
@@ -295,12 +317,32 @@ window.__ModuleLoader__.load({
|
|
|
295
317
|
? t('anytxtOk')
|
|
296
318
|
: `${t('anytxtDown')} (${anytxt.code ?? 'UNKNOWN'})`,
|
|
297
319
|
];
|
|
320
|
+
// The route also reports which link of the Everything chain is missing
|
|
321
|
+
// (`detail`); the page is where a reader can act on that, so the guidance
|
|
322
|
+
// names the cause instead of always asking for a download.
|
|
298
323
|
const guidance = [];
|
|
299
|
-
if (!everything.ok) guidance.push(
|
|
324
|
+
if (!everything.ok) guidance.push(everythingGuidance(t, everything));
|
|
300
325
|
if (!anytxt.ok) guidance.push({ key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt });
|
|
301
326
|
return { text: parts.join(' · '), guidance };
|
|
302
327
|
}
|
|
303
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The one action that would fix a failed Everything probe.
|
|
331
|
+
* @param t - the page's translator
|
|
332
|
+
* @param everything - the route's Everything answer, `detail` included
|
|
333
|
+
* @returns a guidance line, with a download link only when one is the fix
|
|
334
|
+
*/
|
|
335
|
+
function everythingGuidance(t, everything) {
|
|
336
|
+
if (everything.detail === 'not-running') return { key: 'everything', text: t('fixEverythingNotRunning') };
|
|
337
|
+
if (everything.detail === 'not-installed') {
|
|
338
|
+
return { key: 'everything', text: t('fixEverythingNotInstalled'), href: DOWNLOADS.everything };
|
|
339
|
+
}
|
|
340
|
+
if (everything.detail === 'es-missing') {
|
|
341
|
+
return { key: 'everything', text: t('fixEverythingEsMissing'), href: DOWNLOADS.everything };
|
|
342
|
+
}
|
|
343
|
+
return { key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything };
|
|
344
|
+
}
|
|
345
|
+
|
|
304
346
|
/**
|
|
305
347
|
* One section: a small heading, then fields on the official grid.
|
|
306
348
|
* @param props - title, heading id, whether to use the grid, and the fields
|
|
@@ -477,7 +519,9 @@ window.__ModuleLoader__.load({
|
|
|
477
519
|
if (result.guidance.length > 0) {
|
|
478
520
|
probeBlock.push(h('p', { key: 'guidance', style: HINT_STYLE }, result.guidance.map((line, index) => [
|
|
479
521
|
index > 0 ? ' · ' : '',
|
|
480
|
-
|
|
522
|
+
line.href
|
|
523
|
+
? h('a', { key: line.key, href: line.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, line.text)
|
|
524
|
+
: h('span', { key: line.key }, line.text),
|
|
481
525
|
])));
|
|
482
526
|
}
|
|
483
527
|
} else if (probe.status === 'rejected') {
|
|
@@ -569,6 +613,9 @@ window.__ModuleLoader__.load({
|
|
|
569
613
|
exports.ITEM_ID = ITEM_ID;
|
|
570
614
|
exports.STATUS_ROUTE = STATUS_ROUTE;
|
|
571
615
|
exports.ConfigPage = ConfigPage;
|
|
616
|
+
// Exposed so a test can prove these stay equal to the Host's own rules.
|
|
617
|
+
exports.LOOPBACK_HOSTS = LOOPBACK_HOSTS;
|
|
618
|
+
exports.LIMITS = LIMITS;
|
|
572
619
|
exports.en = en;
|
|
573
620
|
exports.zh = zh;
|
|
574
621
|
return module.exports;
|
package/lib/everything.js
CHANGED
|
@@ -165,7 +165,8 @@ export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding =
|
|
|
165
165
|
)
|
|
166
166
|
})
|
|
167
167
|
child.on('close', (code) => {
|
|
168
|
-
|
|
168
|
+
const output = decodeEs(Buffer.concat(stdout), encoding).trim()
|
|
169
|
+
if (isFailedExit(code, output)) {
|
|
169
170
|
const detail = decodeEs(Buffer.concat(stderr), encoding).trim()
|
|
170
171
|
return finish(
|
|
171
172
|
new EngineError(`es.exe exited ${code}${detail ? `: ${detail}` : ''}`, 'EVERYTHING_DOWN', {
|
|
@@ -173,12 +174,26 @@ export function runEsText(args, { esPath, timeoutMs = 15000, signal, encoding =
|
|
|
173
174
|
}),
|
|
174
175
|
)
|
|
175
176
|
}
|
|
176
|
-
finish(undefined,
|
|
177
|
+
finish(undefined, output)
|
|
177
178
|
})
|
|
178
179
|
})
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Whether an es.exe exit means "no answer" rather than "no match".
|
|
184
|
+
*
|
|
185
|
+
* es.exe uses exit 1 both for "nothing matched" and for a failed connection, so
|
|
186
|
+
* the code alone cannot decide; an empty stdout next to stderr text is the
|
|
187
|
+
* failure, anything else is a result.
|
|
188
|
+
* @param {number} code - the process exit code
|
|
189
|
+
* @param {string} output - trimmed stdout
|
|
190
|
+
* @returns true when the call produced no usable answer
|
|
191
|
+
*/
|
|
192
|
+
export function isFailedExit(code, output) {
|
|
193
|
+
return code !== 0 && (code !== 1 || output === '')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Parse es.exe `-json` output into rows, before any exclusion filter. */
|
|
182
197
|
export function parseEsJson(text) {
|
|
183
198
|
if (!text || text.trim().length === 0) return []
|
|
184
199
|
let rows
|
|
@@ -191,7 +206,7 @@ export function parseEsJson(text) {
|
|
|
191
206
|
throw new EngineError('es.exe JSON output was not an array', 'EVERYTHING_BAD_OUTPUT')
|
|
192
207
|
}
|
|
193
208
|
return rows
|
|
194
|
-
.filter((row) => row && typeof row.filename === 'string'
|
|
209
|
+
.filter((row) => row && typeof row.filename === 'string')
|
|
195
210
|
.map((row) => {
|
|
196
211
|
const item = { path: row.filename }
|
|
197
212
|
if (typeof row.size === 'number') item.size = row.size
|
|
@@ -201,13 +216,56 @@ export function parseEsJson(text) {
|
|
|
201
216
|
})
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Drop rows that live in an index we never surface.
|
|
221
|
+
* @param {Array<{path: string}>} rows - parsed rows
|
|
222
|
+
* @returns {Array<{path: string}>} the rows a tool may return
|
|
223
|
+
*/
|
|
224
|
+
export function withoutExcludedPaths(rows) {
|
|
225
|
+
return rows.filter((row) => !EXCLUDED.some((re) => re.test(row.path)))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Candidate Everything.exe locations, in priority order. */
|
|
229
|
+
export function everythingCandidates() {
|
|
230
|
+
const programFiles = process.env.ProgramFiles || 'C:\\Program Files'
|
|
231
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
|
232
|
+
return [`${programFiles}\\Everything\\Everything.exe`, `${programFilesX86}\\Everything\\Everything.exe`]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether Everything itself is installed, independent of es.exe.
|
|
237
|
+
*
|
|
238
|
+
* `sc query` exits non-zero when the service is absent, which is the one signal
|
|
239
|
+
* that survives a localized Windows console. An install outside Program Files
|
|
240
|
+
* (or a portable one) is only found through that service, so a check that could
|
|
241
|
+
* not run at all reports `known: false` instead of claiming "not installed".
|
|
242
|
+
* @returns {{installed: boolean, via?: 'exe'|'service', known: boolean}} install evidence
|
|
243
|
+
*/
|
|
244
|
+
export function everythingInstalled() {
|
|
245
|
+
for (const candidate of everythingCandidates()) {
|
|
246
|
+
if (existsSync(candidate)) return { installed: true, via: 'exe', known: true }
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
execFileSync('sc.exe', ['query', 'Everything'], { encoding: 'utf8', windowsHide: true, timeout: 2000 })
|
|
250
|
+
return { installed: true, via: 'service', known: true }
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// A numeric `status` means sc.exe ran and the service is absent; a spawn
|
|
253
|
+
// failure (EPERM, ENOENT) proves nothing about the machine.
|
|
254
|
+
const ran = typeof error?.status === 'number'
|
|
255
|
+
return { installed: false, known: ran }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
204
259
|
/** Search file names/paths through Everything. */
|
|
205
260
|
export async function searchFiles({ query, dir, limit = 20, esPath, timeoutMs, signal, encoding } = {}) {
|
|
206
261
|
const executable = esPath || resolveEsPath({})
|
|
207
262
|
const args = buildEsArgs({ query, dir, limit })
|
|
208
263
|
const text = await runEsText(args, { esPath: executable, timeoutMs, signal, encoding })
|
|
209
|
-
const
|
|
210
|
-
|
|
264
|
+
const rows = parseEsJson(text)
|
|
265
|
+
// Truncation is decided on what es.exe returned, not on what survived the
|
|
266
|
+
// $RECYCLE.BIN filter: a capped page of excluded hits must not read as a
|
|
267
|
+
// plain "no files found".
|
|
268
|
+
return { items: withoutExcludedPaths(rows), truncated: rows.length >= limit }
|
|
211
269
|
}
|
|
212
270
|
|
|
213
271
|
/** Read the running Everything version. */
|
package/lib/format.js
CHANGED
|
@@ -21,7 +21,13 @@ function modifiedSuffix(modified) {
|
|
|
21
21
|
/** Format the files result (Everything). */
|
|
22
22
|
export function formatFiles(value) {
|
|
23
23
|
const items = Array.isArray(value.items) ? value.items : []
|
|
24
|
-
if (items.length === 0)
|
|
24
|
+
if (items.length === 0) {
|
|
25
|
+
// The cap is applied before the index filter, so an empty window can still
|
|
26
|
+
// mean "more existed": say so instead of a flat "no files found".
|
|
27
|
+
return value.truncated
|
|
28
|
+
? 'No files found in the returned window (the result set was capped; narrow the query).'
|
|
29
|
+
: 'No files found.'
|
|
30
|
+
}
|
|
25
31
|
const header = value.truncated
|
|
26
32
|
? `Found ${items.length} path(s) (capped)`
|
|
27
33
|
: `Found ${items.length} path(s)`
|
|
@@ -57,31 +63,32 @@ export function formatRead(value) {
|
|
|
57
63
|
return `chars ${total}; showing ${offset}..${offset + returned - 1}\n\n${value.text ?? ''}`
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/**
|
|
67
|
+
* One engine's status line.
|
|
68
|
+
*
|
|
69
|
+
* An aborted probe means this call was cancelled, not that the index is down —
|
|
70
|
+
* reporting it as DOWN sends the reader after the wrong problem.
|
|
71
|
+
* @param {string} label - engine label
|
|
72
|
+
* @param {object} status - the probe result
|
|
73
|
+
* @param {(status: object) => string} okDetail - the suffix a healthy engine adds
|
|
74
|
+
* @returns {string} the line
|
|
75
|
+
*/
|
|
76
|
+
function engineLine(label, status, okDetail) {
|
|
77
|
+
if (status.ok) return `${label}: OK${okDetail(status)}`
|
|
78
|
+
if (status.code === 'SKIPPED') return `${label}: SKIPPED${status.message ? ` — ${status.message}` : ''}`
|
|
79
|
+
if (typeof status.code === 'string' && status.code.endsWith('_ABORTED')) {
|
|
80
|
+
return `${label}: ABORTED — ${status.code}`
|
|
81
|
+
}
|
|
82
|
+
return `${label}: DOWN — ${status.code ?? 'UNKNOWN'}${status.message ? `: ${status.message}` : ''}`
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
/** Format both engine statuses and the configured roots. */
|
|
61
86
|
export function formatStatus(value) {
|
|
62
|
-
const lines = [
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
} else if (everything.code === 'SKIPPED') {
|
|
69
|
-
lines.push(`Everything: SKIPPED${everything.message ? ` — ${everything.message}` : ''}`)
|
|
70
|
-
} else {
|
|
71
|
-
lines.push(
|
|
72
|
-
`Everything: DOWN — ${everything.code ?? 'UNKNOWN'}${everything.message ? `: ${everything.message}` : ''}`,
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
const anytxt = value.anytxt ?? {}
|
|
76
|
-
if (anytxt.ok) {
|
|
77
|
-
lines.push(`Anytxt: OK${anytxt.url ? ` at ${anytxt.url}` : ''}`)
|
|
78
|
-
} else if (anytxt.code === 'SKIPPED') {
|
|
79
|
-
lines.push(`Anytxt: SKIPPED${anytxt.message ? ` — ${anytxt.message}` : ''}`)
|
|
80
|
-
} else {
|
|
81
|
-
lines.push(
|
|
82
|
-
`Anytxt: DOWN — ${anytxt.code ?? 'UNKNOWN'}${anytxt.message ? `: ${anytxt.message}` : ''}`,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
87
|
+
const lines = [
|
|
88
|
+
engineLine('Everything', value.everything ?? {}, (status) =>
|
|
89
|
+
`${status.version ? ` (${status.version})` : ''}${status.path ? ` via ${status.path}` : ''}`),
|
|
90
|
+
engineLine('Anytxt', value.anytxt ?? {}, (status) => (status.url ? ` at ${status.url}` : '')),
|
|
91
|
+
]
|
|
85
92
|
const roots = value.roots ?? {}
|
|
86
93
|
lines.push(`workspace=${roots.workspace ?? ''}`)
|
|
87
94
|
lines.push(`work=${roots.work ?? ''}`)
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
|
3
3
|
import { createTools } from './tools.js'
|
|
4
4
|
import { validateConfig } from './config.js'
|
|
5
5
|
import { registerStatusRoute } from './route.js'
|
|
6
|
+
import { MAX_LIMIT, MAX_READ } from './limits.js'
|
|
6
7
|
|
|
7
8
|
/** Cordis plugin name used by loader diagnostics. */
|
|
8
9
|
export const name = 'local-search'
|
|
@@ -24,9 +25,9 @@ export const Config = z.object({
|
|
|
24
25
|
workRoot: z.string().default('').volatile().description('Root for scope=work; required before using that scope.'),
|
|
25
26
|
personalRoot: z.string().default('').volatile().description('Root for scope=personal; required before using that scope.'),
|
|
26
27
|
allRoots: z.string().default('').volatile().description('Semicolon-separated roots for Anytxt scope=all; empty uses C: and D: when present.'),
|
|
27
|
-
maxResults: z.number().min(1).max(
|
|
28
|
+
maxResults: z.number().min(1).max(MAX_LIMIT).default(20).volatile().description(`Default result cap; the hard cap is ${MAX_LIMIT}.`),
|
|
28
29
|
timeoutMs: z.number().default(30000).description('Per-call timeout budget in milliseconds; profile YAML only, because each tool fixes its deadline at registration.'),
|
|
29
|
-
readMaxChars: z.number().min(1).max(
|
|
30
|
+
readMaxChars: z.number().min(1).max(MAX_READ).default(8000).volatile().description(`Default local_search_read window; the hard cap is ${MAX_READ} characters.`),
|
|
30
31
|
esEncoding: z.string().default('gbk').volatile().description('Code page used to decode es.exe output: gbk for a zh-CN console, utf-8 otherwise. Any TextDecoder label is accepted; the page offers these two.'),
|
|
31
32
|
})
|
|
32
33
|
|
|
@@ -48,15 +49,18 @@ export async function apply(ctx, config) {
|
|
|
48
49
|
}
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
// Routing hint, not a tool catalogue: each tool description already says what
|
|
53
|
+
// it is for, so this only orders the choice and names the fallback. Four
|
|
54
|
+
// clauses, ~250 characters — half the previous text for the same decision.
|
|
51
55
|
ctx.effect(() =>
|
|
52
56
|
ctx.systemPrompt.section({
|
|
53
57
|
name: 'tool:local_search',
|
|
54
58
|
order: 106,
|
|
55
59
|
text:
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
+
'Names anywhere outside the workspace: local_search_files. ' +
|
|
61
|
+
'Document text (PDF/Office/EPUB, whole disk): local_search_content, then fragments/read. ' +
|
|
62
|
+
'Workspace code and text: grep/glob. ' +
|
|
63
|
+
'On *_DOWN, fall back to grep/glob and say so.',
|
|
60
64
|
}),
|
|
61
65
|
)
|
|
62
66
|
|
package/lib/limits.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hard caps one call may ask for.
|
|
3
|
+
*
|
|
4
|
+
* The schema caps the saved default, the tools clamp a per-call argument, and
|
|
5
|
+
* the configuration page mirrors the same numbers so a save cannot be rejected.
|
|
6
|
+
* They live here to stay one value; the page cannot import this file (a classic
|
|
7
|
+
* script has no ESM imports), so its copy is checked by a test instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Hard cap on a result count. */
|
|
11
|
+
export const MAX_LIMIT = 100
|
|
12
|
+
|
|
13
|
+
/** Hard cap on characters one read returns. */
|
|
14
|
+
export const MAX_READ = 20000
|
package/lib/probe.js
CHANGED
|
@@ -10,6 +10,23 @@
|
|
|
10
10
|
|
|
11
11
|
import { readConfig } from './config.js'
|
|
12
12
|
import { resolveContentEngine, resolveFilesEngine } from './engines.js'
|
|
13
|
+
import { everythingInstalled } from './everything.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which link of the Everything chain is missing.
|
|
17
|
+
*
|
|
18
|
+
* This detail is for the person reading the configuration page, who can install
|
|
19
|
+
* or start something; the model keeps the two stable codes. When the install
|
|
20
|
+
* check itself could not run, the answer is `unknown` and the page falls back to
|
|
21
|
+
* its generic hint rather than telling someone to install what they already have.
|
|
22
|
+
* @param {string} code - the error code the probe produced
|
|
23
|
+
* @returns {'not-installed'|'not-running'|'es-missing'|'unknown'} the actionable cause
|
|
24
|
+
*/
|
|
25
|
+
export function diagnoseEverything(code, presence = everythingInstalled()) {
|
|
26
|
+
if (!presence.known) return 'unknown'
|
|
27
|
+
if (!presence.installed) return 'not-installed'
|
|
28
|
+
return code === 'EVERYTHING_ES_NOT_FOUND' ? 'es-missing' : 'not-running'
|
|
29
|
+
}
|
|
13
30
|
|
|
14
31
|
/**
|
|
15
32
|
* Probe the configured indexes once, never throwing.
|
|
@@ -18,10 +35,11 @@ import { resolveContentEngine, resolveFilesEngine } from './engines.js'
|
|
|
18
35
|
* @param {string} [options.only] - `auto` probes both; `anytxt` / `everything` skips the other
|
|
19
36
|
* @param {string} [options.workspace] - root Anytxt is asked about
|
|
20
37
|
* @param {string} [options.anytxtUrl] - endpoint to probe instead of the saved one (a page draft)
|
|
38
|
+
* @param {boolean} [options.detail] - add the page-facing cause to a failed engine
|
|
21
39
|
* @param {AbortSignal} [options.signal] - caller cancellation
|
|
22
40
|
* @returns {Promise<{everything: object, anytxt: object}>} both statuses
|
|
23
41
|
*/
|
|
24
|
-
export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal } = {}) {
|
|
42
|
+
export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal, detail = false } = {}) {
|
|
25
43
|
const cfg = readConfig(config)
|
|
26
44
|
const everything = { ok: false }
|
|
27
45
|
const anytxt = { ok: false }
|
|
@@ -42,6 +60,7 @@ export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl
|
|
|
42
60
|
} catch (error) {
|
|
43
61
|
everything.code = error.code || 'EVERYTHING_DOWN'
|
|
44
62
|
everything.message = error.message
|
|
63
|
+
if (detail && everything.code !== 'EVERYTHING_ABORTED') everything.detail = diagnoseEverything(everything.code)
|
|
45
64
|
}
|
|
46
65
|
} else {
|
|
47
66
|
everything.code = 'SKIPPED'
|
package/lib/roots.js
CHANGED
|
@@ -46,6 +46,27 @@ export function validateRoot(root, label = 'root') {
|
|
|
46
46
|
return root
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Drop roots nested inside another root.
|
|
51
|
+
*
|
|
52
|
+
* An AnyTxt search asks once per root and sums the counts, so overlapping roots
|
|
53
|
+
* count the same file twice: the "x of y" header inflates and `truncated` flips
|
|
54
|
+
* early. One query per distinct tree is also cheaper.
|
|
55
|
+
* @param {string[]} roots - validated absolute directories
|
|
56
|
+
* @returns {string[]} the roots that are not inside another one
|
|
57
|
+
*/
|
|
58
|
+
export function dedupeRoots(roots) {
|
|
59
|
+
// Windows accepts either separator, so compare one form while returning the
|
|
60
|
+
// caller's own spelling.
|
|
61
|
+
const normalized = roots.map((root) => root.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase())
|
|
62
|
+
return roots.filter((root, index) => !normalized.some((other, otherIndex) => {
|
|
63
|
+
if (otherIndex === index) return false
|
|
64
|
+
// The same tree spelled twice keeps its first occurrence only.
|
|
65
|
+
if (normalized[index] === other) return otherIndex < index
|
|
66
|
+
return normalized[index].startsWith(`${other}\\`)
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
/**
|
|
50
71
|
* Resolve the directories a call should search.
|
|
51
72
|
*
|
|
@@ -89,5 +110,5 @@ export function rootsFor(scope, explicitPath, config, defaultWorkspace) {
|
|
|
89
110
|
hint: 'Set allRoots to existing directories, or pass an explicit path.',
|
|
90
111
|
})
|
|
91
112
|
}
|
|
92
|
-
return roots
|
|
113
|
+
return dedupeRoots(roots)
|
|
93
114
|
}
|
package/lib/route.js
CHANGED
|
@@ -138,7 +138,9 @@ async function answerStatus(request, response, probe) {
|
|
|
138
138
|
let payload
|
|
139
139
|
let status = 200
|
|
140
140
|
try {
|
|
141
|
-
|
|
141
|
+
// This route only feeds the page, so it may carry the actionable cause the
|
|
142
|
+
// model-facing status keeps out of its surface.
|
|
143
|
+
const probed = await probe({ anytxtUrl: draft.value, detail: true })
|
|
142
144
|
payload = {
|
|
143
145
|
ok: Boolean(probed.everything?.ok) && Boolean(probed.anytxt?.ok),
|
|
144
146
|
checkedAt: Date.now(),
|
package/lib/tools.js
CHANGED
|
@@ -7,10 +7,9 @@ import { parseRef, assertRefEngine } from './ref.js'
|
|
|
7
7
|
import { defaultAllRoots, parseAllRoots, rootsFor } from './roots.js'
|
|
8
8
|
import { readConfig } from './config.js'
|
|
9
9
|
import { probeEngines } from './probe.js'
|
|
10
|
+
import { MAX_LIMIT, MAX_READ } from './limits.js'
|
|
10
11
|
|
|
11
12
|
const STATUS_ENGINES = ['auto', ...new Set([...engineNames().files, ...engineNames().content])]
|
|
12
|
-
const MAX_LIMIT = 100
|
|
13
|
-
const MAX_READ = 20000
|
|
14
13
|
const MAX_FRAGMENTS = 20
|
|
15
14
|
|
|
16
15
|
function toHarnessError(error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fishdivinity/dsh-local-search",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local file and document search for DeepSeek Harness: Everything filenames plus Anytxt document contents, read-only, with a configuration page on the Plugins page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "node --test",
|
|
26
|
-
"check": "node --check lib/index.js && node --check lib/tools.js && node --check lib/engines.js && node --check lib/ref.js && node --check lib/everything.js && node --check lib/anytxt.js && node --check lib/roots.js && node --check lib/format.js && node --check lib/errors.js && node --check lib/config.js && node --check lib/probe.js && node --check lib/route.js && node --check lib/client.js",
|
|
26
|
+
"check": "node --check lib/index.js && node --check lib/tools.js && node --check lib/engines.js && node --check lib/ref.js && node --check lib/everything.js && node --check lib/anytxt.js && node --check lib/roots.js && node --check lib/format.js && node --check lib/errors.js && node --check lib/config.js && node --check lib/limits.js && node --check lib/probe.js && node --check lib/route.js && node --check lib/client.js && node --check scripts/expand-release-links.mjs",
|
|
27
27
|
"pack:check": "npm pack --dry-run"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|