@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 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: 100 },
82
- readMaxChars: { key: 'readMaxChars', kind: 'count', max: 20000 },
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', 'Default result cap for both search tools; 1–100.'],
117
- zh: ['每次结果数', '两个检索工具的默认结果上限;1–100。'],
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)', 'Default window for local_search_read; 1–20000.'],
121
- zh: ['读取窗口(字符)', 'local_search_read 的默认窗口;1–20000。'],
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 host === '127.0.0.1' || host === '::1' || host === 'localhost';
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({ key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything });
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
- h('a', { key: line.key, href: line.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, line.text),
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
- if (code !== 0 && code !== 1) {
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, decodeEs(Buffer.concat(stdout), encoding).trim())
177
+ finish(undefined, output)
177
178
  })
178
179
  })
179
180
  }
180
181
 
181
- /** Parse es.exe `-json` output into bounded, filtered rows. */
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' && !EXCLUDED.some((re) => re.test(row.filename)))
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 items = parseEsJson(text)
210
- return { items, truncated: items.length >= limit }
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) return 'No files found.'
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
- const everything = value.everything ?? {}
64
- if (everything.ok) {
65
- lines.push(
66
- `Everything: OK${everything.version ? ` (${everything.version})` : ''}${everything.path ? ` via ${everything.path}` : ''}`,
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(100).default(20).volatile().description('Default result cap; the hard cap is 100.'),
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(20000).default(8000).volatile().description('Default local_search_read window; the hard cap is 20,000 characters.'),
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
- 'Use local_search_files for file/folder names or paths, especially outside the workspace or across drives. ' +
57
- 'Use local_search_content for text inside PDF/Office/EPUB/TXT/MD documents or a whole-disk content search, then local_search_fragments or local_search_read for detail. ' +
58
- 'Keep grep/glob for source code and plain text already in the workspace. ' +
59
- 'If a search reports EVERYTHING_DOWN or ANYTXT_DOWN, fall back to grep/glob and say that you fell back.',
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
- const probed = await probe({ anytxtUrl: draft.value })
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.0",
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": [