@deepseek-ai/dsh-file-reference-local 0.1.1-rc.2 → 0.1.2-alpha.2

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/context/file-reference-local/README.md
5
- README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e
6
- README.zh.md: beded13250daf041294e4e2656d0e1374407ff94
5
+ README.md: da5db0882e9f4538bdd901a970eca98aac10bbde
6
+ README.zh.md: cea38d335c8b29ddc3c875782cb62df1d0ae6e0d
package/README.md CHANGED
@@ -1,24 +1,102 @@
1
- # `@deepseek-ai/dsh-file-reference-local`
1
+ ---
2
+ description: "Local-workspace @file completion provider for users and maintainers enabling, sizing, or debugging ctx.fileReferences discovery."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-file-reference-local
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Local-filesystem implementation of `ctx.fileReferences`. It maintains one bounded `WorkspaceFileSearch` per agent, rooted at that session's `cwd` and falling back to the host process cwd. The index ranks direct directory listings for queries containing `/`, otherwise fuzzy-ranks a bounded recursive index; it never follows directory symlinks.
10
+ ## Summary
11
+
12
+ Agents and their host UIs get ranked path candidates for `@file` mentions, scoped to each agent's workspace and bounded so even large repositories stay responsive. `dsh-file-reference-local` implements `ctx.fileReferences` for the local filesystem: it keeps one reusable search index per agent, rebuilds it in the background after tool results so completion reflects workspace changes without stalling, and never follows directory symlinks. When the addressed agent can call `read`, it also installs a stable one-sentence guidance into the system prompt. Choose it when the agent's `read` tool operates on the Harness host filesystem; remote or virtual namespaces need a provider whose discovery matches the tool.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount this provider when `@file` completion should discover the Harness host's own filesystem — the namespace the shipped `read` tool operates on. Each agent's workspace is indexed from its session working directory, falling back to the host process directory when the session has none.
29
+
30
+ ### Enabling the provider
31
+
32
+ The defaults suit a typical workspace, so the minimal mount needs no configuration:
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-file-reference-local'
36
+ config:
37
+ maxResults: 20
38
+ ```
39
+
40
+ ### What you get
41
+
42
+ Typing `@` in a host UI returns up to `maxResults` ranked path candidates for the addressed agent. A query containing `/` lists the matching directory's entries directly; a bare query fuzzy-ranks the bounded recursive index. Directory candidates keep the mention open with a trailing slash. After any tool result the agent's index is marked stale: the next query still answers from it and its replacement builds in the background, so a rebuild never sits in front of the caret.
43
+
44
+ ### Configuration
45
+
46
+ | Field | Default | Meaning |
47
+ |---|---|---|
48
+ | `maxResults` | `20` | Maximum ranked candidates returned for one query |
49
+ | `maxEntries` | `50000` | Maximum files and directories indexed per agent workspace |
50
+ | `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates |
51
+
52
+ Every numeric value must be a positive safe integer, and every excluded name must be a non-empty basename without `/` or `\`.
53
+
54
+ -----
55
+
56
+ <a id="understand-the-implementation"></a>
57
+ ## Understand the implementation
6
58
 
7
- Tool-result events invalidate the addressed agent's reusable index so later completion observes likely workspace mutations. Agent disposal releases that index and its scoped prompt contribution; plugin disposal awaits every prompt fiber and releases all cached searches.
59
+ <details>
60
+ <summary>Implementation internals — click to expand</summary>
8
61
 
9
- ## Configuration
62
+ This section explains the design of the provider; the observable behavior is covered in [Use this package](#use-this-package).
10
63
 
11
- | Key | Default | Contract |
12
- |---|---:|---|
13
- | `maxResults` | `20` | Maximum ranked candidates returned for one query. |
14
- | `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace. |
15
- | `excludedDirectories` | `[".git", "node_modules"]` | Directory basenames omitted from traversal and candidates. |
64
+ ### Design concept
16
65
 
17
- Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`.
66
+ The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal. Only a workspace's first bare query waits for that traversal; a `tool/result` event marks the settled entries stale, and the next bare query serves them while the replacement builds. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber.
18
67
 
68
+ ### Source map
69
+
70
+ | File | Role |
71
+ |---|---|
72
+ | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`: config validation, per-agent searches, prompt install |
73
+ | [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, staleness and background rebuild |
74
+ | [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract |
75
+
76
+ ### Main flow
77
+
78
+ A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree. An unreadable or excluded subtree contributes no candidates, while an unreadable root fails its traversal instead: a transient failure must not replace still-good entries with an empty index.
79
+
80
+ </details>
81
+
82
+ -----
83
+
84
+ <a id="further-exploration"></a>
85
+ ## Further Exploration
86
+
87
+ Read these pages when the package-level contract is not enough. They move from the seam this provider implements to the tools its candidates point at.
88
+
89
+ - [File-reference seam](../file-reference/README.md) — the service contract and `@file` grammar this provider implements.
90
+ - [Session-reference subsystem](../../../docs/subsystems/session-reference.md) — the shared file-reference contract behind host UIs.
91
+ - [Filesystem tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs) — the `read` tool whose namespace discovery must match.
92
+ - [Context group map](../README.md) — sibling request-context packages.
93
+
94
+ -----
95
+
96
+ <a id="model-experience"></a>
19
97
  ## Model Experience
20
98
 
21
- ### File-reference guidance when `read` is available
99
+ ### File-reference guidance when read is available
22
100
 
23
101
  #### What the model sees
24
102
 
@@ -27,7 +105,7 @@ When the addressed agent has an effective `read` tool, the provider contributes
27
105
  ##### File-reference instruction
28
106
 
29
107
  ```markdown
30
- Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
108
+ Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.
31
109
  ```
32
110
 
33
111
  #### Token effect
@@ -36,10 +114,26 @@ Conditional and fixed: the one sentence is present while `read` is visible to th
36
114
 
37
115
  #### KV Cache effect
38
116
 
39
- The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not.
117
+ The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index staleness do not.
40
118
 
41
119
  ## Known Limitations and Deferred Work
42
120
 
121
+ <a id="known-limitations-and-deferred-work"></a>
122
+
123
+
124
+ These limits define when the provider is a poor fit. They are current package constraints.
125
+
43
126
  - **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool.
44
- - **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear.
127
+ - **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name only build outputs no ecosystem also uses for sources; `lib` is deliberately absent, so a workspace that builds into it adds that name through `excludedDirectories`.
128
+ - **One invalidation of staleness** — a bare query answered right after a tool result reflects the tree as of the previous traversal; the following query sees the rebuild.
45
129
  - **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded.
130
+
131
+ <a id="dev-note"></a>
132
+ ### Dev Note
133
+
134
+ <details>
135
+ <summary>Working context for maintainers — click to expand</summary>
136
+
137
+ None.
138
+
139
+ </details>
package/README.zh.md CHANGED
@@ -1,45 +1,139 @@
1
- # `@deepseek-ai/dsh-file-reference-local`
1
+ ---
2
+ description: "面向用户与维护者的本地工作区 @file 补全提供方,用于启用、设置大小或排查 ctx.fileReferences 的发现能力。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-file-reference-local
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- `ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。
10
+ ## 概述
11
+
12
+ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选,范围限定在各自 agent 的工作区,并有界以保证大型仓库依然响应迅速。`dsh-file-reference-local` 在本地文件系统上实现 `ctx.fileReferences`:它为每个 agent 维护一个可复用的搜索索引,在工具结果后于后台重建索引,让补全反映工作区变化而不发生停顿,且从不跟随目录符号链接。当指定 agent 可以调用 `read` 时,它还会向系统提示词安装一句稳定指引。当 agent 的 `read` 工具作用于 Harness 宿主文件系统时选择它;远程或虚拟命名空间需要发现能力与工具一致的提供方。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 当 `@file` 补全应发现 Harness 宿主自身的文件系统——即随附 `read` 工具所操作的命名空间——时,挂载此提供方。每个 agent 的工作区从该会话的工作目录开始建立索引;会话没有工作目录时回退到宿主进程目录。
29
+
30
+ ### 启用提供方
31
+
32
+ 默认设置适合典型工作区,因此最小挂载无需任何配置:
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-file-reference-local'
36
+ config:
37
+ maxResults: 20
38
+ ```
39
+
40
+ ### 你能得到什么
41
+
42
+ 在宿主 UI 中输入 `@` 会为指定 agent 返回至多 `maxResults` 个排序路径候选。包含 `/` 的查询直接列出匹配目录的条目;裸查询对有界递归索引做模糊排序。目录候选以尾斜杠保持 mention 开放。任何工具结果之后,该 agent 的索引会被标记为陈旧:下一次查询仍由它作答,其替代品在后台构建,因此重建不会挡在光标前面。
43
+
44
+ ### 配置
45
+
46
+ | 字段 | 默认值 | 含义 |
47
+ |---|---|---|
48
+ | `maxResults` | `20` | 单次查询返回的排序候选最大数量 |
49
+ | `maxEntries` | `50000` | 每个 agent 工作区建立索引的文件与目录最大数量 |
50
+ | `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 |
51
+
52
+ 所有数值都必须是正的安全整数,所有排除名都必须是不含 `/` 或 `\` 的非空基名。
53
+
54
+ -----
55
+
56
+ <a id="understand-the-implementation"></a>
57
+ ## 理解实现
6
58
 
7
- 工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。
59
+ <details>
60
+ <summary>实现细节——点击展开</summary>
8
61
 
9
- ## 配置
62
+ 本节解释提供方的设计;可观察行为见[使用本包](#use-this-package)。
10
63
 
11
- | 配置键 | 默认值 | 契约 |
12
- |---|---:|---|
13
- | `maxResults` | `20` | 单次查询返回的候选项最大数量。 |
14
- | `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 |
15
- | `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 |
64
+ ### 设计理念
16
65
 
17
- 所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` `\`。
66
+ 提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历。只有一个工作区的首次裸查询会等待该遍历;`tool/result` 事件把已完成的条目标记为陈旧,下一次裸查询在替代品构建期间继续由它作答。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent 释放时会同时释放索引与提示词 fiber。
18
67
 
68
+ ### 源码地图
69
+
70
+ | 文件 | 职责 |
71
+ |---|---|
72
+ | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`:配置校验、按 agent 搜索、提示词安装 |
73
+ | [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、陈旧标记与后台重建 |
74
+ | [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 |
75
+
76
+ ### 主要流程
77
+
78
+ `list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树。不可读或已排除的子目录不贡献候选,而不可读的根目录则让该次遍历失败:一次瞬时故障不得用空索引覆盖仍然有效的条目。
79
+
80
+ </details>
81
+
82
+ -----
83
+
84
+ <a id="further-exploration"></a>
85
+ ## 进一步探索
86
+
87
+ 包级约定不够用时阅读以下页面。它们从本提供方所实现的 seam 进入其候选所指向的工具。
88
+
89
+ - [文件引用 seam](../file-reference/README.zh.md)——本提供方所实现的服务约定与 `@file` 语法。
90
+ - [会话引用子系统](../../../docs/subsystems/session-reference.zh.md)——宿主 UI 背后的共享文件引用约定。
91
+ - [文件系统工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-fs)——发现能力必须匹配其命名空间的 `read` 工具。
92
+ - [context 组地图](../README.zh.md)——相邻的请求上下文包。
93
+
94
+ -----
95
+
96
+ <a id="model-experience"></a>
19
97
  ## 模型体验
20
98
 
21
99
  ### `read` 可用时的文件引用指引
22
100
 
23
- #### 模型看到什么
101
+ #### 模型看到的内容
24
102
 
25
103
  当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段:
26
104
 
27
105
  ##### 文件引用指令
28
106
 
29
107
  ```markdown
30
- Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
108
+ Tokens prefixed with @ are workspace paths the user explicitly referenced, relative to the workspace root. A trailing slash marks a directory: list it when its contents matter. Anything else is a file: use the read tool when its contents are needed, and do not claim to have inspected it before reading. @"..." quotes a path containing spaces.
31
109
  ```
32
110
 
33
111
  #### Token 影响
34
112
 
35
113
  该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。
36
114
 
37
- #### KV 缓存影响
115
+ #### KV Cache 影响
116
+
117
+ 该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引陈旧标记不会改变前缀。
118
+
119
+ ## 已知限制与延期工作
120
+
121
+ <a id="known-limitations-and-deferred-work"></a>
38
122
 
39
- 该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。
40
123
 
41
- ## 已知限制与暂缓事项
124
+ 这些限制说明该提供方何时不合适。它们是当前包约束。
42
125
 
43
126
  - **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。
44
- - **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。
127
+ - **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项只列没有任何生态用作源码目录的构建产物;`lib` 被刻意排除在外,因此构建进 `lib` 的工作区需通过 `excludedDirectories` 自行加上。
128
+ - **一次失效的陈旧窗口**:紧接工具结果之后的模糊查询反映的是上一次遍历时的目录树;下一次查询才看到重建结果。
45
129
  - **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。
130
+
131
+ <a id="dev-note"></a>
132
+ ### 开发备注
133
+
134
+ <details>
135
+ <summary>维护者的工作上下文——点击展开</summary>
136
+
137
+ 无。
138
+
139
+ </details>
package/lib/index.js CHANGED
@@ -14,19 +14,51 @@ import { activeAtToken, formatFileMention } from "@deepseek-ai/dsh-file-referenc
14
14
  /** Default maximum file and directory candidates rendered for one query. */
15
15
  const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
16
16
  /** Default maximum entries retained in one workspace search index. */
17
- const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 1e4;
18
- /** Directory basenames omitted from traversal unless the deployment overrides them. */
19
- const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [".git", "node_modules"];
17
+ const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 5e4;
18
+ /**
19
+ * Directory basenames omitted from traversal unless the deployment overrides
20
+ * them: version-control and dependency stores plus build-output names that no
21
+ * ecosystem also uses for sources. Generated files carry the basenames of the
22
+ * sources that produced them, so an unfiltered tree both spends the entry
23
+ * budget twice and ranks `dist/x.js` beside `src/x.ts` for every query.
24
+ *
25
+ * `lib` is deliberately absent: Ruby gems and many npm packages keep their
26
+ * sources there, and excluding it would make `@` miss those sources entirely
27
+ * and silently. A workspace that builds into `lib` adds it through
28
+ * `excludedDirectories`.
29
+ */
30
+ const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [
31
+ ".git",
32
+ "node_modules",
33
+ "dist",
34
+ "build",
35
+ "out",
36
+ "coverage",
37
+ "target",
38
+ ".next",
39
+ ".nuxt",
40
+ ".turbo",
41
+ ".venv",
42
+ "__pycache__",
43
+ ".pytest_cache",
44
+ ".mypy_cache",
45
+ ".gradle"
46
+ ];
20
47
  /**
21
48
  * Cancellable, reusable fuzzy index rooted at one agent working directory.
22
49
  * Directory-scoped queries list live state; bare fuzzy queries share one
23
- * bounded traversal until the `@` interaction ends or a tool result invalidates it.
50
+ * bounded traversal. Only the first query of a workspace waits for that
51
+ * traversal — an invalidated index keeps answering while its replacement
52
+ * builds behind the caret.
24
53
  */
25
54
  var WorkspaceFileSearch = class {
26
55
  root;
27
56
  config;
28
57
  excludedDirectories;
58
+ settled;
29
59
  generation;
60
+ /** Monotonic invalidation counter; a settled index below it is stale. */
61
+ invalidations = 0;
30
62
  disposed = false;
31
63
  constructor(root, config) {
32
64
  this.root = root;
@@ -52,28 +84,61 @@ var WorkspaceFileSearch = class {
52
84
  const fragment = slash < 0 ? "" : query.slice(slash + 1);
53
85
  return this.listDirectory(directory, fragment, signal);
54
86
  }
55
- return rankCandidates((await waitForPromise(this.ensureIndex(), signal)).filter((candidate) => visibleForGlobalQuery(candidate.path, query)), query, this.config.maxResults);
87
+ return rankCandidates((await this.indexFor(signal)).filter((candidate) => visibleForGlobalQuery(candidate.path, query)), query, this.config.maxResults);
56
88
  }
57
- /** Discard the current index so the next bare query observes a fresh tree. */
89
+ /**
90
+ * Mark the index stale so a later bare query observes a fresh tree.
91
+ *
92
+ * The stale entries are kept and keep answering: a rebuild costs one
93
+ * traversal of the whole workspace, and putting that in front of the caret
94
+ * is what a caller invalidating on every tool result would otherwise pay.
95
+ */
58
96
  invalidate() {
59
- this.generation?.controller.abort(/* @__PURE__ */ new Error("file search index invalidated"));
60
- this.generation = void 0;
97
+ this.invalidations += 1;
61
98
  }
62
99
  /** Abort traversal and make later queries return no candidates. */
63
100
  dispose() {
64
101
  if (this.disposed) return;
65
102
  this.disposed = true;
66
- this.invalidate();
103
+ this.generation?.controller.abort(/* @__PURE__ */ new Error("file search index disposed"));
104
+ this.generation = void 0;
105
+ this.settled = void 0;
106
+ }
107
+ /**
108
+ * The entries a bare fuzzy query ranks. Only the first query of a workspace
109
+ * waits for a traversal; afterwards a stale index answers immediately and
110
+ * its replacement builds in the background.
111
+ * @param signal - cancels this caller's wait without killing a shared traversal.
112
+ * @returns indexed paths, at most one invalidation behind the tree.
113
+ */
114
+ async indexFor(signal) {
115
+ const settled = this.settled;
116
+ if (settled === void 0) return waitForPromise(this.ensureIndex(), signal);
117
+ if (settled.startedAt < this.invalidations) this.ensureIndex().catch(() => {});
118
+ return settled.entries;
67
119
  }
68
120
  ensureIndex() {
69
121
  if (this.generation !== void 0) return this.generation.promise;
70
122
  const controller = new AbortController();
123
+ const startedAt = this.invalidations;
71
124
  const generation = {
72
125
  controller,
73
126
  promise: Promise.resolve([])
74
127
  };
75
- generation.promise = this.scanWorkspace(controller.signal).catch((error) => {
76
- /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
128
+ generation.promise = this.scanWorkspace(controller.signal).then((entries) => {
129
+ /* v8 ignore next -- disposal aborts this traversal, so it reaches the
130
+ * rejection handler instead; the guard only covers a scan that finished
131
+ * its last directory in the instant before the abort landed, and must
132
+ * not hand a disposed index its entries back. */
133
+ if (this.disposed) return entries;
134
+ this.generation = void 0;
135
+ this.settled = {
136
+ entries,
137
+ startedAt
138
+ };
139
+ return entries;
140
+ }, (error) => {
141
+ /* v8 ignore next -- dispose clears `generation` synchronously; this only protects an unexpected scan failure */
77
142
  if (this.generation === generation) this.generation = void 0;
78
143
  throw error;
79
144
  });
@@ -91,7 +156,7 @@ var WorkspaceFileSearch = class {
91
156
  const directory = directories[cursor];
92
157
  /* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
93
158
  if (directory === void 0) throw new Error("file search selected a missing directory");
94
- const entries = await readDirectory(directory.absolute, signal);
159
+ const entries = cursor === 0 ? await readWorkspaceRoot(directory.absolute, signal) : await readDirectory(directory.absolute, signal);
95
160
  for (const entry of entries) {
96
161
  signal.throwIfAborted();
97
162
  const path = directory.relative === "" ? entry.name : `${directory.relative}/${entry.name}`;
@@ -158,6 +223,12 @@ async function resolveDisplayDirectory(root, displayDirectory, signal) {
158
223
  }
159
224
  return absolute;
160
225
  }
226
+ async function readWorkspaceRoot(absolute, signal) {
227
+ signal.throwIfAborted();
228
+ const entries = await readdir(absolute, { withFileTypes: true });
229
+ signal.throwIfAborted();
230
+ return entries.sort((left, right) => compareText(left.name, right.name));
231
+ }
161
232
  async function readDirectory(absolute, signal) {
162
233
  signal.throwIfAborted();
163
234
  try {
@@ -260,7 +331,7 @@ var LocalFileReferenceService = class extends FileReferenceService {
260
331
  super(ctx);
261
332
  this.config = {
262
333
  maxResults: config.maxResults ?? 20,
263
- maxEntries: config.maxEntries ?? 1e4,
334
+ maxEntries: config.maxEntries ?? 5e4,
264
335
  excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES
265
336
  };
266
337
  validateConfig(this.config);
@@ -269,7 +340,7 @@ var LocalFileReferenceService = class extends FileReferenceService {
269
340
  const fiber = agent.ctx.inject(["systemPrompt", "tools"], (scope) => {
270
341
  scope.systemPrompt.section({
271
342
  name: "context:file-reference",
272
- order: 99,
343
+ order: scope.systemPrompt.getSectionOrder("FILE_REFERENCE"),
273
344
  text: () => agent.ctx.tools.get("read", agent) === void 0 ? "" : FILE_REFERENCE_PROMPT$1
274
345
  });
275
346
  });
@@ -35,7 +35,7 @@ export class LocalFileReferenceService extends FileReferenceService {
35
35
  const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
36
36
  scope.systemPrompt.section({
37
37
  name: 'context:file-reference',
38
- order: 99,
38
+ order: scope.systemPrompt.getSectionOrder('FILE_REFERENCE'),
39
39
  text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
40
40
  });
41
41
  });
@@ -10,9 +10,20 @@ export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-referenc
10
10
  /** Default maximum file and directory candidates rendered for one query. */
11
11
  export declare const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
12
12
  /** Default maximum entries retained in one workspace search index. */
13
- export declare const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10000;
14
- /** Directory basenames omitted from traversal unless the deployment overrides them. */
15
- export declare const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES: readonly [".git", "node_modules"];
13
+ export declare const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50000;
14
+ /**
15
+ * Directory basenames omitted from traversal unless the deployment overrides
16
+ * them: version-control and dependency stores plus build-output names that no
17
+ * ecosystem also uses for sources. Generated files carry the basenames of the
18
+ * sources that produced them, so an unfiltered tree both spends the entry
19
+ * budget twice and ranks `dist/x.js` beside `src/x.ts` for every query.
20
+ *
21
+ * `lib` is deliberately absent: Ruby gems and many npm packages keep their
22
+ * sources there, and excluding it would make `@` miss those sources entirely
23
+ * and silently. A workspace that builds into `lib` adds it through
24
+ * `excludedDirectories`.
25
+ */
26
+ export declare const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES: readonly [".git", "node_modules", "dist", "build", "out", "coverage", "target", ".next", ".nuxt", ".turbo", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".gradle"];
16
27
  /** Resolved limits and exclusions for one workspace index. */
17
28
  export interface FileSearchConfig {
18
29
  /** Maximum ranked candidates returned for one query. */
@@ -25,13 +36,18 @@ export interface FileSearchConfig {
25
36
  /**
26
37
  * Cancellable, reusable fuzzy index rooted at one agent working directory.
27
38
  * Directory-scoped queries list live state; bare fuzzy queries share one
28
- * bounded traversal until the `@` interaction ends or a tool result invalidates it.
39
+ * bounded traversal. Only the first query of a workspace waits for that
40
+ * traversal — an invalidated index keeps answering while its replacement
41
+ * builds behind the caret.
29
42
  */
30
43
  export declare class WorkspaceFileSearch {
31
44
  private readonly root;
32
45
  private readonly config;
33
46
  private readonly excludedDirectories;
47
+ private settled;
34
48
  private generation;
49
+ /** Monotonic invalidation counter; a settled index below it is stale. */
50
+ private invalidations;
35
51
  private disposed;
36
52
  constructor(root: string, config: FileSearchConfig);
37
53
  /**
@@ -41,10 +57,24 @@ export declare class WorkspaceFileSearch {
41
57
  * @returns at most `maxResults` deterministic candidates.
42
58
  */
43
59
  list(rawQuery: string, signal: AbortSignal): Promise<FileReferenceCandidate[]>;
44
- /** Discard the current index so the next bare query observes a fresh tree. */
60
+ /**
61
+ * Mark the index stale so a later bare query observes a fresh tree.
62
+ *
63
+ * The stale entries are kept and keep answering: a rebuild costs one
64
+ * traversal of the whole workspace, and putting that in front of the caret
65
+ * is what a caller invalidating on every tool result would otherwise pay.
66
+ */
45
67
  invalidate(): void;
46
68
  /** Abort traversal and make later queries return no candidates. */
47
69
  dispose(): void;
70
+ /**
71
+ * The entries a bare fuzzy query ranks. Only the first query of a workspace
72
+ * waits for a traversal; afterwards a stale index answers immediately and
73
+ * its replacement builds in the background.
74
+ * @param signal - cancels this caller's wait without killing a shared traversal.
75
+ * @returns indexed paths, at most one invalidation behind the tree.
76
+ */
77
+ private indexFor;
48
78
  private ensureIndex;
49
79
  private scanWorkspace;
50
80
  private listDirectory;
@@ -11,19 +11,51 @@ export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-referenc
11
11
  /** Default maximum file and directory candidates rendered for one query. */
12
12
  export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
13
13
  /** Default maximum entries retained in one workspace search index. */
14
- export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000;
15
- /** Directory basenames omitted from traversal unless the deployment overrides them. */
16
- export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'];
14
+ export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50_000;
15
+ /**
16
+ * Directory basenames omitted from traversal unless the deployment overrides
17
+ * them: version-control and dependency stores plus build-output names that no
18
+ * ecosystem also uses for sources. Generated files carry the basenames of the
19
+ * sources that produced them, so an unfiltered tree both spends the entry
20
+ * budget twice and ranks `dist/x.js` beside `src/x.ts` for every query.
21
+ *
22
+ * `lib` is deliberately absent: Ruby gems and many npm packages keep their
23
+ * sources there, and excluding it would make `@` miss those sources entirely
24
+ * and silently. A workspace that builds into `lib` adds it through
25
+ * `excludedDirectories`.
26
+ */
27
+ export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [
28
+ '.git',
29
+ 'node_modules',
30
+ 'dist',
31
+ 'build',
32
+ 'out',
33
+ 'coverage',
34
+ 'target',
35
+ '.next',
36
+ '.nuxt',
37
+ '.turbo',
38
+ '.venv',
39
+ '__pycache__',
40
+ '.pytest_cache',
41
+ '.mypy_cache',
42
+ '.gradle',
43
+ ];
17
44
  /**
18
45
  * Cancellable, reusable fuzzy index rooted at one agent working directory.
19
46
  * Directory-scoped queries list live state; bare fuzzy queries share one
20
- * bounded traversal until the `@` interaction ends or a tool result invalidates it.
47
+ * bounded traversal. Only the first query of a workspace waits for that
48
+ * traversal — an invalidated index keeps answering while its replacement
49
+ * builds behind the caret.
21
50
  */
22
51
  export class WorkspaceFileSearch {
23
52
  root;
24
53
  config;
25
54
  excludedDirectories;
55
+ settled;
26
56
  generation;
57
+ /** Monotonic invalidation counter; a settled index below it is stale. */
58
+ invalidations = 0;
27
59
  disposed = false;
28
60
  constructor(root, config) {
29
61
  this.root = root;
@@ -56,31 +88,69 @@ export class WorkspaceFileSearch {
56
88
  const fragment = slash < 0 ? '' : query.slice(slash + 1);
57
89
  return this.listDirectory(directory, fragment, signal);
58
90
  }
59
- const indexed = await waitForPromise(this.ensureIndex(), signal);
91
+ const indexed = await this.indexFor(signal);
60
92
  return rankCandidates(indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)), query, this.config.maxResults);
61
93
  }
62
- /** Discard the current index so the next bare query observes a fresh tree. */
94
+ /**
95
+ * Mark the index stale so a later bare query observes a fresh tree.
96
+ *
97
+ * The stale entries are kept and keep answering: a rebuild costs one
98
+ * traversal of the whole workspace, and putting that in front of the caret
99
+ * is what a caller invalidating on every tool result would otherwise pay.
100
+ */
63
101
  invalidate() {
64
- this.generation?.controller.abort(new Error('file search index invalidated'));
65
- this.generation = undefined;
102
+ this.invalidations += 1;
66
103
  }
67
104
  /** Abort traversal and make later queries return no candidates. */
68
105
  dispose() {
69
106
  if (this.disposed)
70
107
  return;
71
108
  this.disposed = true;
72
- this.invalidate();
109
+ this.generation?.controller.abort(new Error('file search index disposed'));
110
+ this.generation = undefined;
111
+ this.settled = undefined;
112
+ }
113
+ /**
114
+ * The entries a bare fuzzy query ranks. Only the first query of a workspace
115
+ * waits for a traversal; afterwards a stale index answers immediately and
116
+ * its replacement builds in the background.
117
+ * @param signal - cancels this caller's wait without killing a shared traversal.
118
+ * @returns indexed paths, at most one invalidation behind the tree.
119
+ */
120
+ async indexFor(signal) {
121
+ const settled = this.settled;
122
+ if (settled === undefined)
123
+ return waitForPromise(this.ensureIndex(), signal);
124
+ if (settled.startedAt < this.invalidations) {
125
+ void this.ensureIndex().catch(() => {
126
+ // A background refresh failure is not this caller's error: the stale
127
+ // entries still answer and `settled.startedAt` stays behind, so the
128
+ // next bare query starts a fresh attempt.
129
+ });
130
+ }
131
+ return settled.entries;
73
132
  }
74
133
  ensureIndex() {
75
134
  if (this.generation !== undefined)
76
135
  return this.generation.promise;
77
136
  const controller = new AbortController();
137
+ const startedAt = this.invalidations;
78
138
  const generation = {
79
139
  controller,
80
140
  promise: Promise.resolve([]),
81
141
  };
82
- generation.promise = this.scanWorkspace(controller.signal).catch((error) => {
83
- /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
142
+ generation.promise = this.scanWorkspace(controller.signal).then((entries) => {
143
+ /* v8 ignore next -- disposal aborts this traversal, so it reaches the
144
+ * rejection handler instead; the guard only covers a scan that finished
145
+ * its last directory in the instant before the abort landed, and must
146
+ * not hand a disposed index its entries back. */
147
+ if (this.disposed)
148
+ return entries;
149
+ this.generation = undefined;
150
+ this.settled = { entries, startedAt };
151
+ return entries;
152
+ }, (error) => {
153
+ /* v8 ignore next -- dispose clears `generation` synchronously; this only protects an unexpected scan failure */
84
154
  if (this.generation === generation)
85
155
  this.generation = undefined;
86
156
  throw error;
@@ -98,7 +168,13 @@ export class WorkspaceFileSearch {
98
168
  if (directory === undefined) {
99
169
  throw new Error('file search selected a missing directory');
100
170
  }
101
- const entries = await readDirectory(directory.absolute, signal);
171
+ // The root is not a subtree: an unreadable branch costs its own
172
+ // candidates, but an unreadable root means the traversal learned
173
+ // nothing. Letting that settle would publish an empty index over
174
+ // entries that are still good and leave no invalidation to retry from.
175
+ const entries = cursor === 0
176
+ ? await readWorkspaceRoot(directory.absolute, signal)
177
+ : await readDirectory(directory.absolute, signal);
102
178
  for (const entry of entries) {
103
179
  signal.throwIfAborted();
104
180
  const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`;
@@ -166,6 +242,12 @@ async function resolveDisplayDirectory(root, displayDirectory, signal) {
166
242
  }
167
243
  return absolute;
168
244
  }
245
+ async function readWorkspaceRoot(absolute, signal) {
246
+ signal.throwIfAborted();
247
+ const entries = await readdir(absolute, { withFileTypes: true });
248
+ signal.throwIfAborted();
249
+ return entries.sort((left, right) => compareText(left.name, right.name));
250
+ }
169
251
  async function readDirectory(absolute, signal) {
170
252
  signal.throwIfAborted();
171
253
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-file-reference-local",
3
3
  "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -37,22 +37,22 @@
37
37
  ],
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
- "@deepseek-ai/schemastery": "^3.18.1"
40
+ "@deepseek-ai/schemastery": "^3.18.2"
41
41
  },
42
42
  "peerDependencies": {
43
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
44
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
45
- "@deepseek-ai/dsh-file-reference": "^0.1.1-rc.2",
46
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
47
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
48
- "@deepseek-ai/cordis": "^4.0.1"
43
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
44
+ "@deepseek-ai/dsh-file-reference": "^0.1.2-alpha.2",
45
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
46
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2",
47
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
48
+ "@deepseek-ai/cordis": "^4.0.2"
49
49
  },
50
50
  "devDependencies": {
51
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-file-reference": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
56
- "@deepseek-ai/cordis": "^4.0.1"
51
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-file-reference": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/cordis": "^4.0.2"
57
57
  }
58
58
  }