@deepseek-ai/dsh-file-reference-local 0.1.0-rc.8
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +45 -0
- package/README.zh.md +45 -0
- package/lib/index.js +327 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.js +105 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +22 -0
- package/lib/types/search.d.ts +52 -0
- package/lib/types/search.js +258 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/context/file-reference-local/README.md
|
|
5
|
+
README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e
|
|
6
|
+
README.zh.md: beded13250daf041294e4e2656d0e1374407ff94
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# `@deepseek-ai/dsh-file-reference-local`
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
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.
|
|
6
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Configuration
|
|
10
|
+
|
|
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. |
|
|
16
|
+
|
|
17
|
+
Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`.
|
|
18
|
+
|
|
19
|
+
## Model Experience
|
|
20
|
+
|
|
21
|
+
### File-reference guidance when `read` is available
|
|
22
|
+
|
|
23
|
+
#### What the model sees
|
|
24
|
+
|
|
25
|
+
When the addressed agent has an effective `read` tool, the provider contributes this stable system-prompt section:
|
|
26
|
+
|
|
27
|
+
##### File-reference instruction
|
|
28
|
+
|
|
29
|
+
```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.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Token effect
|
|
34
|
+
|
|
35
|
+
Conditional and fixed: the one sentence is present while `read` is visible to the addressed agent; candidate lookup itself adds no tokens, and a selected path contributes only its ordinary user-message characters.
|
|
36
|
+
|
|
37
|
+
#### KV Cache effect
|
|
38
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## Known Limitations and Deferred Work
|
|
42
|
+
|
|
43
|
+
- **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.
|
|
45
|
+
- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# `@deepseek-ai/dsh-file-reference-local`
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
`ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。
|
|
6
|
+
|
|
7
|
+
工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。
|
|
8
|
+
|
|
9
|
+
## 配置
|
|
10
|
+
|
|
11
|
+
| 配置键 | 默认值 | 契约 |
|
|
12
|
+
|---|---:|---|
|
|
13
|
+
| `maxResults` | `20` | 单次查询返回的候选项最大数量。 |
|
|
14
|
+
| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 |
|
|
15
|
+
| `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 |
|
|
16
|
+
|
|
17
|
+
所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` 或 `\`。
|
|
18
|
+
|
|
19
|
+
## 模型体验
|
|
20
|
+
|
|
21
|
+
### `read` 可用时的文件引用指引
|
|
22
|
+
|
|
23
|
+
#### 模型看到什么
|
|
24
|
+
|
|
25
|
+
当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段:
|
|
26
|
+
|
|
27
|
+
##### 文件引用指令
|
|
28
|
+
|
|
29
|
+
```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.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Token 影响
|
|
34
|
+
|
|
35
|
+
该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。
|
|
36
|
+
|
|
37
|
+
#### KV 缓存影响
|
|
38
|
+
|
|
39
|
+
该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。
|
|
40
|
+
|
|
41
|
+
## 已知限制与暂缓事项
|
|
42
|
+
|
|
43
|
+
- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。
|
|
44
|
+
- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。
|
|
45
|
+
- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import FileReferenceService, { FILE_REFERENCE_PROMPT, FILE_REFERENCE_PROMPT as FILE_REFERENCE_PROMPT$1 } from "@deepseek-ai/dsh-file-reference";
|
|
3
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { activeAtToken, formatFileMention } from "@deepseek-ai/dsh-file-reference/grammar";
|
|
6
|
+
//#region lib/types/search.js
|
|
7
|
+
/**
|
|
8
|
+
* Host-workspace discovery for `@file` completion. The index contains paths
|
|
9
|
+
* only: selected values remain ordinary prompt text and file contents stay
|
|
10
|
+
* behind the model-facing `read` tool.
|
|
11
|
+
*
|
|
12
|
+
* @module @deepseek-ai/dsh-file-reference-local/search
|
|
13
|
+
*/
|
|
14
|
+
/** Default maximum file and directory candidates rendered for one query. */
|
|
15
|
+
const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
|
|
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"];
|
|
20
|
+
/**
|
|
21
|
+
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
|
22
|
+
* Directory-scoped queries list live state; bare fuzzy queries share one
|
|
23
|
+
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
|
24
|
+
*/
|
|
25
|
+
var WorkspaceFileSearch = class {
|
|
26
|
+
root;
|
|
27
|
+
config;
|
|
28
|
+
excludedDirectories;
|
|
29
|
+
generation;
|
|
30
|
+
disposed = false;
|
|
31
|
+
constructor(root, config) {
|
|
32
|
+
this.root = root;
|
|
33
|
+
this.config = config;
|
|
34
|
+
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) throw new Error("file search maxResults must be a positive safe integer");
|
|
35
|
+
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) throw new Error("file search maxEntries must be a positive safe integer");
|
|
36
|
+
if (config.excludedDirectories.some((name) => name.length === 0 || name.includes("/") || name.includes("\\"))) throw new Error("file search excludedDirectories entries must be non-empty directory basenames");
|
|
37
|
+
this.excludedDirectories = new Set(config.excludedDirectories);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Return ranked path candidates for the current token.
|
|
41
|
+
* @param rawQuery - path text following `@` or `@"`.
|
|
42
|
+
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
|
43
|
+
* @returns at most `maxResults` deterministic candidates.
|
|
44
|
+
*/
|
|
45
|
+
async list(rawQuery, signal) {
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
if (this.disposed) return [];
|
|
48
|
+
const query = rawQuery.replaceAll("\\", "/");
|
|
49
|
+
const slash = query.lastIndexOf("/");
|
|
50
|
+
if (query === "" || slash >= 0) {
|
|
51
|
+
const directory = slash < 0 ? "" : query.slice(0, slash + 1);
|
|
52
|
+
const fragment = slash < 0 ? "" : query.slice(slash + 1);
|
|
53
|
+
return this.listDirectory(directory, fragment, signal);
|
|
54
|
+
}
|
|
55
|
+
return rankCandidates((await waitForPromise(this.ensureIndex(), signal)).filter((candidate) => visibleForGlobalQuery(candidate.path, query)), query, this.config.maxResults);
|
|
56
|
+
}
|
|
57
|
+
/** Discard the current index so the next bare query observes a fresh tree. */
|
|
58
|
+
invalidate() {
|
|
59
|
+
this.generation?.controller.abort(/* @__PURE__ */ new Error("file search index invalidated"));
|
|
60
|
+
this.generation = void 0;
|
|
61
|
+
}
|
|
62
|
+
/** Abort traversal and make later queries return no candidates. */
|
|
63
|
+
dispose() {
|
|
64
|
+
if (this.disposed) return;
|
|
65
|
+
this.disposed = true;
|
|
66
|
+
this.invalidate();
|
|
67
|
+
}
|
|
68
|
+
ensureIndex() {
|
|
69
|
+
if (this.generation !== void 0) return this.generation.promise;
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const generation = {
|
|
72
|
+
controller,
|
|
73
|
+
promise: Promise.resolve([])
|
|
74
|
+
};
|
|
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 */
|
|
77
|
+
if (this.generation === generation) this.generation = void 0;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
this.generation = generation;
|
|
81
|
+
return generation.promise;
|
|
82
|
+
}
|
|
83
|
+
async scanWorkspace(signal) {
|
|
84
|
+
const indexed = [];
|
|
85
|
+
const directories = [{
|
|
86
|
+
absolute: this.root,
|
|
87
|
+
relative: ""
|
|
88
|
+
}];
|
|
89
|
+
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
const directory = directories[cursor];
|
|
92
|
+
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
|
93
|
+
if (directory === void 0) throw new Error("file search selected a missing directory");
|
|
94
|
+
const entries = await readDirectory(directory.absolute, signal);
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const path = directory.relative === "" ? entry.name : `${directory.relative}/${entry.name}`;
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
if (this.excludedDirectories.has(entry.name)) continue;
|
|
100
|
+
indexed.push({
|
|
101
|
+
path,
|
|
102
|
+
kind: "directory"
|
|
103
|
+
});
|
|
104
|
+
directories.push({
|
|
105
|
+
absolute: join(directory.absolute, entry.name),
|
|
106
|
+
relative: path
|
|
107
|
+
});
|
|
108
|
+
} else if (entry.isFile()) indexed.push({
|
|
109
|
+
path,
|
|
110
|
+
kind: "file"
|
|
111
|
+
});
|
|
112
|
+
if (indexed.length >= this.config.maxEntries) break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return indexed;
|
|
116
|
+
}
|
|
117
|
+
async listDirectory(displayDirectory, fragment, signal) {
|
|
118
|
+
if (displayDirectory.split("/").some((segment) => this.excludedDirectories.has(segment))) return [];
|
|
119
|
+
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal);
|
|
120
|
+
if (absolute === void 0) return [];
|
|
121
|
+
const entries = await readDirectory(absolute, signal);
|
|
122
|
+
const candidates = [];
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (entry.name.startsWith(".") && !fragment.startsWith(".")) continue;
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
if (this.excludedDirectories.has(entry.name)) continue;
|
|
127
|
+
candidates.push({
|
|
128
|
+
path: `${displayDirectory}${entry.name}`,
|
|
129
|
+
kind: "directory"
|
|
130
|
+
});
|
|
131
|
+
} else if (entry.isFile()) candidates.push({
|
|
132
|
+
path: `${displayDirectory}${entry.name}`,
|
|
133
|
+
kind: "file"
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return rankCandidates(candidates, fragment, this.config.maxResults);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
async function resolveDisplayDirectory(root, displayDirectory, signal) {
|
|
140
|
+
const resolvedRoot = resolve(root);
|
|
141
|
+
const absolute = resolve(resolvedRoot, displayDirectory === "" ? "." : displayDirectory);
|
|
142
|
+
const fromRoot = relative(resolvedRoot, absolute);
|
|
143
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) return void 0;
|
|
144
|
+
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
|
145
|
+
if (isAbsolute(fromRoot)) return void 0;
|
|
146
|
+
let current = resolvedRoot;
|
|
147
|
+
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
current = join(current, segment);
|
|
150
|
+
try {
|
|
151
|
+
const status = await lstat(current);
|
|
152
|
+
signal.throwIfAborted();
|
|
153
|
+
if (status.isSymbolicLink() || !status.isDirectory()) return void 0;
|
|
154
|
+
} catch (_error) {
|
|
155
|
+
signal.throwIfAborted();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return absolute;
|
|
160
|
+
}
|
|
161
|
+
async function readDirectory(absolute, signal) {
|
|
162
|
+
signal.throwIfAborted();
|
|
163
|
+
try {
|
|
164
|
+
const entries = await readdir(absolute, { withFileTypes: true });
|
|
165
|
+
signal.throwIfAborted();
|
|
166
|
+
return entries.sort((left, right) => compareText(left.name, right.name));
|
|
167
|
+
} catch (_error) {
|
|
168
|
+
signal.throwIfAborted();
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function visibleForGlobalQuery(path, query) {
|
|
173
|
+
if (query.startsWith(".") || query.includes("/.")) return true;
|
|
174
|
+
return !path.split("/").some((segment) => segment.startsWith("."));
|
|
175
|
+
}
|
|
176
|
+
function rankCandidates(candidates, query, limit) {
|
|
177
|
+
const ranked = [];
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
const score = scoreCandidate(candidate, query);
|
|
180
|
+
if (score !== void 0) ranked.push({
|
|
181
|
+
candidate,
|
|
182
|
+
score
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
ranked.sort((left, right) => right.score - left.score || kindRank(left.candidate.kind) - kindRank(right.candidate.kind) || (query === "" ? 0 : left.candidate.path.length - right.candidate.path.length) || compareText(left.candidate.path, right.candidate.path));
|
|
186
|
+
return ranked.slice(0, limit).map((entry) => entry.candidate);
|
|
187
|
+
}
|
|
188
|
+
function scoreCandidate(candidate, query) {
|
|
189
|
+
if (query === "") return 0;
|
|
190
|
+
const path = candidate.path.toLowerCase();
|
|
191
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
192
|
+
const needle = query.toLowerCase();
|
|
193
|
+
const directoryBonus = candidate.kind === "directory" ? 25 : 0;
|
|
194
|
+
if (name === needle) return 1e3 + directoryBonus;
|
|
195
|
+
if (name.startsWith(needle)) return 900 + directoryBonus;
|
|
196
|
+
if (name.includes(needle)) return 700 + directoryBonus;
|
|
197
|
+
if (path.includes(needle)) return 500 + directoryBonus;
|
|
198
|
+
const subsequence = subsequenceScore(path, needle);
|
|
199
|
+
return subsequence === void 0 ? void 0 : 300 + subsequence + directoryBonus;
|
|
200
|
+
}
|
|
201
|
+
function subsequenceScore(target, query) {
|
|
202
|
+
let targetIndex = 0;
|
|
203
|
+
let gap = 0;
|
|
204
|
+
for (const character of query) {
|
|
205
|
+
const found = target.indexOf(character, targetIndex);
|
|
206
|
+
if (found < 0) return void 0;
|
|
207
|
+
gap += found - targetIndex;
|
|
208
|
+
targetIndex = found + 1;
|
|
209
|
+
}
|
|
210
|
+
return Math.max(0, 100 - gap);
|
|
211
|
+
}
|
|
212
|
+
function kindRank(kind) {
|
|
213
|
+
return kind === "directory" ? 0 : 1;
|
|
214
|
+
}
|
|
215
|
+
function compareText(left, right) {
|
|
216
|
+
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
|
217
|
+
* order determines which comparison direction sort requests. */
|
|
218
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
219
|
+
}
|
|
220
|
+
function waitForPromise(promise, signal) {
|
|
221
|
+
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
|
222
|
+
if (signal.aborted) return Promise.reject(errorReason(signal.reason, "file search aborted"));
|
|
223
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
224
|
+
const onAbort = () => {
|
|
225
|
+
rejectPromise(errorReason(signal.reason, "file search aborted"));
|
|
226
|
+
};
|
|
227
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
promise.then((value) => {
|
|
229
|
+
signal.removeEventListener("abort", onAbort);
|
|
230
|
+
resolvePromise(value);
|
|
231
|
+
}, (error) => {
|
|
232
|
+
signal.removeEventListener("abort", onAbort);
|
|
233
|
+
rejectPromise(errorReason(error, "file search index failed"));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function errorReason(reason, fallback) {
|
|
238
|
+
return reason instanceof Error ? reason : new Error(fallback, { cause: reason });
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region lib/types/index.js
|
|
242
|
+
/**
|
|
243
|
+
* Local-filesystem implementation of `ctx.fileReferences`.
|
|
244
|
+
*
|
|
245
|
+
* @module @deepseek-ai/dsh-file-reference-local
|
|
246
|
+
*/
|
|
247
|
+
/** Local-filesystem owner of the file-reference discovery service. */
|
|
248
|
+
var LocalFileReferenceService = class extends FileReferenceService {
|
|
249
|
+
static inject = ["agents"];
|
|
250
|
+
static Config = z.object({
|
|
251
|
+
maxResults: z.number().step(1).min(1).default(20),
|
|
252
|
+
maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES),
|
|
253
|
+
excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
|
|
254
|
+
});
|
|
255
|
+
config;
|
|
256
|
+
searches = /* @__PURE__ */ new Map();
|
|
257
|
+
promptFibers = /* @__PURE__ */ new Map();
|
|
258
|
+
promptDisposals = /* @__PURE__ */ new Set();
|
|
259
|
+
constructor(ctx, config = {}) {
|
|
260
|
+
super(ctx);
|
|
261
|
+
this.config = {
|
|
262
|
+
maxResults: config.maxResults ?? 20,
|
|
263
|
+
maxEntries: config.maxEntries ?? 1e4,
|
|
264
|
+
excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES
|
|
265
|
+
};
|
|
266
|
+
validateConfig(this.config);
|
|
267
|
+
const installPrompt = (agent) => {
|
|
268
|
+
if (this.promptFibers.has(agent)) return;
|
|
269
|
+
const fiber = agent.ctx.inject(["systemPrompt", "tools"], (scope) => {
|
|
270
|
+
scope.systemPrompt.section({
|
|
271
|
+
name: "context:file-reference",
|
|
272
|
+
order: 99,
|
|
273
|
+
text: () => agent.ctx.tools.get("read", agent) === void 0 ? "" : FILE_REFERENCE_PROMPT$1
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
this.promptFibers.set(agent, fiber);
|
|
277
|
+
};
|
|
278
|
+
const disposePrompt = (agent) => {
|
|
279
|
+
const fiber = this.promptFibers.get(agent);
|
|
280
|
+
if (fiber === void 0) return;
|
|
281
|
+
this.promptFibers.delete(agent);
|
|
282
|
+
const task = fiber.dispose().catch((error) => {
|
|
283
|
+
ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
284
|
+
});
|
|
285
|
+
this.promptDisposals.add(task);
|
|
286
|
+
task.finally(() => {
|
|
287
|
+
this.promptDisposals.delete(task);
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
for (const agent of ctx.agents.list()) installPrompt(agent);
|
|
291
|
+
ctx.on("agent/created", ({ agent }) => {
|
|
292
|
+
installPrompt(agent);
|
|
293
|
+
});
|
|
294
|
+
ctx.on("agent/disposed", ({ agent }) => {
|
|
295
|
+
this.searches.get(agent)?.dispose();
|
|
296
|
+
this.searches.delete(agent);
|
|
297
|
+
disposePrompt(agent);
|
|
298
|
+
});
|
|
299
|
+
ctx.on("session/event", (session, event) => {
|
|
300
|
+
if (event.type !== "tool/result") return;
|
|
301
|
+
const agent = ctx.agents.get(session.id);
|
|
302
|
+
if (agent !== void 0) this.searches.get(agent)?.invalidate();
|
|
303
|
+
});
|
|
304
|
+
ctx.effect(() => async () => {
|
|
305
|
+
for (const search of this.searches.values()) search.dispose();
|
|
306
|
+
this.searches.clear();
|
|
307
|
+
const promptFibers = [...this.promptFibers.values()];
|
|
308
|
+
this.promptFibers.clear();
|
|
309
|
+
await Promise.all([...promptFibers.map((fiber) => fiber.dispose()), ...this.promptDisposals]);
|
|
310
|
+
}, "file-reference-local: search cache");
|
|
311
|
+
}
|
|
312
|
+
list(agent, query, signal) {
|
|
313
|
+
let search = this.searches.get(agent);
|
|
314
|
+
if (search === void 0) {
|
|
315
|
+
search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config);
|
|
316
|
+
this.searches.set(agent, search);
|
|
317
|
+
}
|
|
318
|
+
return search.list(query, signal);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
function validateConfig(config) {
|
|
322
|
+
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) throw new Error("file-reference-local: maxResults must be a positive safe integer");
|
|
323
|
+
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) throw new Error("file-reference-local: maxEntries must be a positive safe integer");
|
|
324
|
+
if (config.excludedDirectories.some((name) => name.length === 0 || name.includes("/") || name.includes("\\"))) throw new Error("file-reference-local: excludedDirectories entries must be non-empty directory basenames");
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, FILE_REFERENCE_PROMPT, LocalFileReferenceService, LocalFileReferenceService as default, WorkspaceFileSearch, activeAtToken, formatFileMention };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`.
|
|
4
|
+
* @module @deepseek-ai/dsh-file-reference-local/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-file-reference-local";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "file-reference-local-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: per-agent indexes are private advisory caches whose
|
|
13
|
+
* invalidation and disposal are observed directly through service tests.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-filesystem implementation of `ctx.fileReferences`.
|
|
3
|
+
*
|
|
4
|
+
* @module @deepseek-ai/dsh-file-reference-local
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
9
|
+
import FileReferenceService, { type FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference';
|
|
10
|
+
export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, WorkspaceFileSearch, } from './search.ts';
|
|
11
|
+
export type { FileSearchConfig } from './search.ts';
|
|
12
|
+
export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference';
|
|
13
|
+
export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar';
|
|
14
|
+
/** Local file-reference discovery configuration. */
|
|
15
|
+
export interface Config {
|
|
16
|
+
/** Maximum ranked candidates returned for one query. */
|
|
17
|
+
maxResults?: number;
|
|
18
|
+
/** Maximum indexed files and directories per agent workspace. */
|
|
19
|
+
maxEntries?: number;
|
|
20
|
+
/** Directory basenames never traversed or offered. */
|
|
21
|
+
excludedDirectories?: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Local-filesystem owner of the file-reference discovery service. */
|
|
24
|
+
export declare class LocalFileReferenceService extends FileReferenceService {
|
|
25
|
+
static inject: string[];
|
|
26
|
+
static Config: z<Config>;
|
|
27
|
+
private readonly config;
|
|
28
|
+
private readonly searches;
|
|
29
|
+
private readonly promptFibers;
|
|
30
|
+
private readonly promptDisposals;
|
|
31
|
+
constructor(ctx: Context, config?: Config);
|
|
32
|
+
list(agent: Agent, query: string, signal: AbortSignal): Promise<FileReferenceCandidate[]>;
|
|
33
|
+
}
|
|
34
|
+
export default LocalFileReferenceService;
|
|
35
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-filesystem implementation of `ctx.fileReferences`.
|
|
3
|
+
*
|
|
4
|
+
* @module @deepseek-ai/dsh-file-reference-local
|
|
5
|
+
*/
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import FileReferenceService, { FILE_REFERENCE_PROMPT, } from '@deepseek-ai/dsh-file-reference';
|
|
8
|
+
import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, WorkspaceFileSearch, } from "./search.js";
|
|
9
|
+
export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, WorkspaceFileSearch, } from "./search.js";
|
|
10
|
+
export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference';
|
|
11
|
+
export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar';
|
|
12
|
+
/** Local-filesystem owner of the file-reference discovery service. */
|
|
13
|
+
export class LocalFileReferenceService extends FileReferenceService {
|
|
14
|
+
static inject = ['agents'];
|
|
15
|
+
static Config = z.object({
|
|
16
|
+
maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS),
|
|
17
|
+
maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES),
|
|
18
|
+
excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]),
|
|
19
|
+
});
|
|
20
|
+
config;
|
|
21
|
+
searches = new Map();
|
|
22
|
+
promptFibers = new Map();
|
|
23
|
+
promptDisposals = new Set();
|
|
24
|
+
constructor(ctx, config = {}) {
|
|
25
|
+
super(ctx);
|
|
26
|
+
this.config = {
|
|
27
|
+
maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
|
28
|
+
maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
|
29
|
+
excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
|
30
|
+
};
|
|
31
|
+
validateConfig(this.config);
|
|
32
|
+
const installPrompt = (agent) => {
|
|
33
|
+
if (this.promptFibers.has(agent))
|
|
34
|
+
return;
|
|
35
|
+
const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
|
|
36
|
+
scope.systemPrompt.section({
|
|
37
|
+
name: 'context:file-reference',
|
|
38
|
+
order: 99,
|
|
39
|
+
text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
this.promptFibers.set(agent, fiber);
|
|
43
|
+
};
|
|
44
|
+
const disposePrompt = (agent) => {
|
|
45
|
+
const fiber = this.promptFibers.get(agent);
|
|
46
|
+
if (fiber === undefined)
|
|
47
|
+
return;
|
|
48
|
+
this.promptFibers.delete(agent);
|
|
49
|
+
const task = fiber.dispose().catch((error) => {
|
|
50
|
+
ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
51
|
+
});
|
|
52
|
+
this.promptDisposals.add(task);
|
|
53
|
+
void task.finally(() => {
|
|
54
|
+
this.promptDisposals.delete(task);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
for (const agent of ctx.agents.list())
|
|
58
|
+
installPrompt(agent);
|
|
59
|
+
ctx.on('agent/created', ({ agent }) => { installPrompt(agent); });
|
|
60
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
61
|
+
this.searches.get(agent)?.dispose();
|
|
62
|
+
this.searches.delete(agent);
|
|
63
|
+
disposePrompt(agent);
|
|
64
|
+
});
|
|
65
|
+
ctx.on('session/event', (session, event) => {
|
|
66
|
+
if (event.type !== 'tool/result')
|
|
67
|
+
return;
|
|
68
|
+
const agent = ctx.agents.get(session.id);
|
|
69
|
+
if (agent !== undefined)
|
|
70
|
+
this.searches.get(agent)?.invalidate();
|
|
71
|
+
});
|
|
72
|
+
ctx.effect(() => async () => {
|
|
73
|
+
for (const search of this.searches.values())
|
|
74
|
+
search.dispose();
|
|
75
|
+
this.searches.clear();
|
|
76
|
+
const promptFibers = [...this.promptFibers.values()];
|
|
77
|
+
this.promptFibers.clear();
|
|
78
|
+
await Promise.all([
|
|
79
|
+
...promptFibers.map(fiber => fiber.dispose()),
|
|
80
|
+
...this.promptDisposals,
|
|
81
|
+
]);
|
|
82
|
+
}, 'file-reference-local: search cache');
|
|
83
|
+
}
|
|
84
|
+
list(agent, query, signal) {
|
|
85
|
+
let search = this.searches.get(agent);
|
|
86
|
+
if (search === undefined) {
|
|
87
|
+
search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config);
|
|
88
|
+
this.searches.set(agent, search);
|
|
89
|
+
}
|
|
90
|
+
return search.list(query, signal);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function validateConfig(config) {
|
|
94
|
+
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
|
95
|
+
throw new Error('file-reference-local: maxResults must be a positive safe integer');
|
|
96
|
+
}
|
|
97
|
+
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
|
98
|
+
throw new Error('file-reference-local: maxEntries must be a positive safe integer');
|
|
99
|
+
}
|
|
100
|
+
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
|
101
|
+
throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export default LocalFileReferenceService;
|
|
105
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`.
|
|
3
|
+
* @module @deepseek-ai/dsh-file-reference-local/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "file-reference-local-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`.
|
|
3
|
+
* @module @deepseek-ai/dsh-file-reference-local/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference-local';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'file-reference-local-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: per-agent indexes are private advisory caches whose
|
|
12
|
+
* invalidation and disposal are observed directly through service tests.
|
|
13
|
+
*/
|
|
14
|
+
const install = () => { };
|
|
15
|
+
/**
|
|
16
|
+
* Register this package's invariant companion.
|
|
17
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
18
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
19
|
+
*/
|
|
20
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
21
|
+
/* jscpd:ignore-end */
|
|
22
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-workspace discovery for `@file` completion. The index contains paths
|
|
3
|
+
* only: selected values remain ordinary prompt text and file contents stay
|
|
4
|
+
* behind the model-facing `read` tool.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-file-reference-local/search
|
|
7
|
+
*/
|
|
8
|
+
import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference';
|
|
9
|
+
export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar';
|
|
10
|
+
/** Default maximum file and directory candidates rendered for one query. */
|
|
11
|
+
export declare const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
|
|
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"];
|
|
16
|
+
/** Resolved limits and exclusions for one workspace index. */
|
|
17
|
+
export interface FileSearchConfig {
|
|
18
|
+
/** Maximum ranked candidates returned for one query. */
|
|
19
|
+
maxResults: number;
|
|
20
|
+
/** Maximum indexed files and directories. */
|
|
21
|
+
maxEntries: number;
|
|
22
|
+
/** Directory basenames never traversed or offered. */
|
|
23
|
+
excludedDirectories: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
|
27
|
+
* Directory-scoped queries list live state; bare fuzzy queries share one
|
|
28
|
+
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
|
29
|
+
*/
|
|
30
|
+
export declare class WorkspaceFileSearch {
|
|
31
|
+
private readonly root;
|
|
32
|
+
private readonly config;
|
|
33
|
+
private readonly excludedDirectories;
|
|
34
|
+
private generation;
|
|
35
|
+
private disposed;
|
|
36
|
+
constructor(root: string, config: FileSearchConfig);
|
|
37
|
+
/**
|
|
38
|
+
* Return ranked path candidates for the current token.
|
|
39
|
+
* @param rawQuery - path text following `@` or `@"`.
|
|
40
|
+
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
|
41
|
+
* @returns at most `maxResults` deterministic candidates.
|
|
42
|
+
*/
|
|
43
|
+
list(rawQuery: string, signal: AbortSignal): Promise<FileReferenceCandidate[]>;
|
|
44
|
+
/** Discard the current index so the next bare query observes a fresh tree. */
|
|
45
|
+
invalidate(): void;
|
|
46
|
+
/** Abort traversal and make later queries return no candidates. */
|
|
47
|
+
dispose(): void;
|
|
48
|
+
private ensureIndex;
|
|
49
|
+
private scanWorkspace;
|
|
50
|
+
private listDirectory;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-workspace discovery for `@file` completion. The index contains paths
|
|
3
|
+
* only: selected values remain ordinary prompt text and file contents stay
|
|
4
|
+
* behind the model-facing `read` tool.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-file-reference-local/search
|
|
7
|
+
*/
|
|
8
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
9
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar';
|
|
11
|
+
/** Default maximum file and directory candidates rendered for one query. */
|
|
12
|
+
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20;
|
|
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'];
|
|
17
|
+
/**
|
|
18
|
+
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
|
19
|
+
* Directory-scoped queries list live state; bare fuzzy queries share one
|
|
20
|
+
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
|
21
|
+
*/
|
|
22
|
+
export class WorkspaceFileSearch {
|
|
23
|
+
root;
|
|
24
|
+
config;
|
|
25
|
+
excludedDirectories;
|
|
26
|
+
generation;
|
|
27
|
+
disposed = false;
|
|
28
|
+
constructor(root, config) {
|
|
29
|
+
this.root = root;
|
|
30
|
+
this.config = config;
|
|
31
|
+
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
|
32
|
+
throw new Error('file search maxResults must be a positive safe integer');
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
|
35
|
+
throw new Error('file search maxEntries must be a positive safe integer');
|
|
36
|
+
}
|
|
37
|
+
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
|
38
|
+
throw new Error('file search excludedDirectories entries must be non-empty directory basenames');
|
|
39
|
+
}
|
|
40
|
+
this.excludedDirectories = new Set(config.excludedDirectories);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Return ranked path candidates for the current token.
|
|
44
|
+
* @param rawQuery - path text following `@` or `@"`.
|
|
45
|
+
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
|
46
|
+
* @returns at most `maxResults` deterministic candidates.
|
|
47
|
+
*/
|
|
48
|
+
async list(rawQuery, signal) {
|
|
49
|
+
signal.throwIfAborted();
|
|
50
|
+
if (this.disposed)
|
|
51
|
+
return [];
|
|
52
|
+
const query = rawQuery.replaceAll('\\', '/');
|
|
53
|
+
const slash = query.lastIndexOf('/');
|
|
54
|
+
if (query === '' || slash >= 0) {
|
|
55
|
+
const directory = slash < 0 ? '' : query.slice(0, slash + 1);
|
|
56
|
+
const fragment = slash < 0 ? '' : query.slice(slash + 1);
|
|
57
|
+
return this.listDirectory(directory, fragment, signal);
|
|
58
|
+
}
|
|
59
|
+
const indexed = await waitForPromise(this.ensureIndex(), signal);
|
|
60
|
+
return rankCandidates(indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)), query, this.config.maxResults);
|
|
61
|
+
}
|
|
62
|
+
/** Discard the current index so the next bare query observes a fresh tree. */
|
|
63
|
+
invalidate() {
|
|
64
|
+
this.generation?.controller.abort(new Error('file search index invalidated'));
|
|
65
|
+
this.generation = undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Abort traversal and make later queries return no candidates. */
|
|
68
|
+
dispose() {
|
|
69
|
+
if (this.disposed)
|
|
70
|
+
return;
|
|
71
|
+
this.disposed = true;
|
|
72
|
+
this.invalidate();
|
|
73
|
+
}
|
|
74
|
+
ensureIndex() {
|
|
75
|
+
if (this.generation !== undefined)
|
|
76
|
+
return this.generation.promise;
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const generation = {
|
|
79
|
+
controller,
|
|
80
|
+
promise: Promise.resolve([]),
|
|
81
|
+
};
|
|
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 */
|
|
84
|
+
if (this.generation === generation)
|
|
85
|
+
this.generation = undefined;
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
this.generation = generation;
|
|
89
|
+
return generation.promise;
|
|
90
|
+
}
|
|
91
|
+
async scanWorkspace(signal) {
|
|
92
|
+
const indexed = [];
|
|
93
|
+
const directories = [{ absolute: this.root, relative: '' }];
|
|
94
|
+
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
const directory = directories[cursor];
|
|
97
|
+
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
|
98
|
+
if (directory === undefined) {
|
|
99
|
+
throw new Error('file search selected a missing directory');
|
|
100
|
+
}
|
|
101
|
+
const entries = await readDirectory(directory.absolute, signal);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`;
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (this.excludedDirectories.has(entry.name))
|
|
107
|
+
continue;
|
|
108
|
+
indexed.push({ path, kind: 'directory' });
|
|
109
|
+
directories.push({ absolute: join(directory.absolute, entry.name), relative: path });
|
|
110
|
+
}
|
|
111
|
+
else if (entry.isFile()) {
|
|
112
|
+
indexed.push({ path, kind: 'file' });
|
|
113
|
+
}
|
|
114
|
+
if (indexed.length >= this.config.maxEntries)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return indexed;
|
|
119
|
+
}
|
|
120
|
+
async listDirectory(displayDirectory, fragment, signal) {
|
|
121
|
+
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment)))
|
|
122
|
+
return [];
|
|
123
|
+
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal);
|
|
124
|
+
if (absolute === undefined)
|
|
125
|
+
return [];
|
|
126
|
+
const entries = await readDirectory(absolute, signal);
|
|
127
|
+
const candidates = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (entry.name.startsWith('.') && !fragment.startsWith('.'))
|
|
130
|
+
continue;
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (this.excludedDirectories.has(entry.name))
|
|
133
|
+
continue;
|
|
134
|
+
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' });
|
|
135
|
+
}
|
|
136
|
+
else if (entry.isFile()) {
|
|
137
|
+
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return rankCandidates(candidates, fragment, this.config.maxResults);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function resolveDisplayDirectory(root, displayDirectory, signal) {
|
|
144
|
+
const resolvedRoot = resolve(root);
|
|
145
|
+
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory);
|
|
146
|
+
const fromRoot = relative(resolvedRoot, absolute);
|
|
147
|
+
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`))
|
|
148
|
+
return undefined;
|
|
149
|
+
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
|
150
|
+
if (isAbsolute(fromRoot))
|
|
151
|
+
return undefined;
|
|
152
|
+
let current = resolvedRoot;
|
|
153
|
+
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
current = join(current, segment);
|
|
156
|
+
try {
|
|
157
|
+
const status = await lstat(current);
|
|
158
|
+
signal.throwIfAborted();
|
|
159
|
+
if (status.isSymbolicLink() || !status.isDirectory())
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
catch (_error) {
|
|
163
|
+
signal.throwIfAborted();
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return absolute;
|
|
168
|
+
}
|
|
169
|
+
async function readDirectory(absolute, signal) {
|
|
170
|
+
signal.throwIfAborted();
|
|
171
|
+
try {
|
|
172
|
+
const entries = await readdir(absolute, { withFileTypes: true });
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
return entries.sort((left, right) => compareText(left.name, right.name));
|
|
175
|
+
}
|
|
176
|
+
catch (_error) {
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
// An unreadable/missing subtree contributes no candidates; other readable
|
|
179
|
+
// branches remain useful and autocomplete is advisory.
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function visibleForGlobalQuery(path, query) {
|
|
184
|
+
if (query.startsWith('.') || query.includes('/.'))
|
|
185
|
+
return true;
|
|
186
|
+
return !path.split('/').some(segment => segment.startsWith('.'));
|
|
187
|
+
}
|
|
188
|
+
function rankCandidates(candidates, query, limit) {
|
|
189
|
+
const ranked = [];
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
const score = scoreCandidate(candidate, query);
|
|
192
|
+
if (score !== undefined)
|
|
193
|
+
ranked.push({ candidate, score });
|
|
194
|
+
}
|
|
195
|
+
ranked.sort((left, right) => right.score - left.score
|
|
196
|
+
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
|
197
|
+
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
|
198
|
+
|| compareText(left.candidate.path, right.candidate.path));
|
|
199
|
+
return ranked.slice(0, limit).map(entry => entry.candidate);
|
|
200
|
+
}
|
|
201
|
+
function scoreCandidate(candidate, query) {
|
|
202
|
+
if (query === '')
|
|
203
|
+
return 0;
|
|
204
|
+
const path = candidate.path.toLowerCase();
|
|
205
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
206
|
+
const needle = query.toLowerCase();
|
|
207
|
+
const directoryBonus = candidate.kind === 'directory' ? 25 : 0;
|
|
208
|
+
if (name === needle)
|
|
209
|
+
return 1_000 + directoryBonus;
|
|
210
|
+
if (name.startsWith(needle))
|
|
211
|
+
return 900 + directoryBonus;
|
|
212
|
+
if (name.includes(needle))
|
|
213
|
+
return 700 + directoryBonus;
|
|
214
|
+
if (path.includes(needle))
|
|
215
|
+
return 500 + directoryBonus;
|
|
216
|
+
const subsequence = subsequenceScore(path, needle);
|
|
217
|
+
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus;
|
|
218
|
+
}
|
|
219
|
+
function subsequenceScore(target, query) {
|
|
220
|
+
let targetIndex = 0;
|
|
221
|
+
let gap = 0;
|
|
222
|
+
for (const character of query) {
|
|
223
|
+
const found = target.indexOf(character, targetIndex);
|
|
224
|
+
if (found < 0)
|
|
225
|
+
return undefined;
|
|
226
|
+
gap += found - targetIndex;
|
|
227
|
+
targetIndex = found + 1;
|
|
228
|
+
}
|
|
229
|
+
return Math.max(0, 100 - gap);
|
|
230
|
+
}
|
|
231
|
+
function kindRank(kind) {
|
|
232
|
+
return kind === 'directory' ? 0 : 1;
|
|
233
|
+
}
|
|
234
|
+
function compareText(left, right) {
|
|
235
|
+
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
|
236
|
+
* order determines which comparison direction sort requests. */
|
|
237
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
function waitForPromise(promise, signal) {
|
|
240
|
+
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
|
241
|
+
if (signal.aborted)
|
|
242
|
+
return Promise.reject(errorReason(signal.reason, 'file search aborted'));
|
|
243
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
244
|
+
const onAbort = () => { rejectPromise(errorReason(signal.reason, 'file search aborted')); };
|
|
245
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
246
|
+
promise.then((value) => {
|
|
247
|
+
signal.removeEventListener('abort', onAbort);
|
|
248
|
+
resolvePromise(value);
|
|
249
|
+
}, (error) => {
|
|
250
|
+
signal.removeEventListener('abort', onAbort);
|
|
251
|
+
rejectPromise(errorReason(error, 'file search index failed'));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function errorReason(reason, fallback) {
|
|
256
|
+
return reason instanceof Error ? reason : new Error(fallback, { cause: reason });
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=search.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-file-reference-local",
|
|
3
|
+
"description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes",
|
|
4
|
+
"version": "0.1.0-rc.8",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/context/file-reference-local"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./search": {
|
|
22
|
+
"types": "./lib/types/search.d.ts",
|
|
23
|
+
"default": "./lib/types/search.js"
|
|
24
|
+
},
|
|
25
|
+
"./invariant": {
|
|
26
|
+
"types": "./lib/types/invariant.d.ts",
|
|
27
|
+
"default": "./lib/invariant.js"
|
|
28
|
+
},
|
|
29
|
+
"./src/*": "./src/*",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"lib/index.js",
|
|
34
|
+
"lib/invariant.js",
|
|
35
|
+
"lib/types/**/*.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
|
|
44
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
|
45
|
+
"@deepseek-ai/dsh-file-reference": "^0.1.0-rc.8",
|
|
46
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
|
|
47
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
|
48
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
|
|
52
|
+
"@deepseek-ai/dsh-file-reference": "^0.1.0-rc.8",
|
|
53
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
|
54
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
|
|
55
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
57
|
+
}
|
|
58
|
+
}
|