@fishdivinity/dsh-local-search 0.2.0 → 0.3.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/lib/format.js CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Model-facing formatting. Results stay bounded: callers cap items and text
3
- * before these functions run.
2
+ * 面向模型的格式化。结果始终有界:调用方在这些函数跑起来之前就把条目数与文本量卡好了。
4
3
  */
5
4
 
6
- /** Convert a Windows FILETIME (100 ns ticks since 1601) to an ISO string. */
5
+ /** 把 Windows FILETIME(1601 起的 100ns 计数)转成 ISO 字符串。 */
7
6
  export function filetimeToIso(ft) {
8
7
  if (typeof ft !== 'number' || ft <= 0) return undefined
9
8
  const date = new Date(ft / 10000 - 11644473600000)
@@ -18,10 +17,16 @@ function modifiedSuffix(modified) {
18
17
  return typeof modified === 'string' && modified.length > 0 ? `\t${modified}` : ''
19
18
  }
20
19
 
21
- /** Format the files result (Everything). */
20
+ /** 格式化文件名结果(Everything)。 */
22
21
  export function formatFiles(value) {
23
22
  const items = Array.isArray(value.items) ? value.items : []
24
- if (items.length === 0) return 'No files found.'
23
+ if (items.length === 0) {
24
+ // 上限先于排除过滤生效,所以空窗口仍可能意味着「本来还有更多」:要说清楚,而不是干巴巴
25
+ // 一句 "no files found"。
26
+ return value.truncated
27
+ ? 'No files found in the returned window (the result set was capped; narrow the query).'
28
+ : 'No files found.'
29
+ }
25
30
  const header = value.truncated
26
31
  ? `Found ${items.length} path(s) (capped)`
27
32
  : `Found ${items.length} path(s)`
@@ -31,7 +36,7 @@ export function formatFiles(value) {
31
36
  return `${header}\n\n${body}`
32
37
  }
33
38
 
34
- /** Format the content result (Anytxt). */
39
+ /** 格式化内容结果(Anytxt)。 */
35
40
  export function formatContent(value) {
36
41
  const items = Array.isArray(value.items) ? value.items : []
37
42
  if (items.length === 0) return `No content matches (indexed total ${value.total ?? 0}).`
@@ -41,47 +46,47 @@ export function formatContent(value) {
41
46
  return `${header}\n\n${body}${footer}`
42
47
  }
43
48
 
44
- /** Format fragments from one indexed document. */
49
+ /** 格式化一个已索引文档的片段。 */
45
50
  export function formatFragments(value) {
46
51
  const fragments = Array.isArray(value.fragments) ? value.fragments.filter(Boolean) : []
47
52
  return fragments.length > 0 ? fragments.join('\n---\n') : 'No fragments returned.'
48
53
  }
49
54
 
50
- /** Format a document excerpt with its total length. */
55
+ /** 格式化一段文档摘录,并带上它的总长度。 */
51
56
  export function formatRead(value) {
52
57
  const total = typeof value.totalChars === 'number' ? value.totalChars : 0
53
58
  const offset = typeof value.offset === 'number' ? value.offset : 0
54
59
  const returned = typeof value.returned === 'number' ? value.returned : 0
55
60
  if (returned === 0) return `chars ${total}; nothing to show at offset ${offset}`
56
- // The end is inclusive so a reader can subtract the two numbers directly.
61
+ // 终点是闭区间,读的人才能直接拿两个数相减。
57
62
  return `chars ${total}; showing ${offset}..${offset + returned - 1}\n\n${value.text ?? ''}`
58
63
  }
59
64
 
60
- /** Format both engine statuses and the configured roots. */
61
- 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
- )
65
+ /**
66
+ * 一个引擎的状态行。
67
+ *
68
+ * 探测被取消说明这次调用被中止了,不是索引挂了——报成 DOWN 会让人去查错的问题。
69
+ * @param {string} label - 引擎标签
70
+ * @param {object} status - 探测结果
71
+ * @param {(status: object) => string} okDetail - 引擎正常时追加的后缀
72
+ * @returns {string} 这一行
73
+ */
74
+ function engineLine(label, status, okDetail) {
75
+ if (status.ok) return `${label}: OK${okDetail(status)}`
76
+ if (status.code === 'SKIPPED') return `${label}: SKIPPED${status.message ? ` — ${status.message}` : ''}`
77
+ if (typeof status.code === 'string' && status.code.endsWith('_ABORTED')) {
78
+ return `${label}: ABORTED — ${status.code}`
84
79
  }
80
+ return `${label}: DOWN — ${status.code ?? 'UNKNOWN'}${status.message ? `: ${status.message}` : ''}`
81
+ }
82
+
83
+ /** 格式化两个引擎的状态与配置的根。 */
84
+ export function formatStatus(value) {
85
+ const lines = [
86
+ engineLine('Everything', value.everything ?? {}, (status) =>
87
+ `${status.version ? ` (${status.version})` : ''}${status.path ? ` via ${status.path}` : ''}`),
88
+ engineLine('Anytxt', value.anytxt ?? {}, (status) => (status.url ? ` at ${status.url}` : '')),
89
+ ]
85
90
  const roots = value.roots ?? {}
86
91
  lines.push(`workspace=${roots.workspace ?? ''}`)
87
92
  lines.push(`work=${roots.work ?? ''}`)
package/lib/index.js CHANGED
@@ -1,46 +1,73 @@
1
- import z from '@deepseek-ai/schemastery'
2
1
  import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
3
2
  import { createTools } from './tools.js'
4
3
  import { validateConfig } from './config.js'
5
- import { registerStatusRoute } from './route.js'
4
+ import { Config } from './schema.js'
5
+ import { bootstrap } from './bootstrap.js'
6
+ import { describeConfig } from './describe.js'
7
+ import { registerDescribeRoute, registerStatusRoute } from './route.js'
6
8
 
7
- /** Cordis plugin name used by loader diagnostics. */
9
+ /** Loader 诊断里用的 Cordis 插件名。 */
8
10
  export const name = 'local-search'
9
11
 
10
- /** Services required by the tools. */
12
+ /** 工具需要的服务。 */
11
13
  export const inject = ['tools', 'systemPrompt', 'workingDirectory']
12
14
 
13
- // Every field the Plugins page edits is volatile: that is what makes the
14
- // settings namespace served and a page write accepted. timeoutMs stays a
15
- // fixed number because tool registration captures it (see lib/config.js).
16
- // esEncoding stays a plain string on purpose: 0.1 accepted any label and fell
17
- // back to UTF-8 in `decodeEs`, so narrowing it to a union would refuse to
18
- // activate a profile that worked yesterday. The page offers the two labels
19
- // that matter; YAML keeps the old tolerance.
20
- export const Config = z.object({
21
- esPath: z.string().default('').volatile().description('Absolute path to es.exe; empty auto-detects PATH and common install folders.'),
22
- anytxtUrl: z.string().default('http://127.0.0.1:9920').volatile().description('Local Anytxt JSON-RPC endpoint on this machine (127.0.0.1, localhost or [::1]); :9921 is tried as a fallback.'),
23
- workspaceRoot: z.string().default('').volatile().description('Root for scope=workspace; empty uses the session working directory.'),
24
- workRoot: z.string().default('').volatile().description('Root for scope=work; required before using that scope.'),
25
- personalRoot: z.string().default('').volatile().description('Root for scope=personal; required before using that scope.'),
26
- 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
- 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
- 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
- })
15
+ /** profile 行 id,同时也是设置命名空间与卡片 id;三者必须一致。 */
16
+ export const ENTRY_ID = 'fishdivinity-local-search'
32
17
 
33
- export { validateConfig }
18
+ /**
19
+ * 「还在探测」对外最多声称多久。
20
+ *
21
+ * 注入回调只有在 settings 服务出现时才跑;部署里根本没有这个服务时它永远不来,`describe`
22
+ * 就会一直报 `autoconfig.pending`。10 秒足够覆盖正常的 loader 安顿 + 一次探测。
23
+ */
24
+ const PENDING_GIVE_UP_MS = 10000
25
+
26
+ export { Config, validateConfig }
34
27
 
35
28
  /**
36
- * Register the five local-search tools, their routing guidance, and the
37
- * configuration page's probe route.
29
+ * 注册五个本地检索工具、它们的路由提示、首用配置,以及配置页的两个只读接口。
38
30
  * @param {import('@deepseek-ai/cordis').Context} ctx
39
- * @param {object} config - schema output; volatile fields are live references
31
+ * @param {object} config - schema 输出;volatile 字段是活引用
40
32
  */
41
33
  export async function apply(ctx, config) {
42
34
  validateConfig(config, MAX_TIMER_DELAY_MS)
43
35
 
36
+ // 首用配置的进展要能被 describe 读到,所以先建一个就地更新的状态对象,再由后台任务填。
37
+ const autoconfig = { pending: true, ran: false, failures: [] }
38
+ // 没有 settings 的部署里注入回调永远不会跑:给「还在探测」一个上限,免得 /describe 永远这么报。
39
+ const pendingDeadline = setTimeout(() => { autoconfig.pending = false }, PENDING_GIVE_UP_MS)
40
+ ctx.effect(() => () => { clearTimeout(pendingDeadline) })
41
+ ctx.inject(['settings'], (settingsCtx) => {
42
+ settingsCtx.effect(() => {
43
+ // 迁移旧键 → 按需探测 → 写回。
44
+ //
45
+ // 必须等 loader 把每一行都安顿好:settings.describe() 只列已经激活的行,而自己这行在
46
+ // apply 期间还没进入 active 状态,早跑一步就会「找不到可写的命名空间」并静默跳过。
47
+ // dsh-settings 迁移 settings.yaml 用的也是这个时机。
48
+ const loader = ctx.root?.loader
49
+ const settled = typeof loader?.await === 'function' ? loader.await() : Promise.resolve()
50
+ void settled
51
+ .then(() => bootstrap({ settings: settingsCtx.get('settings'), ns: ENTRY_ID, config, logger: ctx.logger }))
52
+ .then((state) => {
53
+ autoconfig.ran = state.ran
54
+ autoconfig.failures = state.failures
55
+ // 写回失败在页面上看不出来:把错误带进 describe,排查时不用猜。
56
+ autoconfig.error = state.error
57
+ if (state.written.length > 0) {
58
+ ctx.logger?.info?.('dsh-local-search: startup write: %s', state.written.join(', '))
59
+ }
60
+ })
61
+ .catch((error) => {
62
+ // 启动配置失败只降级:工具照旧读配置,只是旧键留在原地。
63
+ ctx.logger?.warn?.('dsh-local-search: first-run configuration failed', error)
64
+ })
65
+ .finally(() => {
66
+ autoconfig.pending = false
67
+ })
68
+ }, 'local-search: first-run configuration')
69
+ })
70
+
44
71
  ctx.effect(() => {
45
72
  const disposers = createTools(ctx, config).map((tool) => ctx.tools.register(tool))
46
73
  return () => {
@@ -48,26 +75,36 @@ export async function apply(ctx, config) {
48
75
  }
49
76
  })
50
77
 
78
+ // 这是路由提示,不是工具目录:每个工具的 description 已经说了自己是干什么的,这里只排选择
79
+ // 的顺序并点出回退方案。四句、约 250 字符——同样的决策,字数只有上一版的一半。
51
80
  ctx.effect(() =>
52
81
  ctx.systemPrompt.section({
53
82
  name: 'tool:local_search',
54
83
  order: 106,
55
84
  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.',
85
+ 'Names anywhere outside the workspace: local_search_files. ' +
86
+ 'Document text (PDF/Office/EPUB, whole disk): local_search_content, then fragments/read. ' +
87
+ 'Workspace code and text: grep/glob. ' +
88
+ 'On *_DOWN, fall back to grep/glob and say so.',
60
89
  }),
61
90
  )
62
91
 
63
- // The configuration page's Test control: the browser cannot call the local
64
- // Anytxt JSON-RPC itself, so the Host answers for it. A deployment without a
65
- // web server simply has no Test control and no route.
92
+ // 配置页的两个只读接口:页面不能自己 POST 本机 Anytxt JSON-RPC,也无法知道装了哪些后端,
93
+ // 所以由 Host 代答。没有 web server 的部署就没有这两个接口,也没有页面。
66
94
  ctx.inject(['webServer'], (webCtx) => {
67
95
  const server = webCtx.get('webServer')
68
- webCtx.effect(
69
- () => registerStatusRoute(server, config),
70
- 'local-search: configuration page probe route',
71
- )
96
+ const describe = () => describeConfig({
97
+ pending: autoconfig.pending,
98
+ ran: autoconfig.ran,
99
+ failures: autoconfig.failures,
100
+ })
101
+ webCtx.effect(() => {
102
+ const disposeStatus = registerStatusRoute(server, config)
103
+ const disposeDescribe = registerDescribeRoute(server, describe)
104
+ return () => {
105
+ disposeStatus()
106
+ disposeDescribe()
107
+ }
108
+ }, 'local-search: configuration page routes')
72
109
  })
73
110
  }
package/lib/limits.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 一次调用可以要到的硬上限。
3
+ *
4
+ * schema 卡的是保存下来的默认值,工具卡的是每次调用的参数,配置页镜像同一组数字,保存才不会
5
+ * 被拒。放在一处是为了只有一个值;页面没法 import 这个文件(普通脚本没有 ESM import),所以
6
+ * 它那份副本由一条测试比对。
7
+ */
8
+
9
+ /** 结果条数的硬上限。 */
10
+ export const MAX_LIMIT = 100
11
+
12
+ /** 单次读取返回字符数的硬上限。 */
13
+ export const MAX_READ = 20000
package/lib/probe.js CHANGED
@@ -1,47 +1,60 @@
1
1
  /**
2
- * One probe of both local indexes, shared by the model-facing
3
- * `local_search_status` tool and the configuration page's Test control.
2
+ * 对两个本地索引各探测一次,供面向模型的 `local_search_status` 工具与配置页的测试按钮共用。
4
3
  *
5
- * They answer the same question — "can this machine reach its indexes right
6
- * now?" — so they must not drift into two slightly different probes. The
7
- * probe goes through the engine registry, so a new backend is still one entry
8
- * in `lib/engines.js`.
4
+ * 两边回答的是同一个问题——「这台机器现在能连上它的索引吗?」——所以不能漂成两份略有差异的
5
+ * 探测。探测走引擎注册表,新增后端仍然只是 `lib/engines.js` 里的一条。
9
6
  */
10
7
 
11
8
  import { readConfig } from './config.js'
12
9
  import { resolveContentEngine, resolveFilesEngine } from './engines.js'
10
+ import { everythingInstalled } from './everything.js'
13
11
 
14
12
  /**
15
- * Probe the configured indexes once, never throwing.
16
- * @param {object} config - schema output, volatile references included
13
+ * Everything 这条链上缺的是哪一环。
14
+ *
15
+ * 这个细节是给读配置页的人看的,他能装或能启动点什么;模型只拿两个稳定 code。安装检查本身
16
+ * 就跑不起来时答案是 `unknown`,页面回落到它的通用提示,而不是让一个已经装了的人去安装。
17
+ * @param {string} code - 探测产生的错误码
18
+ * @returns {'not-installed'|'not-running'|'es-missing'|'unknown'} 能照着做的原因
19
+ */
20
+ export function diagnoseEverything(code, presence = everythingInstalled()) {
21
+ if (!presence.known) return 'unknown'
22
+ if (!presence.installed) return 'not-installed'
23
+ return code === 'EVERYTHING_ES_NOT_FOUND' ? 'es-missing' : 'not-running'
24
+ }
25
+
26
+ /**
27
+ * 探测配置的索引一次,永不抛异常。
28
+ * @param {object} config - schema 输出,含 volatile 引用
17
29
  * @param {object} [options]
18
- * @param {string} [options.only] - `auto` probes both; `anytxt` / `everything` skips the other
19
- * @param {string} [options.workspace] - root Anytxt is asked about
20
- * @param {string} [options.anytxtUrl] - endpoint to probe instead of the saved one (a page draft)
21
- * @param {AbortSignal} [options.signal] - caller cancellation
22
- * @returns {Promise<{everything: object, anytxt: object}>} both statuses
30
+ * @param {string} [options.only] - `auto` 两个都探;`anytxt` / `everything` 跳过另一个
31
+ * @param {string} [options.workspace] - 向 Anytxt 询问时用的根
32
+ * @param {string} [options.anytxtUrl] - 要探测的地址,而不是已保存的那个(页面的草稿)
33
+ * @param {boolean} [options.detail] - 给失败的引擎补上页面用的原因
34
+ * @param {AbortSignal} [options.signal] - 调用方的取消
35
+ * @returns {Promise<{everything: object, anytxt: object}>} 两个状态
23
36
  */
24
- export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal } = {}) {
37
+ export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl, signal, detail = false } = {}) {
25
38
  const cfg = readConfig(config)
26
39
  const everything = { ok: false }
27
40
  const anytxt = { ok: false }
28
41
  const budget = Number.isFinite(cfg.timeoutMs) ? cfg.timeoutMs : 5000
29
- // A probe is a diagnostic, not a search: it gets a floor as well as a ceiling,
30
- // because a tiny timeoutMs would report a healthy engine as DOWN.
42
+ // 探测是诊断而不是检索:除了上限还得有下限,否则一个很小的 timeoutMs 会把健康的引擎报成
43
+ // DOWN。
31
44
  const probeBudget = Math.max(1500, Math.min(budget, 5000))
32
45
 
33
46
  if (only !== 'anytxt') {
34
47
  try {
35
48
  Object.assign(everything, await resolveFilesEngine('everything').status({
36
- config: cfg,
37
- esPath: cfg.esPath,
49
+ esPath: cfg.files.everything.esPath,
38
50
  timeoutMs: probeBudget,
39
51
  signal,
40
- encoding: cfg.esEncoding,
52
+ encoding: cfg.files.everything.esEncoding,
41
53
  }))
42
54
  } catch (error) {
43
55
  everything.code = error.code || 'EVERYTHING_DOWN'
44
56
  everything.message = error.message
57
+ if (detail && everything.code !== 'EVERYTHING_ABORTED') everything.detail = diagnoseEverything(everything.code)
45
58
  }
46
59
  } else {
47
60
  everything.code = 'SKIPPED'
@@ -51,8 +64,8 @@ export async function probeEngines(config, { only = 'auto', workspace, anytxtUrl
51
64
  if (only !== 'everything') {
52
65
  try {
53
66
  Object.assign(anytxt, await resolveContentEngine('anytxt').status({
54
- url: anytxtUrl || cfg.anytxtUrl,
55
- root: workspace || cfg.workspaceRoot || process.cwd(),
67
+ url: anytxtUrl || cfg.content.anytxt.url,
68
+ root: workspace || cfg.scopes.workspaceRoot || process.cwd(),
56
69
  timeoutMs: probeBudget,
57
70
  signal,
58
71
  }))
package/lib/ref.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { EngineError } from './errors.js'
2
2
 
3
3
  /**
4
- * Refs are self-describing: `<engine>:<id>`. This keeps a Recoll docid, an
5
- * Everything fid, and a future Windows Search item from colliding when the
6
- * model passes a stale ref back to local_search_read/local_search_fragments.
4
+ * ref 自带引擎名:`<engine>:<id>`。模型把过期的 ref 递回 local_search_read /
5
+ * local_search_fragments 时,Recoll 的 docid、Everything 的 fid 与将来的 Windows Search
6
+ * 条目才不会撞在一起。
7
7
  */
8
8
  export function formatRef(engine, id) {
9
9
  if (!engine || typeof engine !== 'string') {
@@ -15,7 +15,7 @@ export function formatRef(engine, id) {
15
15
  return `${engine}:${String(id)}`
16
16
  }
17
17
 
18
- /** Parse `<engine>:<id>`; reject bare ids with an actionable hint. */
18
+ /** 解析 `<engine>:<id>`;裸 id 直接拒掉,并给一条能照着做的提示。 */
19
19
  export function parseRef(ref) {
20
20
  const text = String(ref ?? '')
21
21
  const separator = text.indexOf(':')
@@ -27,7 +27,7 @@ export function parseRef(ref) {
27
27
  return { engine: text.slice(0, separator), id: text.slice(separator + 1) }
28
28
  }
29
29
 
30
- /** Ensure a ref belongs to the resolved engine. */
30
+ /** 确认 ref 属于已经解析出来的引擎。 */
31
31
  export function assertRefEngine(parsed, engineName) {
32
32
  if (parsed.engine !== engineName) {
33
33
  throw new EngineError(`ref belongs to ${parsed.engine}, not ${engineName}`, 'INVALID_REF', {
package/lib/roots.js CHANGED
@@ -4,7 +4,7 @@ import { EngineError } from './errors.js'
4
4
 
5
5
  export const SCOPES = ['workspace', 'work', 'personal', 'all']
6
6
 
7
- /** Default `all` roots: the system drive plus D: when present. */
7
+ /** `all` 的默认根:系统盘,加上存在时的 D:。 */
8
8
  export function defaultAllRoots() {
9
9
  const roots = []
10
10
  const systemDrive = process.env.SystemDrive || 'C:'
@@ -16,7 +16,7 @@ export function defaultAllRoots() {
16
16
  return roots
17
17
  }
18
18
 
19
- /** Parse the semicolon-separated `allRoots` config. */
19
+ /** 解析分号分隔的 `allRoots` 配置。 */
20
20
  export function parseAllRoots(value) {
21
21
  return String(value || '')
22
22
  .split(';')
@@ -24,7 +24,7 @@ export function parseAllRoots(value) {
24
24
  .filter(Boolean)
25
25
  }
26
26
 
27
- /** Validate one existing absolute directory root. */
27
+ /** 校验一个已存在的绝对目录根。 */
28
28
  export function validateRoot(root, label = 'root') {
29
29
  if (!root || typeof root !== 'string' || !isAbsolute(root)) {
30
30
  throw new EngineError(`${label} must be an absolute directory: ${root ?? '(empty)'}`, 'ROOT_NOT_ABSOLUTE', {
@@ -36,8 +36,8 @@ export function validateRoot(root, label = 'root') {
36
36
  hint: `Set ${label} to an existing directory.`,
37
37
  })
38
38
  }
39
- // A file passes existsSync but makes es.exe -path and Anytxt filterDir return
40
- // nothing, which reads as "no results" instead of a configuration mistake.
39
+ // 文件也能过 existsSync,但会让 es.exe -path 与 Anytxt 的 filterDir 返回空:读起来像
40
+ // 「没有结果」,而不是配置写错了。
41
41
  if (!statSync(root).isDirectory()) {
42
42
  throw new EngineError(`${label} is not a directory: ${root}`, 'ROOT_NOT_DIRECTORY', {
43
43
  hint: `Set ${label} to a directory, not a file.`,
@@ -47,11 +47,29 @@ export function validateRoot(root, label = 'root') {
47
47
  }
48
48
 
49
49
  /**
50
- * Resolve the directories a call should search.
50
+ * 丢掉嵌套在另一个根里的根。
51
51
  *
52
- * `workspace` falls back to the session's default directory; `work` and
53
- * `personal` require explicit config; `all` uses `allRoots` or the C:/D:
54
- * defaults.
52
+ * Anytxt 的检索是每个根问一次再把 count 相加,重叠的根会把同一个文件数两遍:「x of y」的
53
+ * 表头虚高,`truncated` 也提前翻真。每棵独立的树只查一次同样更省。
54
+ * @param {string[]} roots - 已校验的绝对目录
55
+ * @returns {string[]} 不在另一个根里的那些根
56
+ */
57
+ export function dedupeRoots(roots) {
58
+ // Windows 两种分隔符都认,所以比较时统一成一种写法,返回时仍用调用方自己的写法。
59
+ const normalized = roots.map((root) => root.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase())
60
+ return roots.filter((root, index) => !normalized.some((other, otherIndex) => {
61
+ if (otherIndex === index) return false
62
+ // 同一棵树被写了两遍时只留第一次出现的那个。
63
+ if (normalized[index] === other) return otherIndex < index
64
+ return normalized[index].startsWith(`${other}\\`)
65
+ }))
66
+ }
67
+
68
+ /**
69
+ * 解析一次调用该搜哪些目录。
70
+ *
71
+ * `workspace` 回落到会话的默认目录;`work` 与 `personal` 必须显式配置;`all` 用 `allRoots`
72
+ * 或 C:/D: 的默认值。
55
73
  */
56
74
  export function rootsFor(scope, explicitPath, config, defaultWorkspace) {
57
75
  const normalized = scope || 'workspace'
@@ -64,30 +82,30 @@ export function rootsFor(scope, explicitPath, config, defaultWorkspace) {
64
82
  return [validateRoot(explicitPath, 'path')]
65
83
  }
66
84
  if (normalized === 'workspace') {
67
- return [validateRoot(config.workspaceRoot || defaultWorkspace || process.cwd(), 'workspaceRoot')]
85
+ return [validateRoot(config?.scopes?.workspaceRoot || defaultWorkspace || process.cwd(), 'workspaceRoot')]
68
86
  }
69
87
  if (normalized === 'work') {
70
- if (!config.workRoot) {
88
+ if (!config?.scopes?.workRoot) {
71
89
  throw new EngineError('workRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
72
90
  hint: 'Set workRoot in the plugin config, or pass an explicit path.',
73
91
  })
74
92
  }
75
- return [validateRoot(config.workRoot, 'workRoot')]
93
+ return [validateRoot(config.scopes.workRoot, 'workRoot')]
76
94
  }
77
95
  if (normalized === 'personal') {
78
- if (!config.personalRoot) {
96
+ if (!config?.scopes?.personalRoot) {
79
97
  throw new EngineError('personalRoot is not configured', 'SCOPE_NOT_CONFIGURED', {
80
98
  hint: 'Set personalRoot in the plugin config, or pass an explicit path.',
81
99
  })
82
100
  }
83
- return [validateRoot(config.personalRoot, 'personalRoot')]
101
+ return [validateRoot(config.scopes.personalRoot, 'personalRoot')]
84
102
  }
85
- const configured = parseAllRoots(config.allRoots)
103
+ const configured = parseAllRoots(config?.scopes?.allRoots)
86
104
  const roots = (configured.length > 0 ? configured : defaultAllRoots()).filter((root) => existsSync(root))
87
105
  if (roots.length === 0) {
88
106
  throw new EngineError('no allRoots are available', 'SCOPE_NOT_CONFIGURED', {
89
107
  hint: 'Set allRoots to existing directories, or pass an explicit path.',
90
108
  })
91
109
  }
92
- return roots
110
+ return dedupeRoots(roots)
93
111
  }