@swoop111/dsh-tool-fs 0.0.0-stage → 0.2.0-rc.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/LICENSE +21 -0
- package/README.i18n.yaml +142 -0
- package/README.md +277 -2
- package/README.zh.md +278 -0
- package/lib/index.js +1838 -0
- package/lib/types/completion.d.ts +58 -0
- package/lib/types/diff.d.ts +41 -0
- package/lib/types/edit.d.ts +73 -0
- package/lib/types/error.d.ts +19 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/read-image.d.ts +80 -0
- package/lib/types/read-render.d.ts +129 -0
- package/lib/types/read-target.d.ts +33 -0
- package/lib/types/read.d.ts +150 -0
- package/lib/types/sandbox.d.ts +82 -0
- package/lib/types/session-cwd.d.ts +26 -0
- package/lib/types/structure.d.ts +67 -0
- package/lib/types/write.d.ts +57 -0
- package/package.json +70 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { DEFAULT_MAX_FILE_CHARS, DEFAULT_MAX_WINDOW_LINES, createDisclosureLedger, createReadWindowProjection, createSessionPathProjection, repairMissingPath, repairStrReplace } from "@deepseek-ai/dsh-fs-edit-repair";
|
|
3
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
4
|
+
import { repairReadArgs } from "@deepseek-ai/dsh-arg-repair";
|
|
5
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
6
|
+
import { readLangHintForPath as langFromPath } from "@deepseek-ai/dsh-util-code-language";
|
|
7
|
+
import { structuredPatch } from "diff";
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
import { AttachmentError, AttachmentId } from "@deepseek-ai/dsh-attachment";
|
|
10
|
+
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, sandboxPermissionsDescription, validateEscalationArgs } from "@deepseek-ai/dsh-sandbox";
|
|
11
|
+
//#region lib/types/read-render.js
|
|
12
|
+
/**
|
|
13
|
+
* Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and
|
|
14
|
+
* model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant
|
|
15
|
+
* line cannot grow memory without bound.
|
|
16
|
+
* @module @deepseek-ai/dsh-tool-fs/read-render
|
|
17
|
+
*/
|
|
18
|
+
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
|
19
|
+
const READ_MAX_LINE_LENGTH = 2e3;
|
|
20
|
+
/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
|
|
21
|
+
const READ_MAX_BYTES = 50 * 1024;
|
|
22
|
+
function newAccumulator() {
|
|
23
|
+
return {
|
|
24
|
+
lines: [],
|
|
25
|
+
totalLines: 0,
|
|
26
|
+
outputBytes: 0,
|
|
27
|
+
truncatedByBytes: false
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function truncateLine(line, maxLineLength) {
|
|
31
|
+
return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line;
|
|
32
|
+
}
|
|
33
|
+
function lineByteSize(line, currentLineCount) {
|
|
34
|
+
return Buffer.byteLength(line, "utf8") + (currentLineCount > 0 ? 1 : 0);
|
|
35
|
+
}
|
|
36
|
+
function consumeLine(acc, rawLine, request) {
|
|
37
|
+
acc.totalLines += 1;
|
|
38
|
+
if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return;
|
|
39
|
+
const text = truncateLine(rawLine, request.maxLineLength);
|
|
40
|
+
const bytes = lineByteSize(text, acc.lines.length);
|
|
41
|
+
if (acc.outputBytes + bytes > request.maxBytes) {
|
|
42
|
+
acc.truncatedByBytes = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
acc.outputBytes += bytes;
|
|
46
|
+
acc.lines.push({
|
|
47
|
+
number: acc.totalLines,
|
|
48
|
+
text
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function stripCarriageReturn(line) {
|
|
52
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
53
|
+
}
|
|
54
|
+
function finish(acc, request, displayPath) {
|
|
55
|
+
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, "FS_NOT_FOUND");
|
|
56
|
+
return {
|
|
57
|
+
lines: acc.lines,
|
|
58
|
+
totalLines: acc.totalLines,
|
|
59
|
+
truncatedByBytes: acc.truncatedByBytes
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const OFFSET_OUT_OF_RANGE_RE = /^offset (-?\d+) is out of range for "(?:[^"\\]|\\"|\\\\)*" \((\d+) lines\)$/u;
|
|
63
|
+
/**
|
|
64
|
+
* Recognize this package's own offset-out-of-range `FsError` and return its scan
|
|
65
|
+
* facts, so the read tool can tail-anchor the window instead of erroring. The
|
|
66
|
+
* message and this parser live in the same module on purpose: the format is a
|
|
67
|
+
* package-internal contract, pinned by tests on both sides.
|
|
68
|
+
* @param error - the caught value from {@link buildWindow}.
|
|
69
|
+
* @returns the requested offset and observed line count, or `undefined` for any other failure.
|
|
70
|
+
*/
|
|
71
|
+
function parseOffsetOutOfRange(error) {
|
|
72
|
+
if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") return void 0;
|
|
73
|
+
const match = error.message.match(OFFSET_OUT_OF_RANGE_RE);
|
|
74
|
+
if (match === null) return void 0;
|
|
75
|
+
const requestedOffset = Number(match[1]);
|
|
76
|
+
const totalLines = Number(match[2]);
|
|
77
|
+
return Number.isSafeInteger(requestedOffset) && Number.isSafeInteger(totalLines) ? {
|
|
78
|
+
requestedOffset,
|
|
79
|
+
totalLines
|
|
80
|
+
} : void 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build one window from streamed or whole-file chunks, enforcing line and byte caps while still
|
|
84
|
+
* scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is
|
|
85
|
+
* past EOF.
|
|
86
|
+
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
|
|
87
|
+
* @param request - the resolved window; the caller has already applied its defaults and caps.
|
|
88
|
+
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
|
|
89
|
+
* @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag.
|
|
90
|
+
*/
|
|
91
|
+
async function buildWindow(chunks, request, displayPath) {
|
|
92
|
+
const acc = newAccumulator();
|
|
93
|
+
const lineBufferCap = request.maxLineLength + 1;
|
|
94
|
+
let lineBuffer = "";
|
|
95
|
+
function appendToLineBuffer(segment) {
|
|
96
|
+
if (lineBuffer.length >= lineBufferCap) return;
|
|
97
|
+
lineBuffer += segment;
|
|
98
|
+
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap);
|
|
99
|
+
}
|
|
100
|
+
function flushLine() {
|
|
101
|
+
consumeLine(acc, stripCarriageReturn(lineBuffer), request);
|
|
102
|
+
lineBuffer = "";
|
|
103
|
+
}
|
|
104
|
+
for await (const chunk of chunks) {
|
|
105
|
+
let startPos = 0;
|
|
106
|
+
let newlinePos;
|
|
107
|
+
while ((newlinePos = chunk.indexOf("\n", startPos)) !== -1) {
|
|
108
|
+
appendToLineBuffer(chunk.slice(startPos, newlinePos));
|
|
109
|
+
flushLine();
|
|
110
|
+
startPos = newlinePos + 1;
|
|
111
|
+
}
|
|
112
|
+
appendToLineBuffer(chunk.slice(startPos));
|
|
113
|
+
}
|
|
114
|
+
if (lineBuffer.length > 0) flushLine();
|
|
115
|
+
return finish(acc, request, displayPath);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Format a read outcome as one OpenCode-style line-numbered text block body.
|
|
119
|
+
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
|
|
120
|
+
* @param outcome - the windowed read to render.
|
|
121
|
+
* @returns the model-facing envelope: numbered lines, a continuation or end-of-file footer, and the repair disclosure when one fired.
|
|
122
|
+
*/
|
|
123
|
+
function formatReadOutput(displayPath, outcome) {
|
|
124
|
+
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1);
|
|
125
|
+
let footer;
|
|
126
|
+
if (outcome.truncatedByBytes) footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`;
|
|
127
|
+
else if (endLine < outcome.totalLines) footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`;
|
|
128
|
+
else footer = `(End of file - total ${outcome.totalLines} lines)`;
|
|
129
|
+
return `<path>${displayPath}</path>
|
|
130
|
+
<type>file</type>
|
|
131
|
+
<content>
|
|
132
|
+
${outcome.lines.length > 0 ? `${outcome.lines.map((line) => `${line.number}: ${line.text}`).join("\n")}\n\n${footer}` : footer}${outcome.repairNote === void 0 ? "" : `\n(repair: ${outcome.repairNote})`}
|
|
133
|
+
</content>`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
|
|
137
|
+
* opaque `meta`). `number` must be a 1-based integer line number, since a card
|
|
138
|
+
* rendered from a zero, fractional, or non-finite line number would violate the
|
|
139
|
+
* 1-based numbering contract the read window promises.
|
|
140
|
+
*/
|
|
141
|
+
function isFileTextLine(value) {
|
|
142
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
143
|
+
const { number, text } = value;
|
|
144
|
+
return typeof number === "number" && Number.isInteger(number) && number >= 1 && typeof text === "string";
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Narrow opaque live or replayed result metadata to a structured read window.
|
|
148
|
+
* Malformed metadata returns `undefined` so presentation can fall back to the
|
|
149
|
+
* generic text card instead of throwing during replay. Beyond shape, the
|
|
150
|
+
* semantic contract of a read window is enforced against replayed JSON that is
|
|
151
|
+
* well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
|
|
152
|
+
* must be a non-negative integer, each line number must be a 1-based integer no
|
|
153
|
+
* less than `offset`, the line numbers must strictly increase, and no line number
|
|
154
|
+
* may exceed `totalLines`. Any violation declines to the generic fallback rather
|
|
155
|
+
* than emitting a card that misnumbers or overcounts.
|
|
156
|
+
* @param meta - result metadata.
|
|
157
|
+
* @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
|
|
158
|
+
*/
|
|
159
|
+
function readMetaFromMeta(meta) {
|
|
160
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
|
|
161
|
+
const { path, offset, lines, totalLines, lang } = meta;
|
|
162
|
+
if (typeof path !== "string" || typeof totalLines !== "number" || typeof offset !== "number") return void 0;
|
|
163
|
+
if (!Number.isInteger(offset) || offset < 1) return void 0;
|
|
164
|
+
if (!Number.isInteger(totalLines) || totalLines < 0) return void 0;
|
|
165
|
+
if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return void 0;
|
|
166
|
+
if (lang !== void 0 && typeof lang !== "string") return void 0;
|
|
167
|
+
let previous = offset - 1;
|
|
168
|
+
for (const { number } of lines) {
|
|
169
|
+
if (number <= previous || number > totalLines) return void 0;
|
|
170
|
+
previous = number;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
path,
|
|
174
|
+
offset,
|
|
175
|
+
lines,
|
|
176
|
+
totalLines,
|
|
177
|
+
...lang === void 0 ? {} : { lang }
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region lib/types/session-cwd.js
|
|
182
|
+
/**
|
|
183
|
+
* Derive the working directory a filesystem tool resolves relative paths against: the calling
|
|
184
|
+
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each session's
|
|
185
|
+
* `read`/`write`/`edit` act on its workspace, not the server's launch directory.
|
|
186
|
+
* Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading
|
|
187
|
+
* `process.cwd()` at the tool boundary.
|
|
188
|
+
* @module @deepseek-ai/dsh-tool-fs/session-cwd
|
|
189
|
+
*/
|
|
190
|
+
/**
|
|
191
|
+
* The session workspace cwd for this call, or `undefined` when none applies.
|
|
192
|
+
* @param exec - the tool-execution context; only its optional `agent` is read.
|
|
193
|
+
* @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
|
|
194
|
+
*/
|
|
195
|
+
function sessionCwd(exec) {
|
|
196
|
+
return exec.agent?.session.header.cwd;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Resolution options shared by all model-facing filesystem tools.
|
|
200
|
+
* @param exec - the tool-execution context supplying session cwd and cancellation.
|
|
201
|
+
* @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
|
|
202
|
+
* @returns provider resolution options for the current tool call.
|
|
203
|
+
*/
|
|
204
|
+
function sessionResolveOptions(exec, policyWorkspaceRoot) {
|
|
205
|
+
const cwd = policyWorkspaceRoot ?? sessionCwd(exec);
|
|
206
|
+
return {
|
|
207
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
208
|
+
signal: exec.signal
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region lib/types/read-target.js
|
|
213
|
+
/**
|
|
214
|
+
* Shared path resolution and regular-file validation for model-facing read tools.
|
|
215
|
+
* @module @deepseek-ai/dsh-tool-fs/src/read-target
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* Resolve a model-supplied path, observe absence, and require a regular file.
|
|
219
|
+
* @param ctx - the plugin context providing filesystem resolution and observation events.
|
|
220
|
+
* @param exec - the current tool execution, including session cwd and cancellation.
|
|
221
|
+
* @param requestedPath - the raw path supplied to the tool.
|
|
222
|
+
* @returns the resolved target and its single stat result.
|
|
223
|
+
*/
|
|
224
|
+
async function resolveRegularReadTarget(ctx, exec, requestedPath) {
|
|
225
|
+
const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec));
|
|
226
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
227
|
+
if (info === void 0) {
|
|
228
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
229
|
+
throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
230
|
+
}
|
|
231
|
+
if (info.type !== "file") throw new FsError(`cannot read "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
232
|
+
return {
|
|
233
|
+
target,
|
|
234
|
+
info
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Attempt the engine's missing-path composition for a failed tool target: a
|
|
239
|
+
* unique match among the session's prior successful path arguments, then a
|
|
240
|
+
* unique directory-tree completion, each proven present before adoption. A
|
|
241
|
+
* tool never re-anchors on ambiguity, so a wrong guess cannot replace a real
|
|
242
|
+
* answer — the caller keeps its verbatim error when this returns `undefined`.
|
|
243
|
+
* @param ctx - the plugin context providing filesystem resolution and listing.
|
|
244
|
+
* @param exec - the current tool execution, including session cwd and cancellation.
|
|
245
|
+
* @param requestedPath - the raw path the model supplied.
|
|
246
|
+
* @param knownPaths - the session's prior successful path arguments, most recent first.
|
|
247
|
+
* @returns the verified replacement path with its disclosure, or `undefined`.
|
|
248
|
+
*/
|
|
249
|
+
async function repairTargetPath(ctx, exec, requestedPath, knownPaths) {
|
|
250
|
+
const cwd = sessionCwd(exec);
|
|
251
|
+
return await repairMissingPath(requestedPath, {
|
|
252
|
+
knownPaths,
|
|
253
|
+
explorer: fsPathExplorer(ctx, exec.signal),
|
|
254
|
+
...cwd === void 0 ? {} : { root: cwd },
|
|
255
|
+
verify: async (candidate) => {
|
|
256
|
+
const target = await ctx.fs.resolve(candidate, sessionResolveOptions(exec));
|
|
257
|
+
return await ctx.fs.stat(target, exec.signal) !== void 0;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** The filesystem itself as the engine's read-only probe: entry names, undefined when unreadable. */
|
|
262
|
+
function fsPathExplorer(ctx, signal) {
|
|
263
|
+
return { listDir: async (directory) => {
|
|
264
|
+
const target = await ctx.fs.resolve(directory, { signal }).catch(() => void 0);
|
|
265
|
+
if (target === void 0) return void 0;
|
|
266
|
+
return await ctx.fs.listDir(target, signal).then((entries) => entries.map((entry) => entry.name)).catch(() => void 0);
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region lib/types/structure.js
|
|
271
|
+
/**
|
|
272
|
+
* Structural analysis of file text for read-window completion: markdown
|
|
273
|
+
* fences, bracket nesting, and indentation blocks. The analyzers are pure
|
|
274
|
+
* functions over already-delivered text; they decide which construct a window
|
|
275
|
+
* boundary falls inside, and how many neighbouring lines close an open one.
|
|
276
|
+
*
|
|
277
|
+
* Precision is traded for never touching delivered content: a wrong verdict
|
|
278
|
+
* only costs a few appended real lines, or a silent non-completion. String
|
|
279
|
+
* literals are scanned per line (an unterminated quote never carries into the
|
|
280
|
+
* next line), line comments and C-style block comments are skipped, and
|
|
281
|
+
* brackets inside an open fence are content, so prose and code samples do not
|
|
282
|
+
* fabricate unbalanced constructs.
|
|
283
|
+
* @module @deepseek-ai/dsh-tool-fs/structure
|
|
284
|
+
*/
|
|
285
|
+
/** The fence marker of one line, or `undefined` when the line is not a fence. */
|
|
286
|
+
function fenceLine(line) {
|
|
287
|
+
const rest = line.slice(line.length - line.trimStart().length);
|
|
288
|
+
if (line.length - rest.length > 3) return void 0;
|
|
289
|
+
const char = rest.charAt(0);
|
|
290
|
+
if (char !== "`" && char !== "~") return void 0;
|
|
291
|
+
let length = 0;
|
|
292
|
+
while (rest.charAt(length) === char) length += 1;
|
|
293
|
+
if (length < 3) return void 0;
|
|
294
|
+
const info = rest.slice(length);
|
|
295
|
+
if (char === "`" && info.includes("`")) return void 0;
|
|
296
|
+
return {
|
|
297
|
+
marker: rest.slice(0, length),
|
|
298
|
+
info
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Whether one line closes an open fence: same family, at least as long, no info string. */
|
|
302
|
+
function closesFence(marker, line) {
|
|
303
|
+
const candidate = fenceLine(line);
|
|
304
|
+
return candidate !== void 0 && candidate.marker.slice(0, 1) === marker.slice(0, 1) && candidate.marker.length >= marker.length && candidate.info.trim().length === 0;
|
|
305
|
+
}
|
|
306
|
+
/** Code brackets of one line: string literals and comments are skipped. */
|
|
307
|
+
function codeBrackets(line, state) {
|
|
308
|
+
const found = [];
|
|
309
|
+
let quote;
|
|
310
|
+
let i = 0;
|
|
311
|
+
while (i < line.length) {
|
|
312
|
+
const ch = line.charAt(i);
|
|
313
|
+
if (state.inBlockComment) {
|
|
314
|
+
if (ch === "*" && line.charAt(i + 1) === "/") {
|
|
315
|
+
state.inBlockComment = false;
|
|
316
|
+
i += 2;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (quote !== void 0) {
|
|
323
|
+
if (ch === "\\") {
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (ch === quote) quote = void 0;
|
|
328
|
+
i += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
332
|
+
quote = ch;
|
|
333
|
+
i += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "/" && line.charAt(i + 1) === "/") break;
|
|
337
|
+
if (ch === "/" && line.charAt(i + 1) === "*") {
|
|
338
|
+
state.inBlockComment = true;
|
|
339
|
+
i += 2;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (ch === "(" || ch === "[" || ch === "{" || ch === ")" || ch === "]" || ch === "}") found.push(ch);
|
|
343
|
+
i += 1;
|
|
344
|
+
}
|
|
345
|
+
return found;
|
|
346
|
+
}
|
|
347
|
+
/** Whether one bracket character opens a level. */
|
|
348
|
+
function isOpener(ch) {
|
|
349
|
+
return ch === "(" || ch === "[" || ch === "{";
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Scan a line sequence for the constructs it leaves open.
|
|
353
|
+
* @param lines - the lines to scan, in file order.
|
|
354
|
+
* @returns the open fence and the opening lines of every still-open bracket level.
|
|
355
|
+
*/
|
|
356
|
+
function scan(lines) {
|
|
357
|
+
const state = { inBlockComment: false };
|
|
358
|
+
const openBracketLines = [];
|
|
359
|
+
let fence;
|
|
360
|
+
for (const [index, line] of lines.entries()) {
|
|
361
|
+
if (fence !== void 0) {
|
|
362
|
+
if (closesFence(fence.marker, line)) fence = void 0;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const candidate = fenceLine(line);
|
|
366
|
+
if (candidate !== void 0) {
|
|
367
|
+
fence = {
|
|
368
|
+
marker: candidate.marker,
|
|
369
|
+
lineIndex: index
|
|
370
|
+
};
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
for (const ch of codeBrackets(line, state)) if (isOpener(ch)) openBracketLines.push(index);
|
|
374
|
+
else openBracketLines.pop();
|
|
375
|
+
}
|
|
376
|
+
return fence === void 0 ? { openBracketLines } : {
|
|
377
|
+
fence,
|
|
378
|
+
openBracketLines
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The indentation block a line sequence ends inside: its last non-blank line is
|
|
383
|
+
* indented deeper than the nearest preceding shallower line, so a Python-style
|
|
384
|
+
* block may still be open.
|
|
385
|
+
* @param lines - the lines to analyze, in file order.
|
|
386
|
+
* @returns the open indented block, or `undefined` when the tail is balanced.
|
|
387
|
+
*/
|
|
388
|
+
function detectOpenIndent(lines) {
|
|
389
|
+
const lastLine = lines.findLast((line) => line.trim().length > 0);
|
|
390
|
+
if (lastLine === void 0) return void 0;
|
|
391
|
+
const width = indentWidth(lastLine);
|
|
392
|
+
if (width === 0) return void 0;
|
|
393
|
+
const lastContent = lines.findLastIndex((line) => line.trim().length > 0);
|
|
394
|
+
for (const [offset, line] of lines.slice(0, lastContent).toReversed().entries()) {
|
|
395
|
+
if (line.trim().length === 0) continue;
|
|
396
|
+
const prior = indentWidth(line);
|
|
397
|
+
if (prior < width) return {
|
|
398
|
+
kind: "indent",
|
|
399
|
+
width,
|
|
400
|
+
lineIndex: lastContent - 1 - offset
|
|
401
|
+
};
|
|
402
|
+
if (prior === width) return void 0;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
kind: "indent",
|
|
406
|
+
width,
|
|
407
|
+
lineIndex: 0
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The construct a window ends inside.
|
|
412
|
+
* @param lines - the delivered window lines, in file order.
|
|
413
|
+
* @returns the construct to close, or `undefined` when the tail is balanced.
|
|
414
|
+
*/
|
|
415
|
+
function detectOpenTail(lines) {
|
|
416
|
+
const facts = scan(lines);
|
|
417
|
+
if (facts.fence !== void 0) return {
|
|
418
|
+
kind: "fence",
|
|
419
|
+
marker: facts.fence.marker,
|
|
420
|
+
lineIndex: facts.fence.lineIndex
|
|
421
|
+
};
|
|
422
|
+
const innermost = facts.openBracketLines.at(-1);
|
|
423
|
+
if (innermost !== void 0) return {
|
|
424
|
+
kind: "brace",
|
|
425
|
+
depth: facts.openBracketLines.length,
|
|
426
|
+
lineIndex: innermost
|
|
427
|
+
};
|
|
428
|
+
return detectOpenIndent(lines);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The indentation block a window starts inside: the window's first non-blank
|
|
432
|
+
* line is indented, and some earlier line is shallower, so the block header
|
|
433
|
+
* lies above the window.
|
|
434
|
+
* @param window - the delivered window lines, in file order.
|
|
435
|
+
* @param preceding - the lines immediately before the window, in file order.
|
|
436
|
+
* @returns the open indented block, or `undefined` when the window starts at a block boundary.
|
|
437
|
+
*/
|
|
438
|
+
function detectOpenIndentHead(window, preceding) {
|
|
439
|
+
const firstContent = window.find((line) => line.trim().length > 0);
|
|
440
|
+
if (firstContent === void 0) return void 0;
|
|
441
|
+
const width = indentWidth(firstContent);
|
|
442
|
+
if (width === 0) return void 0;
|
|
443
|
+
for (const [offset, line] of preceding.toReversed().entries()) {
|
|
444
|
+
if (line.trim().length === 0) continue;
|
|
445
|
+
if (indentWidth(line) < width) return {
|
|
446
|
+
kind: "indent",
|
|
447
|
+
startIndex: preceding.length - 1 - offset
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The construct a window starts inside, resolved from the lines that precede it.
|
|
453
|
+
* A fence outranks a bracket block, which outranks an indented block. A
|
|
454
|
+
* construct opened before the supplied preceding lines is not provable and
|
|
455
|
+
* reports nothing, so the completion stays silent rather than guessing.
|
|
456
|
+
* @param window - the delivered window lines, in file order.
|
|
457
|
+
* @param preceding - the lines immediately before the window, in file order; the last entry is the line that precedes the window.
|
|
458
|
+
* @returns the leading context, or `undefined` when the window starts at a construct boundary.
|
|
459
|
+
*/
|
|
460
|
+
function detectOpenHead(window, preceding) {
|
|
461
|
+
if (preceding.length === 0) return void 0;
|
|
462
|
+
const facts = scan(preceding);
|
|
463
|
+
if (facts.fence !== void 0) return {
|
|
464
|
+
kind: "fence",
|
|
465
|
+
startIndex: facts.fence.lineIndex
|
|
466
|
+
};
|
|
467
|
+
const outer = facts.openBracketLines[0];
|
|
468
|
+
if (outer !== void 0) return {
|
|
469
|
+
kind: "brace",
|
|
470
|
+
startIndex: outer
|
|
471
|
+
};
|
|
472
|
+
return detectOpenIndentHead(window, preceding);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* How many of the following lines close an open construct.
|
|
476
|
+
* @param construct - the construct {@link detectOpenTail} found.
|
|
477
|
+
* @param appended - the lines following the window, in file order.
|
|
478
|
+
* @returns the count of lines to append, or `0` when the sequence closes nothing.
|
|
479
|
+
*/
|
|
480
|
+
function closureLength(construct, appended) {
|
|
481
|
+
if (construct.kind === "fence") {
|
|
482
|
+
for (const [index, line] of appended.entries()) if (closesFence(construct.marker, line)) return index + 1;
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
if (construct.kind === "brace") {
|
|
486
|
+
const state = { inBlockComment: false };
|
|
487
|
+
let depth = construct.depth;
|
|
488
|
+
for (const [index, line] of appended.entries()) {
|
|
489
|
+
for (const ch of codeBrackets(line, state)) depth += isOpener(ch) ? 1 : -1;
|
|
490
|
+
if (depth <= 0) return index + 1;
|
|
491
|
+
}
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
let cut = -1;
|
|
495
|
+
for (const [index, line] of appended.entries()) {
|
|
496
|
+
if (line.trim().length === 0) continue;
|
|
497
|
+
if (indentWidth(line) < construct.width) break;
|
|
498
|
+
cut = index;
|
|
499
|
+
}
|
|
500
|
+
return cut + 1;
|
|
501
|
+
}
|
|
502
|
+
/** Leading indentation width of one line, counting a tab as four columns. */
|
|
503
|
+
function indentWidth(line) {
|
|
504
|
+
let width = 0;
|
|
505
|
+
for (const ch of line) if (ch === " ") width += 1;
|
|
506
|
+
else if (ch === " ") width += 4;
|
|
507
|
+
else break;
|
|
508
|
+
return width;
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region lib/types/completion.js
|
|
512
|
+
/**
|
|
513
|
+
* Window completion for reads: the neighbouring lines that give a delivered
|
|
514
|
+
* window its structural boundaries. The head is the single line that opened
|
|
515
|
+
* the construct the window starts inside; the tail is the run of following
|
|
516
|
+
* lines that closes the construct the window ends inside. Neither ever
|
|
517
|
+
* modifies, reorders, or drops a delivered line — a window either gains
|
|
518
|
+
* context or stays exactly as the caller asked for it.
|
|
519
|
+
*
|
|
520
|
+
* The head is one line because everything between that opening line and the
|
|
521
|
+
* window is the content the caller chose to skip; the tail is a run because
|
|
522
|
+
* the construct cannot be called closed until its closer arrives.
|
|
523
|
+
* @module @deepseek-ai/dsh-tool-fs/completion
|
|
524
|
+
*/
|
|
525
|
+
/**
|
|
526
|
+
* Complete a window's structural boundaries.
|
|
527
|
+
* @param lines - the delivered window lines, in file order.
|
|
528
|
+
* @param supply - the line sources on each side of the window.
|
|
529
|
+
* @param tuning - the hard caps for the added lines.
|
|
530
|
+
* @returns the head and tail lines with the rules that produced them; `undefined` when neither side completes.
|
|
531
|
+
*/
|
|
532
|
+
async function completeWindow(lines, supply, tuning) {
|
|
533
|
+
if (lines.length === 0) return void 0;
|
|
534
|
+
let head = [];
|
|
535
|
+
let headRule;
|
|
536
|
+
const preceding = await supply.preceding(tuning.maxLines);
|
|
537
|
+
if (preceding !== void 0 && preceding.length > 0) {
|
|
538
|
+
const context = detectOpenHead(lines.map((line) => line.text), preceding.map((line) => line.text));
|
|
539
|
+
const opening = context === void 0 ? void 0 : preceding[context.startIndex];
|
|
540
|
+
if (context !== void 0 && opening !== void 0) {
|
|
541
|
+
head = [opening];
|
|
542
|
+
headRule = context.kind;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
let tail = [];
|
|
546
|
+
let tailRule;
|
|
547
|
+
const construct = detectOpenTail([...head, ...lines].map((line) => line.text));
|
|
548
|
+
if (construct !== void 0) {
|
|
549
|
+
const following = await supply.following(tuning.maxLines);
|
|
550
|
+
if (following !== void 0) {
|
|
551
|
+
const closing = closureLength(construct, following.map((line) => line.text));
|
|
552
|
+
if (closing > 0) {
|
|
553
|
+
tail = following.slice(0, closing);
|
|
554
|
+
tailRule = construct.kind;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (headRule === void 0 && tailRule === void 0) return void 0;
|
|
559
|
+
return {
|
|
560
|
+
head,
|
|
561
|
+
tail,
|
|
562
|
+
...headRule === void 0 ? {} : { headRule },
|
|
563
|
+
...tailRule === void 0 ? {} : { tailRule }
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region lib/types/read.js
|
|
568
|
+
/**
|
|
569
|
+
* Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version,
|
|
570
|
+
* streams large or size-unknown files, renders a bounded window, then emits the observation.
|
|
571
|
+
* @module @deepseek-ai/dsh-tool-fs/src/read
|
|
572
|
+
*/
|
|
573
|
+
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
|
574
|
+
const READ_LIMIT = 2e3;
|
|
575
|
+
/**
|
|
576
|
+
* Default streaming threshold (the `readStreamMinSize` config): files at or
|
|
577
|
+
* above this size stream; smaller files read whole into memory.
|
|
578
|
+
*/
|
|
579
|
+
const STREAM_MIN_SIZE = 10 * 1024 * 1024;
|
|
580
|
+
function parsePositiveInteger(value, name) {
|
|
581
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap.
|
|
586
|
+
* With repair enabled, the measured addressing failures are normalized first through
|
|
587
|
+
* {@link repairReadArgs}: a 0-based or negative start index and an over-cap or
|
|
588
|
+
* non-positive limit all carry unambiguous intent, so the value is substituted
|
|
589
|
+
* and the rule reported instead of erroring.
|
|
590
|
+
* @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given unrepairable shapes.
|
|
591
|
+
* @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted.
|
|
592
|
+
* @param repairEnabled - the read repair engine's switch; disabled keeps the strict validation errors.
|
|
593
|
+
* @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`, plus the rules that fired.
|
|
594
|
+
*/
|
|
595
|
+
function parseReadArgs(args, maxLimit, repairEnabled = true) {
|
|
596
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
597
|
+
const repair = repairEnabled ? repairReadArgs(args.offset, args.limit, maxLimit) : void 0;
|
|
598
|
+
const offset = repair?.offset ?? (args.offset === void 0 ? 1 : parsePositiveInteger(args.offset, "offset"));
|
|
599
|
+
const limit = repair?.limit ?? (args.limit === void 0 ? maxLimit : parsePositiveInteger(args.limit, "limit"));
|
|
600
|
+
if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`);
|
|
601
|
+
return {
|
|
602
|
+
filePath: args.file_path,
|
|
603
|
+
offset,
|
|
604
|
+
limit,
|
|
605
|
+
force: args.force === true,
|
|
606
|
+
...repair === void 0 ? {} : { repairs: repair.repairs }
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The model-facing failure for a blocked duplicate read: the file is unchanged,
|
|
611
|
+
* what was delivered and how many steps ago, and the ways past the refusal.
|
|
612
|
+
*/
|
|
613
|
+
function duplicateMessage(verdict) {
|
|
614
|
+
const { record, steps } = verdict;
|
|
615
|
+
return `duplicate read blocked: lines ${record.offset}-${record.endLine} unchanged, delivered ${steps} step${steps === 1 ? "" : "s"} ago; that content is still above. Use offset=${record.endLine + 1}, or force=true.`;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Register the `read` tool and its scope-aware system-prompt guidance.
|
|
619
|
+
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
|
620
|
+
* @param caps - the deployment's resolved read caps (plugin config after defaulting).
|
|
621
|
+
* @param repair - the read repair tuning; disabled keeps every failure's verbatim error.
|
|
622
|
+
*/
|
|
623
|
+
function applyReadTool(ctx, caps, repair) {
|
|
624
|
+
ctx.systemPrompt.section({
|
|
625
|
+
name: "tool:read",
|
|
626
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_READ"),
|
|
627
|
+
text: ({ scope }) => ctx.tools.get("read", scope) === void 0 ? "" : "Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files."
|
|
628
|
+
});
|
|
629
|
+
ctx.tools.register(defineTool({
|
|
630
|
+
name: "read",
|
|
631
|
+
description: "Read a UTF-8 text file and return line-numbered content.",
|
|
632
|
+
parameters: {
|
|
633
|
+
file_path: {
|
|
634
|
+
type: "string",
|
|
635
|
+
required: true,
|
|
636
|
+
description: "Path to read, resolved by the filesystem backend."
|
|
637
|
+
},
|
|
638
|
+
offset: {
|
|
639
|
+
type: "number",
|
|
640
|
+
description: "1-based first line to return. Defaults to 1."
|
|
641
|
+
},
|
|
642
|
+
limit: {
|
|
643
|
+
type: "number",
|
|
644
|
+
description: `Maximum number of lines to return. Defaults to ${caps.limit}.`
|
|
645
|
+
},
|
|
646
|
+
...repair.duplicate.enabled ? { force: {
|
|
647
|
+
type: "boolean",
|
|
648
|
+
description: "Read this window even when it was recently delivered and the file is unchanged."
|
|
649
|
+
} } : {}
|
|
650
|
+
},
|
|
651
|
+
output: {
|
|
652
|
+
schema: {
|
|
653
|
+
type: "object",
|
|
654
|
+
additionalProperties: false,
|
|
655
|
+
properties: {
|
|
656
|
+
path: {
|
|
657
|
+
type: "string",
|
|
658
|
+
required: true
|
|
659
|
+
},
|
|
660
|
+
offset: {
|
|
661
|
+
type: "integer",
|
|
662
|
+
required: true
|
|
663
|
+
},
|
|
664
|
+
lines: {
|
|
665
|
+
type: "array",
|
|
666
|
+
required: true,
|
|
667
|
+
items: {
|
|
668
|
+
type: "object",
|
|
669
|
+
additionalProperties: false,
|
|
670
|
+
properties: {
|
|
671
|
+
number: {
|
|
672
|
+
type: "integer",
|
|
673
|
+
required: true
|
|
674
|
+
},
|
|
675
|
+
text: {
|
|
676
|
+
type: "string",
|
|
677
|
+
required: true
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
totalLines: {
|
|
683
|
+
type: "integer",
|
|
684
|
+
required: true
|
|
685
|
+
},
|
|
686
|
+
note: { type: "string" }
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
render: (args, value) => {
|
|
690
|
+
const input = parseReadArgs(args, caps.limit, repair.enabled);
|
|
691
|
+
const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1);
|
|
692
|
+
const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines;
|
|
693
|
+
return [{
|
|
694
|
+
type: "text",
|
|
695
|
+
text: formatReadOutput(value.path, {
|
|
696
|
+
offset: value.offset,
|
|
697
|
+
lines: value.lines,
|
|
698
|
+
totalLines: value.totalLines,
|
|
699
|
+
...truncatedByBytes ? { truncatedByBytes: true } : {},
|
|
700
|
+
...value.note === void 0 ? {} : { repairNote: value.note }
|
|
701
|
+
})
|
|
702
|
+
}];
|
|
703
|
+
},
|
|
704
|
+
presentationMeta: (_args, value) => {
|
|
705
|
+
const lang = langFromPath(value.path);
|
|
706
|
+
return {
|
|
707
|
+
path: value.path,
|
|
708
|
+
offset: value.offset,
|
|
709
|
+
lines: value.lines.map(({ number, text }) => ({
|
|
710
|
+
number,
|
|
711
|
+
text
|
|
712
|
+
})),
|
|
713
|
+
totalLines: value.totalLines,
|
|
714
|
+
...lang === void 0 ? {} : { lang }
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
isConcurrencySafe: () => true,
|
|
719
|
+
async execute(args, exec) {
|
|
720
|
+
const input = parseReadArgs(args, caps.limit, repair.enabled);
|
|
721
|
+
const session = exec.agent?.session;
|
|
722
|
+
const notes = (input.repairs ?? []).filter((rule) => repair.claimDisclosure(session, `read:${rule.rule}`)).map((rule) => rule.note);
|
|
723
|
+
let requestedOffset = input.offset;
|
|
724
|
+
let target;
|
|
725
|
+
let info;
|
|
726
|
+
try {
|
|
727
|
+
({target, info} = await resolveRegularReadTarget(ctx, exec, input.filePath));
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (!repair.enabled || !(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
|
|
730
|
+
const repaired = await repairTargetPath(ctx, exec, input.filePath, repair.priorPaths(exec.agent?.session));
|
|
731
|
+
if (repaired === void 0) throw error;
|
|
732
|
+
notes.push(repaired.note);
|
|
733
|
+
({target, info} = await resolveRegularReadTarget(ctx, exec, repaired.path));
|
|
734
|
+
}
|
|
735
|
+
if (repair.duplicate.enabled && !input.force) {
|
|
736
|
+
const verdict = repair.duplicate.judge(exec.agent?.session, input.filePath, info.version, {
|
|
737
|
+
offset: input.offset,
|
|
738
|
+
endLine: input.offset + input.limit - 1
|
|
739
|
+
});
|
|
740
|
+
if (verdict?.action === "block") throw new Error(duplicateMessage(verdict));
|
|
741
|
+
}
|
|
742
|
+
const readChunks = () => info.size === void 0 || info.size >= caps.streamMinSize ? ctx.fs.streamText(target, exec.signal) : ctx.fs.readText(target, exec.signal).then((text) => [text]);
|
|
743
|
+
const windowRequest = {
|
|
744
|
+
offset: input.offset,
|
|
745
|
+
limit: input.limit,
|
|
746
|
+
maxLineLength: caps.maxLineLength,
|
|
747
|
+
maxBytes: caps.maxBytes
|
|
748
|
+
};
|
|
749
|
+
let window;
|
|
750
|
+
try {
|
|
751
|
+
window = await buildWindow(await readChunks(), windowRequest, target.displayPath);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
const out = repair.enabled ? parseOffsetOutOfRange(error) : void 0;
|
|
754
|
+
if (out === void 0) throw error;
|
|
755
|
+
const windowSize = Math.min(input.limit, out.totalLines);
|
|
756
|
+
requestedOffset = Math.max(1, out.totalLines - windowSize + 1);
|
|
757
|
+
window = await buildWindow(await readChunks(), {
|
|
758
|
+
...windowRequest,
|
|
759
|
+
offset: requestedOffset
|
|
760
|
+
}, target.displayPath);
|
|
761
|
+
}
|
|
762
|
+
const windowOffset = window.lines[0]?.number ?? requestedOffset;
|
|
763
|
+
if (repair.completion.enabled && window.lines.length > 0 && !window.truncatedByBytes) {
|
|
764
|
+
const firstAfterWindow = windowOffset + window.lines.length;
|
|
765
|
+
const fetchLines = async (offset, count) => {
|
|
766
|
+
return (await buildWindow(await readChunks(), {
|
|
767
|
+
...windowRequest,
|
|
768
|
+
offset,
|
|
769
|
+
limit: count,
|
|
770
|
+
maxBytes: Number.MAX_SAFE_INTEGER
|
|
771
|
+
}, target.displayPath)).lines;
|
|
772
|
+
};
|
|
773
|
+
const completion = await completeWindow(window.lines, {
|
|
774
|
+
preceding: async (count) => {
|
|
775
|
+
const lastBefore = windowOffset - 1;
|
|
776
|
+
if (lastBefore < 1) return void 0;
|
|
777
|
+
const offset = Math.max(1, lastBefore - count + 1);
|
|
778
|
+
return await fetchLines(offset, lastBefore - offset + 1);
|
|
779
|
+
},
|
|
780
|
+
following: async (count) => {
|
|
781
|
+
if (firstAfterWindow > window.totalLines) return void 0;
|
|
782
|
+
return await fetchLines(firstAfterWindow, count);
|
|
783
|
+
}
|
|
784
|
+
}, { maxLines: repair.completion.maxLines });
|
|
785
|
+
if (completion !== void 0) window = {
|
|
786
|
+
...window,
|
|
787
|
+
lines: [
|
|
788
|
+
...completion.head,
|
|
789
|
+
...window.lines,
|
|
790
|
+
...completion.tail
|
|
791
|
+
]
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const note = notes.length === 0 ? void 0 : notes.join("; ");
|
|
795
|
+
repair.duplicate.record(exec.agent?.session, input.filePath, {
|
|
796
|
+
offset: windowOffset,
|
|
797
|
+
endLine: window.lines.at(-1)?.number ?? Math.max(0, windowOffset - 1),
|
|
798
|
+
totalLines: window.totalLines,
|
|
799
|
+
version: info.version
|
|
800
|
+
});
|
|
801
|
+
const outcome = {
|
|
802
|
+
path: target.displayPath,
|
|
803
|
+
offset: window.lines[0]?.number ?? requestedOffset,
|
|
804
|
+
lines: window.lines,
|
|
805
|
+
totalLines: window.totalLines,
|
|
806
|
+
...note === void 0 ? {} : { note }
|
|
807
|
+
};
|
|
808
|
+
ctx.emit("fs/observed", target, {
|
|
809
|
+
kind: "present",
|
|
810
|
+
version: info.version
|
|
811
|
+
}, exec);
|
|
812
|
+
return outcome;
|
|
813
|
+
},
|
|
814
|
+
presentResult(_args, result) {
|
|
815
|
+
if (result.isError) return void 0;
|
|
816
|
+
const meta = readMetaFromMeta(result.meta);
|
|
817
|
+
if (meta === void 0) return void 0;
|
|
818
|
+
const only = result.content.length === 1 ? result.content[0] : void 0;
|
|
819
|
+
const text = only?.type === "text" ? only.text : void 0;
|
|
820
|
+
if (text === void 0) return void 0;
|
|
821
|
+
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1];
|
|
822
|
+
if (body === void 0) return void 0;
|
|
823
|
+
return {
|
|
824
|
+
card: "read",
|
|
825
|
+
path: meta.path,
|
|
826
|
+
offset: meta.offset,
|
|
827
|
+
lines: meta.lines,
|
|
828
|
+
totalLines: meta.totalLines,
|
|
829
|
+
...meta.lang === void 0 ? {} : { lang: meta.lang },
|
|
830
|
+
content: [{
|
|
831
|
+
type: "text",
|
|
832
|
+
text: body
|
|
833
|
+
}]
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
presentCall(args) {
|
|
837
|
+
const { offset, limit } = args;
|
|
838
|
+
const window = limit !== void 0 && limit > 0 ? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` : offset !== void 0 ? ` (from line ${offset})` : "";
|
|
839
|
+
return {
|
|
840
|
+
card: "generic",
|
|
841
|
+
title: `Read ${args.file_path}${window}`,
|
|
842
|
+
kind: "read",
|
|
843
|
+
locations: [{
|
|
844
|
+
path: args.file_path,
|
|
845
|
+
line: offset ?? 1
|
|
846
|
+
}]
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the
|
|
853
|
+
* applied change plus {@link DIFF_CONTEXT} context lines. Pure insertions use `oldText: null`,
|
|
854
|
+
* patch-only no-newline markers are omitted, and scattered replacements remain separate hunks.
|
|
855
|
+
*
|
|
856
|
+
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the
|
|
857
|
+
* bridge relativizes it).
|
|
858
|
+
* @param before - the file text before the change (the backend's LF-normalized diff basis).
|
|
859
|
+
* @param after - the file text after the change, on the same basis.
|
|
860
|
+
* @returns one diff per applied hunk, in file order; empty when the texts are identical.
|
|
861
|
+
*/
|
|
862
|
+
function computeHunkDiffs(path, before, after) {
|
|
863
|
+
const patch = structuredPatch("", "", before, after, void 0, void 0, { context: 3 });
|
|
864
|
+
const diffs = [];
|
|
865
|
+
for (const hunk of patch.hunks) {
|
|
866
|
+
const oldLines = [];
|
|
867
|
+
const newLines = [];
|
|
868
|
+
for (const line of hunk.lines) {
|
|
869
|
+
if (line.startsWith("\\")) continue;
|
|
870
|
+
const text = line.slice(1);
|
|
871
|
+
if (line.startsWith("-")) oldLines.push(text);
|
|
872
|
+
else if (line.startsWith("+")) newLines.push(text);
|
|
873
|
+
else {
|
|
874
|
+
oldLines.push(text);
|
|
875
|
+
newLines.push(text);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
diffs.push({
|
|
879
|
+
path,
|
|
880
|
+
oldText: oldLines.length > 0 ? oldLines.join("\n") : null,
|
|
881
|
+
newText: newLines.join("\n")
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return diffs;
|
|
885
|
+
}
|
|
886
|
+
/** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */
|
|
887
|
+
function isFileDiff(value) {
|
|
888
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
889
|
+
const { path, oldText, newText } = value;
|
|
890
|
+
return typeof path === "string" && (oldText === null || typeof oldText === "string") && typeof newText === "string";
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Narrow opaque live or replayed result metadata to non-empty file diffs. Malformed metadata
|
|
894
|
+
* returns `undefined` so presentation can fall back instead of throwing during replay.
|
|
895
|
+
* @param meta - result metadata.
|
|
896
|
+
* @returns validated hunks, or `undefined` for absent or malformed data.
|
|
897
|
+
*/
|
|
898
|
+
function diffsFromMeta(meta) {
|
|
899
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
|
|
900
|
+
const diffs = meta.diffs;
|
|
901
|
+
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return void 0;
|
|
902
|
+
return diffs;
|
|
903
|
+
}
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region lib/types/error.js
|
|
906
|
+
/**
|
|
907
|
+
* Model-facing diagnostics for guarded-mutation failures. Providers and
|
|
908
|
+
* policies retain operation-specific causes, while this package owns the
|
|
909
|
+
* stable message shown to the model.
|
|
910
|
+
* @module @deepseek-ai/dsh-tool-fs/src/error
|
|
911
|
+
*/
|
|
912
|
+
/**
|
|
913
|
+
* Render the stable model-facing diagnostic for a guarded-mutation failure.
|
|
914
|
+
* `FS_STALE_VERSION` keeps the provider's reason and appends its re-read
|
|
915
|
+
* remedy. `FS_NOT_OBSERVED` replaces operation-specific policy/provider text
|
|
916
|
+
* with one path-aware reason and read remedy. The original error remains the
|
|
917
|
+
* cause, and both diagnostics preserve its code for machine routing. Anything
|
|
918
|
+
* else passes through untouched.
|
|
919
|
+
* @param error - the caught value from a write/edit execution.
|
|
920
|
+
* @param displayPath - the resolved target path shown to the model.
|
|
921
|
+
* @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
|
|
922
|
+
*/
|
|
923
|
+
function remediateFsError(error, displayPath) {
|
|
924
|
+
if (!(error instanceof FsError)) return error;
|
|
925
|
+
if (error.code === "FS_NOT_OBSERVED") return new FsError(`cannot modify "${displayPath}": file has not been read — read the file, then retry`, error.code, { cause: error });
|
|
926
|
+
if (error.code === "FS_STALE_VERSION") return new FsError(`${error.message} — re-read the file, then retry`, error.code, { cause: error });
|
|
927
|
+
return error;
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region lib/types/write.js
|
|
931
|
+
/**
|
|
932
|
+
* Model-facing full-file write. It obtains an optional intent from the single policy slot, calls
|
|
933
|
+
* `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an
|
|
934
|
+
* unconditional atomic create-or-overwrite.
|
|
935
|
+
* @module @deepseek-ai/dsh-tool-fs/src/write
|
|
936
|
+
*/
|
|
937
|
+
/**
|
|
938
|
+
* Validate value constraints the schema DSL can't express: only a non-blank
|
|
939
|
+
* `file_path` — an empty `content` is legitimate (it writes an empty file).
|
|
940
|
+
* @param args - the schema-validated raw tool arguments.
|
|
941
|
+
* @returns the camelCased input; `content` passes through untouched.
|
|
942
|
+
*/
|
|
943
|
+
function parseWriteArgs(args) {
|
|
944
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
945
|
+
return {
|
|
946
|
+
filePath: args.file_path,
|
|
947
|
+
content: args.content
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Format a write outcome as one model-facing text block body.
|
|
952
|
+
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
|
|
953
|
+
* @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
|
|
954
|
+
* @param hint - the drifted-path disclosure appended inside the content block, or undefined.
|
|
955
|
+
* @returns the model-facing confirmation envelope (no file content is echoed back).
|
|
956
|
+
*/
|
|
957
|
+
function formatWriteOutput(displayPath, outcome, hint) {
|
|
958
|
+
return `<path>${displayPath}</path>
|
|
959
|
+
<type>file</type>
|
|
960
|
+
<content>
|
|
961
|
+
${outcome.operation === "create" ? "Created" : "Updated"} file${hint === void 0 ? "" : `\n(hint: ${hint})`}
|
|
962
|
+
</content>`;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Register the `write` tool and its scope-aware system-prompt guidance.
|
|
966
|
+
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
|
967
|
+
* @param sandbox - the shared sandbox-escalation API (advertisement, mode stamping, denial mapping).
|
|
968
|
+
* @param hints - the drifted-path hint tuning; disabled keeps creates silent.
|
|
969
|
+
*/
|
|
970
|
+
function applyWriteTool(ctx, sandbox, hints) {
|
|
971
|
+
ctx.systemPrompt.section({
|
|
972
|
+
name: "tool:write",
|
|
973
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_WRITE"),
|
|
974
|
+
text: ({ scope }) => ctx.tools.get("write", scope) === void 0 ? "" : "Read an existing file before overwriting it with write (the default fs-observation-policy requires it)" + (ctx.tools.get("edit", scope) === void 0 ? "" : " and prefer edit for targeted changes") + "."
|
|
975
|
+
});
|
|
976
|
+
ctx.tools.register(defineTool({
|
|
977
|
+
name: "write",
|
|
978
|
+
description: "Create or fully replace a UTF-8 text file.",
|
|
979
|
+
parameters: {
|
|
980
|
+
file_path: {
|
|
981
|
+
type: "string",
|
|
982
|
+
required: true,
|
|
983
|
+
description: "Path to write, resolved by the filesystem backend."
|
|
984
|
+
},
|
|
985
|
+
content: {
|
|
986
|
+
type: "string",
|
|
987
|
+
required: true,
|
|
988
|
+
description: "Full UTF-8 text content to write."
|
|
989
|
+
},
|
|
990
|
+
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
|
|
991
|
+
},
|
|
992
|
+
output: {
|
|
993
|
+
schema: {
|
|
994
|
+
type: "object",
|
|
995
|
+
additionalProperties: false,
|
|
996
|
+
properties: {
|
|
997
|
+
path: {
|
|
998
|
+
type: "string",
|
|
999
|
+
required: true
|
|
1000
|
+
},
|
|
1001
|
+
operation: {
|
|
1002
|
+
type: "string",
|
|
1003
|
+
required: true,
|
|
1004
|
+
enum: ["create", "update"]
|
|
1005
|
+
},
|
|
1006
|
+
before: {
|
|
1007
|
+
required: true,
|
|
1008
|
+
oneOf: [{ type: "string" }, { type: "null" }]
|
|
1009
|
+
},
|
|
1010
|
+
after: {
|
|
1011
|
+
type: "string",
|
|
1012
|
+
required: true
|
|
1013
|
+
},
|
|
1014
|
+
note: { type: "string" }
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
render: (_args, value) => [{
|
|
1018
|
+
type: "text",
|
|
1019
|
+
text: formatWriteOutput(value.path, value, value.note)
|
|
1020
|
+
}],
|
|
1021
|
+
presentationMeta: (args, value) => ({
|
|
1022
|
+
operation: value.operation,
|
|
1023
|
+
diffs: value.before === null ? [] : computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
|
|
1024
|
+
path,
|
|
1025
|
+
oldText,
|
|
1026
|
+
newText
|
|
1027
|
+
}))
|
|
1028
|
+
})
|
|
1029
|
+
},
|
|
1030
|
+
async execute(args, exec) {
|
|
1031
|
+
const input = parseWriteArgs(args);
|
|
1032
|
+
const sandboxPolicy = await sandbox.resolvePolicy("write", args, exec);
|
|
1033
|
+
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1034
|
+
const intent = await ctx.waterfall("fs/write-intent", target, exec, () => void 0);
|
|
1035
|
+
let outcome;
|
|
1036
|
+
try {
|
|
1037
|
+
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath);
|
|
1040
|
+
}
|
|
1041
|
+
ctx.emit("fs/observed", target, {
|
|
1042
|
+
kind: "present",
|
|
1043
|
+
version: outcome.version
|
|
1044
|
+
}, exec);
|
|
1045
|
+
let note;
|
|
1046
|
+
if (hints.pathHint && outcome.operation === "create") {
|
|
1047
|
+
const similar = await repairTargetPath(ctx, exec, input.filePath, hints.priorPaths(exec.agent?.session));
|
|
1048
|
+
if (similar !== void 0) note = `created a new file; similar existing "${similar.path}" — write to that path if you meant to update it`;
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
path: target.displayPath,
|
|
1052
|
+
operation: outcome.operation,
|
|
1053
|
+
before: outcome.before,
|
|
1054
|
+
after: outcome.after,
|
|
1055
|
+
...note === void 0 ? {} : { note }
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
presentCall(args) {
|
|
1059
|
+
return {
|
|
1060
|
+
card: "diff",
|
|
1061
|
+
title: `Write ${args.file_path}`,
|
|
1062
|
+
diffs: [{
|
|
1063
|
+
path: args.file_path,
|
|
1064
|
+
oldText: null,
|
|
1065
|
+
newText: args.content
|
|
1066
|
+
}],
|
|
1067
|
+
locations: [{ path: args.file_path }]
|
|
1068
|
+
};
|
|
1069
|
+
},
|
|
1070
|
+
presentResult(args, result) {
|
|
1071
|
+
if (result.isError) return void 0;
|
|
1072
|
+
const diffs = diffsFromMeta(result.meta) ?? [{
|
|
1073
|
+
path: args.file_path,
|
|
1074
|
+
oldText: null,
|
|
1075
|
+
newText: args.content
|
|
1076
|
+
}];
|
|
1077
|
+
return {
|
|
1078
|
+
card: "diff",
|
|
1079
|
+
title: `Write ${args.file_path}`,
|
|
1080
|
+
diffs
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
}));
|
|
1084
|
+
}
|
|
1085
|
+
//#endregion
|
|
1086
|
+
//#region lib/types/edit.js
|
|
1087
|
+
/**
|
|
1088
|
+
* Model-facing literal edit, unique-match by default. It obtains an optional guard from the
|
|
1089
|
+
* single intent slot, calls `ctx.fs.editText` without a separate stat, then records the observed
|
|
1090
|
+
* version; no policy means an unconditional atomic edit. When the literal match fails or is
|
|
1091
|
+
* ambiguous, the `@deepseek-ai/dsh-fs-edit-repair` engine re-anchors the pair before the error
|
|
1092
|
+
* reaches the model.
|
|
1093
|
+
* @module @deepseek-ai/dsh-tool-fs/src/edit
|
|
1094
|
+
*/
|
|
1095
|
+
/**
|
|
1096
|
+
* Error codes that can mean "the target does not exist": the policy's unread
|
|
1097
|
+
* refusal, the provider's stale/missing guards on a guarded edit, and the
|
|
1098
|
+
* no-match failure FakeFs-style providers raise for absent content. Each is
|
|
1099
|
+
* only treated as a drifted path after a stat confirms the target's absence —
|
|
1100
|
+
* a present target means the code carries its own ordinary meaning (an unread
|
|
1101
|
+
* existing file, real staleness, a failed literal match).
|
|
1102
|
+
*/
|
|
1103
|
+
const ABSENT_TARGET_CODES = new Set([
|
|
1104
|
+
"FS_NOT_FOUND",
|
|
1105
|
+
"FS_STALE_VERSION",
|
|
1106
|
+
"FS_EDIT_NOT_FOUND",
|
|
1107
|
+
"FS_NOT_OBSERVED"
|
|
1108
|
+
]);
|
|
1109
|
+
/**
|
|
1110
|
+
* Validate value constraints the schema DSL can't express: a non-blank
|
|
1111
|
+
* `file_path`, a non-empty `old_string`, and `old_string !== new_string`
|
|
1112
|
+
* (an equal pair would be a guaranteed no-op edit).
|
|
1113
|
+
* @param args - the schema-validated raw tool arguments.
|
|
1114
|
+
* @returns the camelCased input with `replace_all` defaulted to false.
|
|
1115
|
+
*/
|
|
1116
|
+
function parseEditArgs(args) {
|
|
1117
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
1118
|
+
if (args.old_string.length === 0) throw new Error("old_string must be a non-empty string");
|
|
1119
|
+
if (args.old_string === args.new_string) throw new Error("old_string and new_string must differ");
|
|
1120
|
+
return {
|
|
1121
|
+
filePath: args.file_path,
|
|
1122
|
+
oldString: args.old_string,
|
|
1123
|
+
newString: args.new_string,
|
|
1124
|
+
replaceAll: args.replace_all ?? false
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Format an edit success (single-match or replace-all) as a Claude-style model-facing message.
|
|
1129
|
+
* @param displayPath - the backend-resolved path shown to the model.
|
|
1130
|
+
* @param replaceAll - selects the all-occurrences wording over the single-replacement one.
|
|
1131
|
+
* @returns the confirmation sentence the model sees as the tool result.
|
|
1132
|
+
*/
|
|
1133
|
+
function formatEditOutput(displayPath, replaceAll) {
|
|
1134
|
+
return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.`;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Repair a failed or ambiguous literal edit through the fs-edit-repair engine.
|
|
1138
|
+
* A repaired pair is applied through the same guarded `editText` seam with the
|
|
1139
|
+
* original intent, so version guards and sandbox policy stay in force; the
|
|
1140
|
+
* outcome carries the repair note for the model. Without a safe patch the
|
|
1141
|
+
* remediated error is re-thrown, enriched with the engine's near-match hints.
|
|
1142
|
+
* @param ctx - the plugin context, used to re-read the target for repair.
|
|
1143
|
+
* @param sandbox - the shared sandbox-escalation API, used for denial mapping.
|
|
1144
|
+
* @param repair - the resolved repair tuning.
|
|
1145
|
+
* @param target - the resolved edit target.
|
|
1146
|
+
* @param input - the validated edit arguments.
|
|
1147
|
+
* @param intent - the intent resolved by the `fs/edit-intent` waterfall.
|
|
1148
|
+
* @param exec - the tool execution context.
|
|
1149
|
+
* @param sandboxPolicy - the per-call sandbox policy.
|
|
1150
|
+
* @param error - the failure thrown by the original `editText` call.
|
|
1151
|
+
* @returns the outcome of the repaired edit plus its model-facing note.
|
|
1152
|
+
*/
|
|
1153
|
+
async function repairEditFailure(ctx, sandbox, repair, target, input, intent, exec, sandboxPolicy, error) {
|
|
1154
|
+
const mapped = sandbox.mapError(error, sandboxPolicy);
|
|
1155
|
+
if (!repair.enabled || input.replaceAll || !(mapped instanceof FsError) || mapped.code !== "FS_EDIT_NOT_FOUND" && mapped.code !== "FS_AMBIGUOUS_EDIT") throw remediateFsError(mapped, target.displayPath);
|
|
1156
|
+
let content;
|
|
1157
|
+
try {
|
|
1158
|
+
content = await ctx.fs.readText(target, exec.signal);
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw remediateFsError(mapped, target.displayPath);
|
|
1161
|
+
}
|
|
1162
|
+
const attempt = repairStrReplace({
|
|
1163
|
+
content,
|
|
1164
|
+
oldString: input.oldString,
|
|
1165
|
+
newString: input.newString
|
|
1166
|
+
}, repair);
|
|
1167
|
+
const patch = attempt.patch;
|
|
1168
|
+
if (patch !== void 0) return {
|
|
1169
|
+
outcome: await ctx.fs.editText(target, {
|
|
1170
|
+
oldString: patch.oldString,
|
|
1171
|
+
newString: patch.newString,
|
|
1172
|
+
replaceAll: false
|
|
1173
|
+
}, intent, exec.signal, sandboxPolicy),
|
|
1174
|
+
note: patch.note
|
|
1175
|
+
};
|
|
1176
|
+
const hints = attempt.diagnosis.hints;
|
|
1177
|
+
const base = remediateFsError(mapped, target.displayPath);
|
|
1178
|
+
if (hints.length === 0 || !(base instanceof FsError)) throw base;
|
|
1179
|
+
throw new FsError(`${base.message} ${hints.join(" ")}`, base.code, { cause: base });
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Register the `edit` tool and its scope-aware system-prompt guidance.
|
|
1183
|
+
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
|
1184
|
+
* @param sandbox - the shared sandbox-escalation API (advertisement, mode stamping, denial mapping).
|
|
1185
|
+
* @param repair - the resolved tuning for the failed-match repair engine and missing-path re-anchoring.
|
|
1186
|
+
*/
|
|
1187
|
+
function applyEditTool(ctx, sandbox, repair) {
|
|
1188
|
+
ctx.systemPrompt.section({
|
|
1189
|
+
name: "tool:edit",
|
|
1190
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_EDIT"),
|
|
1191
|
+
text: ({ scope }) => ctx.tools.get("edit", scope) === void 0 ? "" : "Read a file before editing it (the default fs-observation-policy requires it), unless you just created or edited it in this session."
|
|
1192
|
+
});
|
|
1193
|
+
ctx.tools.register(defineTool({
|
|
1194
|
+
name: "edit",
|
|
1195
|
+
description: "Edit an existing UTF-8 text file by replacing literal text.",
|
|
1196
|
+
parameters: {
|
|
1197
|
+
file_path: {
|
|
1198
|
+
type: "string",
|
|
1199
|
+
required: true,
|
|
1200
|
+
description: "Path to edit, resolved by the filesystem backend."
|
|
1201
|
+
},
|
|
1202
|
+
old_string: {
|
|
1203
|
+
type: "string",
|
|
1204
|
+
required: true,
|
|
1205
|
+
description: "Literal text to replace."
|
|
1206
|
+
},
|
|
1207
|
+
new_string: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
required: true,
|
|
1210
|
+
description: "Literal replacement text. Use an empty string to delete the match."
|
|
1211
|
+
},
|
|
1212
|
+
replace_all: {
|
|
1213
|
+
type: "boolean",
|
|
1214
|
+
description: "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
|
|
1215
|
+
},
|
|
1216
|
+
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
|
|
1217
|
+
},
|
|
1218
|
+
output: {
|
|
1219
|
+
schema: {
|
|
1220
|
+
type: "object",
|
|
1221
|
+
additionalProperties: false,
|
|
1222
|
+
properties: {
|
|
1223
|
+
path: {
|
|
1224
|
+
type: "string",
|
|
1225
|
+
required: true
|
|
1226
|
+
},
|
|
1227
|
+
before: {
|
|
1228
|
+
type: "string",
|
|
1229
|
+
required: true
|
|
1230
|
+
},
|
|
1231
|
+
after: {
|
|
1232
|
+
type: "string",
|
|
1233
|
+
required: true
|
|
1234
|
+
},
|
|
1235
|
+
note: { type: "string" }
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
render: (args, value) => [{
|
|
1239
|
+
type: "text",
|
|
1240
|
+
text: `${formatEditOutput(value.path, args.replace_all ?? false)}${value.note === void 0 ? "" : ` (repair: ${value.note})`}`
|
|
1241
|
+
}],
|
|
1242
|
+
presentationMeta: (args, value) => ({ diffs: computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
|
|
1243
|
+
path,
|
|
1244
|
+
oldText,
|
|
1245
|
+
newText
|
|
1246
|
+
})) })
|
|
1247
|
+
},
|
|
1248
|
+
async execute(args, exec) {
|
|
1249
|
+
const input = parseEditArgs(args);
|
|
1250
|
+
const sandboxPolicy = await sandbox.resolvePolicy("edit", args, exec);
|
|
1251
|
+
let target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1252
|
+
let outcome;
|
|
1253
|
+
let note;
|
|
1254
|
+
let intent;
|
|
1255
|
+
const applyAt = async (editTarget) => {
|
|
1256
|
+
const slot = await ctx.waterfall("fs/edit-intent", editTarget, exec, () => void 0);
|
|
1257
|
+
intent = slot;
|
|
1258
|
+
return await ctx.fs.editText(editTarget, {
|
|
1259
|
+
oldString: input.oldString,
|
|
1260
|
+
newString: input.newString,
|
|
1261
|
+
replaceAll: input.replaceAll
|
|
1262
|
+
}, slot, exec.signal, sandboxPolicy);
|
|
1263
|
+
};
|
|
1264
|
+
try {
|
|
1265
|
+
outcome = await applyAt(target);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
const mapped = sandbox.mapError(error, sandboxPolicy);
|
|
1268
|
+
if (repair.enabled && mapped instanceof FsError && ABSENT_TARGET_CODES.has(mapped.code) && await ctx.fs.stat(target, exec.signal) === void 0) {
|
|
1269
|
+
const repaired = await repairTargetPath(ctx, exec, input.filePath, repair.priorPaths(exec.agent?.session));
|
|
1270
|
+
if (repaired === void 0) throw remediateFsError(mapped, target.displayPath);
|
|
1271
|
+
target = await ctx.fs.resolve(repaired.path, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1272
|
+
note = repaired.note;
|
|
1273
|
+
try {
|
|
1274
|
+
outcome = await applyAt(target);
|
|
1275
|
+
} catch (retryError) {
|
|
1276
|
+
throw remediateFsError(sandbox.mapError(retryError, sandboxPolicy), target.displayPath);
|
|
1277
|
+
}
|
|
1278
|
+
} else {
|
|
1279
|
+
const attempt = await repairEditFailure(ctx, sandbox, repair, target, input, intent, exec, sandboxPolicy, error);
|
|
1280
|
+
outcome = attempt.outcome;
|
|
1281
|
+
note = attempt.note;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
ctx.emit("fs/observed", target, {
|
|
1285
|
+
kind: "present",
|
|
1286
|
+
version: outcome.version
|
|
1287
|
+
}, exec);
|
|
1288
|
+
return {
|
|
1289
|
+
path: target.displayPath,
|
|
1290
|
+
before: outcome.before,
|
|
1291
|
+
after: outcome.after,
|
|
1292
|
+
...note === void 0 ? {} : { note }
|
|
1293
|
+
};
|
|
1294
|
+
},
|
|
1295
|
+
presentCall(args) {
|
|
1296
|
+
return {
|
|
1297
|
+
card: "diff",
|
|
1298
|
+
title: `Edit ${args.file_path}`,
|
|
1299
|
+
diffs: [{
|
|
1300
|
+
path: args.file_path,
|
|
1301
|
+
oldText: args.old_string || null,
|
|
1302
|
+
newText: args.new_string
|
|
1303
|
+
}],
|
|
1304
|
+
locations: [{ path: args.file_path }]
|
|
1305
|
+
};
|
|
1306
|
+
},
|
|
1307
|
+
presentResult(args, result) {
|
|
1308
|
+
if (result.isError) return void 0;
|
|
1309
|
+
const diffs = diffsFromMeta(result.meta);
|
|
1310
|
+
if (diffs === void 0) return void 0;
|
|
1311
|
+
return {
|
|
1312
|
+
card: "diff",
|
|
1313
|
+
title: `Edit ${args.file_path}`,
|
|
1314
|
+
diffs
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}));
|
|
1318
|
+
}
|
|
1319
|
+
//#endregion
|
|
1320
|
+
//#region lib/types/read-image.js
|
|
1321
|
+
/**
|
|
1322
|
+
* The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. A path
|
|
1323
|
+
* without a file extension is identified from its file signature, while the
|
|
1324
|
+
* attachment service's full decode stays authoritative. The mounted `ctx.fs`
|
|
1325
|
+
* backend owns path resolution and read access; names only declare media type.
|
|
1326
|
+
*
|
|
1327
|
+
* The route gate is deliberately stricter than the host upload preflight. An
|
|
1328
|
+
* image-reading tool is useful only when the exact calling route can inspect
|
|
1329
|
+
* its result, so unknown capability refuses instead of relying on an adapter
|
|
1330
|
+
* failure after filesystem and attachment work.
|
|
1331
|
+
* @module @deepseek-ai/dsh-tool-fs/src/read-image
|
|
1332
|
+
*/
|
|
1333
|
+
/** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */
|
|
1334
|
+
const IMAGE_EXTENSIONS = {
|
|
1335
|
+
".png": "image/png",
|
|
1336
|
+
".jpg": "image/jpeg",
|
|
1337
|
+
".jpeg": "image/jpeg",
|
|
1338
|
+
".webp": "image/webp",
|
|
1339
|
+
".gif": "image/gif"
|
|
1340
|
+
};
|
|
1341
|
+
const PNG_SIGNATURE = [
|
|
1342
|
+
137,
|
|
1343
|
+
80,
|
|
1344
|
+
78,
|
|
1345
|
+
71,
|
|
1346
|
+
13,
|
|
1347
|
+
10,
|
|
1348
|
+
26,
|
|
1349
|
+
10
|
|
1350
|
+
];
|
|
1351
|
+
const JPEG_SIGNATURE = [
|
|
1352
|
+
255,
|
|
1353
|
+
216,
|
|
1354
|
+
255
|
|
1355
|
+
];
|
|
1356
|
+
function matchesBytes(data, offset, expected) {
|
|
1357
|
+
if (data.byteLength < offset + expected.length) return false;
|
|
1358
|
+
return expected.every((byte, index) => data[offset + index] === byte);
|
|
1359
|
+
}
|
|
1360
|
+
function matchesAscii(data, offset, value) {
|
|
1361
|
+
if (data.byteLength < offset + value.length) return false;
|
|
1362
|
+
for (let index = 0; index < value.length; index += 1) if (data[offset + index] !== value.charCodeAt(index)) return false;
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Identify the media type declared by a supported image file signature.
|
|
1367
|
+
* @param data - file bytes read through the current filesystem backend.
|
|
1368
|
+
* @returns the detected supported media type, or undefined for other bytes.
|
|
1369
|
+
*/
|
|
1370
|
+
function sniffImageMediaType(data) {
|
|
1371
|
+
if (matchesBytes(data, 0, PNG_SIGNATURE)) return "image/png";
|
|
1372
|
+
if (matchesBytes(data, 0, JPEG_SIGNATURE)) return "image/jpeg";
|
|
1373
|
+
if (matchesAscii(data, 0, "GIF87a") || matchesAscii(data, 0, "GIF89a")) return "image/gif";
|
|
1374
|
+
if (matchesAscii(data, 0, "RIFF") && matchesAscii(data, 8, "WEBP")) return "image/webp";
|
|
1375
|
+
}
|
|
1376
|
+
const IMAGE_VALUE_SCHEMA = {
|
|
1377
|
+
type: "object",
|
|
1378
|
+
additionalProperties: false,
|
|
1379
|
+
required: true,
|
|
1380
|
+
properties: {
|
|
1381
|
+
attachmentId: {
|
|
1382
|
+
type: "string",
|
|
1383
|
+
required: true
|
|
1384
|
+
},
|
|
1385
|
+
mediaType: {
|
|
1386
|
+
type: "string",
|
|
1387
|
+
enum: [
|
|
1388
|
+
"image/png",
|
|
1389
|
+
"image/jpeg",
|
|
1390
|
+
"image/webp",
|
|
1391
|
+
"image/gif"
|
|
1392
|
+
],
|
|
1393
|
+
required: true
|
|
1394
|
+
},
|
|
1395
|
+
bytes: {
|
|
1396
|
+
type: "integer",
|
|
1397
|
+
required: true
|
|
1398
|
+
},
|
|
1399
|
+
width: {
|
|
1400
|
+
type: "integer",
|
|
1401
|
+
required: true
|
|
1402
|
+
},
|
|
1403
|
+
height: {
|
|
1404
|
+
type: "integer",
|
|
1405
|
+
required: true
|
|
1406
|
+
},
|
|
1407
|
+
name: { type: "string" },
|
|
1408
|
+
originalDimensions: {
|
|
1409
|
+
type: "object",
|
|
1410
|
+
additionalProperties: false,
|
|
1411
|
+
properties: {
|
|
1412
|
+
width: {
|
|
1413
|
+
type: "integer",
|
|
1414
|
+
required: true
|
|
1415
|
+
},
|
|
1416
|
+
height: {
|
|
1417
|
+
type: "integer",
|
|
1418
|
+
required: true
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
/**
|
|
1425
|
+
* Map a model-supplied path to its declared image media type by extension.
|
|
1426
|
+
* @param filePath - the raw `file_path` argument (not yet resolved).
|
|
1427
|
+
* @returns the declared media type, or undefined when the path does not claim an image.
|
|
1428
|
+
*/
|
|
1429
|
+
function imageMediaTypeForPath(filePath) {
|
|
1430
|
+
return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()];
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Enforce the strict image-capability gate for the calling route. Resolves the
|
|
1434
|
+
* session's latest routed provider/model (request header config, then agent
|
|
1435
|
+
* options) and requires the exact resolved route to declare `image` input explicitly.
|
|
1436
|
+
* @param ctx - the plugin context used to resolve the optional `llm` service.
|
|
1437
|
+
* @param exec - the tool-execution context supplying the calling agent.
|
|
1438
|
+
* @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages.
|
|
1439
|
+
*/
|
|
1440
|
+
async function assertImageCapableRoute(ctx, exec, requestedPath) {
|
|
1441
|
+
const routed = exec.agent?.session.requestHeader()?.config;
|
|
1442
|
+
const provider = routed?.provider ?? exec.agent?.options.provider;
|
|
1443
|
+
const model = routed?.model ?? exec.agent?.options.model;
|
|
1444
|
+
const llm = ctx.get("llm");
|
|
1445
|
+
if (provider === void 0 || model === void 0 || llm === void 0) throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`);
|
|
1446
|
+
const active = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
1447
|
+
if (active.inputModalities === void 0 || !active.inputModalities.includes("image")) throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`);
|
|
1448
|
+
}
|
|
1449
|
+
/** Refuse a media type outside the deployment's accepted set, naming the offending path. */
|
|
1450
|
+
function assertDeploymentAccepts(attachments, mediaType, displayPath) {
|
|
1451
|
+
if (!attachments.imageLimits.mediaTypes.includes(mediaType)) throw new Error(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`);
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Re-brand a structured image outcome into the durable attachment reference an
|
|
1455
|
+
* `ImageBlock` carries.
|
|
1456
|
+
* @param image - the image metadata from the output schema.
|
|
1457
|
+
* @returns the branded attachment reference.
|
|
1458
|
+
*/
|
|
1459
|
+
function imageRefFromValue(image) {
|
|
1460
|
+
return {
|
|
1461
|
+
attachmentId: AttachmentId(image.attachmentId),
|
|
1462
|
+
mediaType: image.mediaType,
|
|
1463
|
+
bytes: image.bytes,
|
|
1464
|
+
width: image.width,
|
|
1465
|
+
height: image.height,
|
|
1466
|
+
...image.name === void 0 ? {} : { name: image.name },
|
|
1467
|
+
...image.originalDimensions === void 0 ? {} : { originalDimensions: { ...image.originalDimensions } }
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Format an image read as the model-facing envelope beside its image block.
|
|
1472
|
+
* A downscaled read names the on-disk dimensions and the multiplier that maps
|
|
1473
|
+
* coordinates measured on the attached image back onto the original file.
|
|
1474
|
+
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
|
|
1475
|
+
* @param image - the image metadata to summarize.
|
|
1476
|
+
* @returns the model-facing envelope; the image itself rides the adjacent image block.
|
|
1477
|
+
*/
|
|
1478
|
+
function formatImageReadOutput(displayPath, image) {
|
|
1479
|
+
let scaled = "";
|
|
1480
|
+
if (image.originalDimensions !== void 0) {
|
|
1481
|
+
const x = (image.originalDimensions.width / image.width).toFixed(2);
|
|
1482
|
+
const y = (image.originalDimensions.height / image.height).toFixed(2);
|
|
1483
|
+
const advice = x === y ? `multiply coordinates by ${x}` : `multiply x coordinates by ${x} and y coordinates by ${y}`;
|
|
1484
|
+
scaled = ` (downscaled from ${image.originalDimensions.width}x${image.originalDimensions.height} px; ${advice} to locate features in the original file)`;
|
|
1485
|
+
}
|
|
1486
|
+
return `<path>${displayPath}</path>
|
|
1487
|
+
<type>image</type>
|
|
1488
|
+
<content>
|
|
1489
|
+
${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes${scaled}
|
|
1490
|
+
</content>`;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Project one structured image read into its model-facing envelope and image.
|
|
1494
|
+
* @param value - the image-read outcome.
|
|
1495
|
+
* @returns the two content blocks used by native and nested dispatches.
|
|
1496
|
+
*/
|
|
1497
|
+
function imageReadContent(value) {
|
|
1498
|
+
return [{
|
|
1499
|
+
type: "text",
|
|
1500
|
+
text: formatImageReadOutput(value.path, value.image)
|
|
1501
|
+
}, {
|
|
1502
|
+
type: "image",
|
|
1503
|
+
attachment: imageRefFromValue(value.image)
|
|
1504
|
+
}];
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Register the `read_image` tool into the given context. The composing plugin
|
|
1508
|
+
* owns the attachments gate: `src/index.ts` calls this inside
|
|
1509
|
+
* `ctx.inject(['attachments'], …)` so the tool exists only while a durable
|
|
1510
|
+
* store is mounted. Execution still re-checks `ctx.get('attachments')` for
|
|
1511
|
+
* direct callers and gates on the calling route's declared image input.
|
|
1512
|
+
* @param ctx - the registration scope; execution uses its `fs` service plus
|
|
1513
|
+
* the optional `attachments`/`llm` services.
|
|
1514
|
+
*/
|
|
1515
|
+
function applyReadImageTool(ctx) {
|
|
1516
|
+
ctx.tools.register(defineTool({
|
|
1517
|
+
name: "read_image",
|
|
1518
|
+
description: "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.",
|
|
1519
|
+
parameters: { file_path: {
|
|
1520
|
+
type: "string",
|
|
1521
|
+
required: true,
|
|
1522
|
+
description: "Path to the image file, resolved by the filesystem backend."
|
|
1523
|
+
} },
|
|
1524
|
+
output: {
|
|
1525
|
+
schema: {
|
|
1526
|
+
type: "object",
|
|
1527
|
+
additionalProperties: false,
|
|
1528
|
+
properties: {
|
|
1529
|
+
path: {
|
|
1530
|
+
type: "string",
|
|
1531
|
+
required: true
|
|
1532
|
+
},
|
|
1533
|
+
image: IMAGE_VALUE_SCHEMA
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
render: (_args, value) => imageReadContent(value),
|
|
1537
|
+
presentationMeta: (_args, value) => ({ path: value.path })
|
|
1538
|
+
},
|
|
1539
|
+
isConcurrencySafe: () => true,
|
|
1540
|
+
async execute(args, exec) {
|
|
1541
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
1542
|
+
const extension = extname(args.file_path).toLowerCase();
|
|
1543
|
+
const declared = imageMediaTypeForPath(args.file_path);
|
|
1544
|
+
if (declared === void 0 && extension !== "") throw new Error(`cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`);
|
|
1545
|
+
const attachments = ctx.get("attachments");
|
|
1546
|
+
if (attachments === void 0) throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`);
|
|
1547
|
+
if (declared !== void 0) assertDeploymentAccepts(attachments, declared, args.file_path);
|
|
1548
|
+
await assertImageCapableRoute(ctx, exec, args.file_path);
|
|
1549
|
+
const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path);
|
|
1550
|
+
const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes);
|
|
1551
|
+
const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
|
|
1552
|
+
const mediaType = declared ?? sniffImageMediaType(data);
|
|
1553
|
+
if (mediaType === void 0) throw new Error(`cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`);
|
|
1554
|
+
if (declared === void 0) assertDeploymentAccepts(attachments, mediaType, target.displayPath);
|
|
1555
|
+
let ref;
|
|
1556
|
+
try {
|
|
1557
|
+
ref = await attachments.saveImage({
|
|
1558
|
+
data,
|
|
1559
|
+
mediaType,
|
|
1560
|
+
name: basename(target.displayPath)
|
|
1561
|
+
});
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (!(error instanceof AttachmentError)) throw error;
|
|
1564
|
+
if (error.code === "IMAGE_DIMENSION_TOO_LARGE") throw new Error(`cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, { cause: error });
|
|
1565
|
+
if (error.code === "IMAGE_TOO_MANY_PIXELS") throw new Error(`cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, { cause: error });
|
|
1566
|
+
if (error.code === "IMAGE_TOO_LARGE") throw new Error(`cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`, { cause: error });
|
|
1567
|
+
if (error.code === "ATTACHMENT_WRITE_FAILED" && /16-bit PNG/iu.test(error.message)) throw new Error(`cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, { cause: error });
|
|
1568
|
+
if (error.code === "INVALID_IMAGE" && declared === void 0) throw new Error(`cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, { cause: error });
|
|
1569
|
+
if (error.code !== "IMAGE_TYPE_MISMATCH") throw error;
|
|
1570
|
+
if (declared === void 0) throw new Error(`cannot read "${target.displayPath}": the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, { cause: error });
|
|
1571
|
+
throw new Error(`cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, { cause: error });
|
|
1572
|
+
}
|
|
1573
|
+
ctx.emit("fs/observed", target, {
|
|
1574
|
+
kind: "present",
|
|
1575
|
+
version: info.version
|
|
1576
|
+
}, exec);
|
|
1577
|
+
return {
|
|
1578
|
+
path: target.displayPath,
|
|
1579
|
+
image: {
|
|
1580
|
+
attachmentId: ref.attachmentId,
|
|
1581
|
+
mediaType: ref.mediaType,
|
|
1582
|
+
bytes: ref.bytes,
|
|
1583
|
+
width: ref.width,
|
|
1584
|
+
height: ref.height,
|
|
1585
|
+
...ref.name === void 0 ? {} : { name: ref.name },
|
|
1586
|
+
...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
},
|
|
1590
|
+
presentCall(args) {
|
|
1591
|
+
return {
|
|
1592
|
+
card: "generic",
|
|
1593
|
+
title: `Read image ${args.file_path}`,
|
|
1594
|
+
kind: "read",
|
|
1595
|
+
locations: [{ path: args.file_path }]
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
//#endregion
|
|
1601
|
+
//#region lib/types/sandbox.js
|
|
1602
|
+
/**
|
|
1603
|
+
* The sandbox-escalation API shared by the `write` and `edit` tools: the
|
|
1604
|
+
* per-call policy resolution, the advertised escalation fields, and the denial-marker
|
|
1605
|
+
* mapping — all delegating the vocabulary and the fail-closed approval
|
|
1606
|
+
* sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
|
|
1607
|
+
* uses), so bash and fs escalate identically. Built ONCE per plugin from
|
|
1608
|
+
* `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
|
|
1609
|
+
* and shared by both mutating tools.
|
|
1610
|
+
*
|
|
1611
|
+
* @module @deepseek-ai/dsh-tool-fs/sandbox
|
|
1612
|
+
*/
|
|
1613
|
+
/**
|
|
1614
|
+
* The filesystem escalation API: advertisement gating, per-call policy
|
|
1615
|
+
* resolution, the one-approved wider retry, and denial-marker mapping. A pure
|
|
1616
|
+
* product of `ctx` at plugin apply time.
|
|
1617
|
+
*/
|
|
1618
|
+
var FsSandboxController = class {
|
|
1619
|
+
ctx;
|
|
1620
|
+
/** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
|
|
1621
|
+
escalationModes;
|
|
1622
|
+
/** Shared per-session policy resolver, required by a confining backend. */
|
|
1623
|
+
policy;
|
|
1624
|
+
constructor(ctx) {
|
|
1625
|
+
this.ctx = ctx;
|
|
1626
|
+
const defaultMode = ctx.fs.sandboxMode;
|
|
1627
|
+
this.escalationModes = defaultMode === void 0 ? [] : ESCALATION_TARGETS;
|
|
1628
|
+
this.policy = defaultMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
|
|
1629
|
+
if (defaultMode !== void 0 && this.policy === void 0) throw new Error("tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing");
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* The escalation schema fields for a mutating tool's `parameters`. Call it
|
|
1633
|
+
* only under a confining backend (guard on {@link escalationModes}); the
|
|
1634
|
+
* enum pins the closed target vocabulary, the strict-wider check happens per
|
|
1635
|
+
* call at execution.
|
|
1636
|
+
* @returns the two escalation parameter specs.
|
|
1637
|
+
*/
|
|
1638
|
+
schemaFields() {
|
|
1639
|
+
return {
|
|
1640
|
+
sandbox_permissions: {
|
|
1641
|
+
type: "string",
|
|
1642
|
+
enum: [...this.escalationModes],
|
|
1643
|
+
description: sandboxPermissionsDescription("operation")
|
|
1644
|
+
},
|
|
1645
|
+
justification: {
|
|
1646
|
+
type: "string",
|
|
1647
|
+
description: "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request."
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The policy to stamp onto this mutation: an approved escalation grant (a
|
|
1653
|
+
* strictly wider retry resolved through `ctx.approval` before anything
|
|
1654
|
+
* executes), else the session's standing mode. Repeating the standing mode
|
|
1655
|
+
* requires no approval. The calling session's cwd is
|
|
1656
|
+
* always carried as the workspace root. Validates the escalation argument
|
|
1657
|
+
* pairing first.
|
|
1658
|
+
* @param toolName - the mutating tool's name, for the approval audit trail.
|
|
1659
|
+
* @param args - the call's escalation arguments.
|
|
1660
|
+
* @param exec - the tool-execution context (agent, callId, signal).
|
|
1661
|
+
* @returns the policy to pass to the mutation, or undefined for an
|
|
1662
|
+
* unsandboxed backend.
|
|
1663
|
+
*/
|
|
1664
|
+
async resolvePolicy(toolName, args, exec) {
|
|
1665
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification);
|
|
1666
|
+
const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} });
|
|
1667
|
+
if (args.sandbox_permissions === void 0 || args.justification === void 0) return standingPolicy;
|
|
1668
|
+
if (this.escalationModes.length === 0) throw new Error("sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)");
|
|
1669
|
+
const policy = standingPolicy;
|
|
1670
|
+
const approvedMode = await approveEscalation({
|
|
1671
|
+
requestedMode: args.sandbox_permissions,
|
|
1672
|
+
justification: args.justification,
|
|
1673
|
+
effectiveMode: policy.mode,
|
|
1674
|
+
subject: "operation"
|
|
1675
|
+
}, {
|
|
1676
|
+
approver: this.ctx.get("approval"),
|
|
1677
|
+
agent: exec.agent,
|
|
1678
|
+
callId: exec.callId,
|
|
1679
|
+
toolName,
|
|
1680
|
+
signal: exec.signal
|
|
1681
|
+
});
|
|
1682
|
+
return {
|
|
1683
|
+
...policy,
|
|
1684
|
+
mode: approvedMode
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a
|
|
1689
|
+
* `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
|
|
1690
|
+
* same-turn escalation hint, so a policy denial reads identically to bash's
|
|
1691
|
+
* WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRuntime`
|
|
1692
|
+
* populates `result.error` only for `HarnessError` instances, so a plain
|
|
1693
|
+
* `Error` would strip the code retry/observers key off. Any other error
|
|
1694
|
+
* passes through unchanged. A `FS_SANDBOX_DENIED` only arises under a
|
|
1695
|
+
* confining backend, which always advertises the escalation fields, so the
|
|
1696
|
+
* hint always applies here.
|
|
1697
|
+
* @param error - the error thrown by the mutation.
|
|
1698
|
+
* @param policy - the policy stamped onto the call (names the mode in the marker).
|
|
1699
|
+
* @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
|
|
1700
|
+
*/
|
|
1701
|
+
mapError(error, policy) {
|
|
1702
|
+
if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
|
|
1703
|
+
const mode = policy.mode;
|
|
1704
|
+
return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker("operation")}`, "FS_SANDBOX_DENIED", { cause: error });
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
//#endregion
|
|
1708
|
+
//#region lib/types/index.js
|
|
1709
|
+
/**
|
|
1710
|
+
* Model-facing read, read_image, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
|
|
1711
|
+
* read windows, formatting, and observation events, never a concrete provider. An optional
|
|
1712
|
+
* event policy supplies mutation guards; without one the tools use unconditional provider calls.
|
|
1713
|
+
* @module @deepseek-ai/dsh-tool-fs
|
|
1714
|
+
*/
|
|
1715
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
1716
|
+
const name = "tool-fs";
|
|
1717
|
+
/** Services required by the filesystem tool suite. */
|
|
1718
|
+
const inject = [
|
|
1719
|
+
"tools",
|
|
1720
|
+
"fs",
|
|
1721
|
+
"systemPrompt"
|
|
1722
|
+
];
|
|
1723
|
+
/** Defaults for the read repair sub-tunables. */
|
|
1724
|
+
const DEFAULT_DUPLICATE_STEPS = 4;
|
|
1725
|
+
/** Defaults for the structural completion sub-tunables. */
|
|
1726
|
+
const DEFAULT_COMPLETION_MAX_LINES = 40;
|
|
1727
|
+
const Config = z.object({
|
|
1728
|
+
readLimit: z.number().default(READ_LIMIT),
|
|
1729
|
+
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
|
|
1730
|
+
readMaxBytes: z.number().default(READ_MAX_BYTES),
|
|
1731
|
+
readStreamMinSize: z.number().default(STREAM_MIN_SIZE),
|
|
1732
|
+
readRepair: z.object({
|
|
1733
|
+
enabled: z.boolean().default(true),
|
|
1734
|
+
duplicate: z.object({
|
|
1735
|
+
enabled: z.boolean().default(true),
|
|
1736
|
+
maxSteps: z.number().default(DEFAULT_DUPLICATE_STEPS)
|
|
1737
|
+
}).default({
|
|
1738
|
+
enabled: true,
|
|
1739
|
+
maxSteps: DEFAULT_DUPLICATE_STEPS
|
|
1740
|
+
}),
|
|
1741
|
+
completion: z.object({
|
|
1742
|
+
enabled: z.boolean().default(true),
|
|
1743
|
+
maxLines: z.number().default(DEFAULT_COMPLETION_MAX_LINES)
|
|
1744
|
+
}).default({
|
|
1745
|
+
enabled: true,
|
|
1746
|
+
maxLines: DEFAULT_COMPLETION_MAX_LINES
|
|
1747
|
+
})
|
|
1748
|
+
}).default({
|
|
1749
|
+
enabled: true,
|
|
1750
|
+
duplicate: {
|
|
1751
|
+
enabled: true,
|
|
1752
|
+
maxSteps: DEFAULT_DUPLICATE_STEPS
|
|
1753
|
+
},
|
|
1754
|
+
completion: {
|
|
1755
|
+
enabled: true,
|
|
1756
|
+
maxLines: DEFAULT_COMPLETION_MAX_LINES
|
|
1757
|
+
}
|
|
1758
|
+
}),
|
|
1759
|
+
editRepair: z.object({
|
|
1760
|
+
enabled: z.boolean().default(true),
|
|
1761
|
+
maxFileChars: z.number().default(DEFAULT_MAX_FILE_CHARS),
|
|
1762
|
+
maxWindowLines: z.number().default(DEFAULT_MAX_WINDOW_LINES)
|
|
1763
|
+
}).default({
|
|
1764
|
+
enabled: true,
|
|
1765
|
+
maxFileChars: DEFAULT_MAX_FILE_CHARS,
|
|
1766
|
+
maxWindowLines: DEFAULT_MAX_WINDOW_LINES
|
|
1767
|
+
}),
|
|
1768
|
+
writeRepair: z.object({ pathHint: z.boolean().default(true) }).default({ pathHint: true })
|
|
1769
|
+
});
|
|
1770
|
+
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
|
|
1771
|
+
function assertPositiveInteger(name, value) {
|
|
1772
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-fs: ${name} must be a positive integer`);
|
|
1773
|
+
}
|
|
1774
|
+
/** Register the full `read`/`write`/`edit` filesystem tool suite, plus `read_image` while `attachments` is mounted. */
|
|
1775
|
+
function apply(ctx, config) {
|
|
1776
|
+
const resolved = config;
|
|
1777
|
+
assertPositiveInteger("readLimit", resolved.readLimit);
|
|
1778
|
+
assertPositiveInteger("readMaxLineLength", resolved.readMaxLineLength);
|
|
1779
|
+
assertPositiveInteger("readMaxBytes", resolved.readMaxBytes);
|
|
1780
|
+
assertPositiveInteger("readStreamMinSize", resolved.readStreamMinSize);
|
|
1781
|
+
const editRepair = resolved.editRepair;
|
|
1782
|
+
const repairMaxFileChars = editRepair.maxFileChars ?? DEFAULT_MAX_FILE_CHARS;
|
|
1783
|
+
const repairMaxWindowLines = editRepair.maxWindowLines ?? DEFAULT_MAX_WINDOW_LINES;
|
|
1784
|
+
assertPositiveInteger("editRepair.maxFileChars", repairMaxFileChars);
|
|
1785
|
+
assertPositiveInteger("editRepair.maxWindowLines", repairMaxWindowLines);
|
|
1786
|
+
const sessionPaths = createSessionPathProjection(["file_path", "path"]);
|
|
1787
|
+
const readWindows = createReadWindowProjection();
|
|
1788
|
+
const disclosures = createDisclosureLedger();
|
|
1789
|
+
ctx.on("session/event", (session, event) => {
|
|
1790
|
+
sessionPaths.observe(session, event);
|
|
1791
|
+
readWindows.observe(session, event);
|
|
1792
|
+
disclosures.observe(session, event);
|
|
1793
|
+
});
|
|
1794
|
+
const readRepair = resolved.readRepair;
|
|
1795
|
+
const duplicateEnabled = readRepair.enabled === true && readRepair.duplicate?.enabled === true;
|
|
1796
|
+
const duplicateSteps = readRepair.duplicate?.maxSteps ?? DEFAULT_DUPLICATE_STEPS;
|
|
1797
|
+
const completionMaxLines = readRepair.completion?.maxLines ?? DEFAULT_COMPLETION_MAX_LINES;
|
|
1798
|
+
assertPositiveInteger("readRepair.duplicate.maxSteps", duplicateSteps);
|
|
1799
|
+
assertPositiveInteger("readRepair.completion.maxLines", completionMaxLines);
|
|
1800
|
+
applyReadTool(ctx, {
|
|
1801
|
+
limit: resolved.readLimit,
|
|
1802
|
+
maxLineLength: resolved.readMaxLineLength,
|
|
1803
|
+
maxBytes: resolved.readMaxBytes,
|
|
1804
|
+
streamMinSize: resolved.readStreamMinSize
|
|
1805
|
+
}, {
|
|
1806
|
+
enabled: readRepair.enabled ?? true,
|
|
1807
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit),
|
|
1808
|
+
duplicate: {
|
|
1809
|
+
enabled: duplicateEnabled,
|
|
1810
|
+
maxSteps: duplicateSteps,
|
|
1811
|
+
judge: (session, path, version, window) => readWindows.judge(session, path, version, window, duplicateSteps),
|
|
1812
|
+
record: (session, path, observation) => {
|
|
1813
|
+
readWindows.record(session, path, observation);
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
completion: {
|
|
1817
|
+
enabled: readRepair.enabled === true && readRepair.completion?.enabled === true,
|
|
1818
|
+
maxLines: completionMaxLines
|
|
1819
|
+
},
|
|
1820
|
+
claimDisclosure: (session, key) => disclosures.claim(session, key)
|
|
1821
|
+
});
|
|
1822
|
+
ctx.inject(["attachments"], (imageCtx) => {
|
|
1823
|
+
applyReadImageTool(imageCtx);
|
|
1824
|
+
});
|
|
1825
|
+
const sandbox = new FsSandboxController(ctx);
|
|
1826
|
+
applyWriteTool(ctx, sandbox, {
|
|
1827
|
+
pathHint: resolved.writeRepair.pathHint ?? true,
|
|
1828
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit)
|
|
1829
|
+
});
|
|
1830
|
+
applyEditTool(ctx, sandbox, {
|
|
1831
|
+
enabled: editRepair.enabled ?? true,
|
|
1832
|
+
maxFileChars: repairMaxFileChars,
|
|
1833
|
+
maxWindowLines: repairMaxWindowLines,
|
|
1834
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit)
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
//#endregion
|
|
1838
|
+
export { Config, apply, inject, name };
|