@deepseek-ai/dsh-tool-str-replace-editor 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +52 -0
- package/README.zh.md +52 -0
- package/lib/index.js +341 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md
|
|
5
|
+
README.md: 8b4772cc4eb40e23a5d6ea8e409188b5033318ba
|
|
6
|
+
README.zh.md: b9ccf285870f2d02a2195b70dcd24fd37794976e
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-tool-str-replace-editor
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
| Key | Default | Meaning |
|
|
10
|
+
|---|---:|---|
|
|
11
|
+
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
|
|
12
|
+
| `description` | Editor command guide | Model-facing tool description. |
|
|
13
|
+
|
|
14
|
+
## Tool
|
|
15
|
+
|
|
16
|
+
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
|
17
|
+
|
|
18
|
+
## Model Experience
|
|
19
|
+
|
|
20
|
+
### Tool schema
|
|
21
|
+
|
|
22
|
+
#### What the model sees
|
|
23
|
+
|
|
24
|
+
The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section.
|
|
25
|
+
|
|
26
|
+
#### Token effect
|
|
27
|
+
|
|
28
|
+
Fixed schema cost while `str_replace_editor` is visible.
|
|
29
|
+
|
|
30
|
+
#### KV Cache effect
|
|
31
|
+
|
|
32
|
+
Prefix-stable while the configured description and schema remain unchanged.
|
|
33
|
+
|
|
34
|
+
### Tool results
|
|
35
|
+
|
|
36
|
+
#### What the model sees
|
|
37
|
+
|
|
38
|
+
Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
|
|
39
|
+
|
|
40
|
+
#### Token effect
|
|
41
|
+
|
|
42
|
+
Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
|
|
43
|
+
|
|
44
|
+
#### KV Cache effect
|
|
45
|
+
|
|
46
|
+
Append-only tool results follow the reusable request prefix.
|
|
47
|
+
|
|
48
|
+
## Known Limitations and Deferred Work
|
|
49
|
+
|
|
50
|
+
- Operations target UTF-8 text; binary files are unsupported.
|
|
51
|
+
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
|
|
52
|
+
- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-tool-str-replace-editor
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端接口组合。
|
|
6
|
+
|
|
7
|
+
## 配置
|
|
8
|
+
|
|
9
|
+
| 键 | 默认值 | 含义 |
|
|
10
|
+
|---|---:|---|
|
|
11
|
+
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
|
|
12
|
+
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
|
|
13
|
+
|
|
14
|
+
## 工具
|
|
15
|
+
|
|
16
|
+
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
|
|
17
|
+
|
|
18
|
+
## 模型体验
|
|
19
|
+
|
|
20
|
+
### 工具 schema
|
|
21
|
+
|
|
22
|
+
#### 模型看到的内容
|
|
23
|
+
|
|
24
|
+
生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。
|
|
25
|
+
|
|
26
|
+
#### Token 影响
|
|
27
|
+
|
|
28
|
+
`str_replace_editor` 可见时产生固定的 schema 成本。
|
|
29
|
+
|
|
30
|
+
#### KV Cache 影响
|
|
31
|
+
|
|
32
|
+
配置的描述与 schema 不变时前缀稳定。
|
|
33
|
+
|
|
34
|
+
### 工具结果
|
|
35
|
+
|
|
36
|
+
#### 模型看到的内容
|
|
37
|
+
|
|
38
|
+
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
|
|
39
|
+
|
|
40
|
+
#### Token 影响
|
|
41
|
+
|
|
42
|
+
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
|
|
43
|
+
|
|
44
|
+
#### KV Cache 影响
|
|
45
|
+
|
|
46
|
+
工具结果以追加方式位于可复用请求前缀之后。
|
|
47
|
+
|
|
48
|
+
## 已知限制与延后工作
|
|
49
|
+
|
|
50
|
+
- 操作面向 UTF-8 文本,不支持二进制文件。
|
|
51
|
+
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
|
|
52
|
+
- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前会话的沙箱策略,并交由挂载的文件系统与策略插件执行。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
4
|
+
import { sandboxDenialMarker } from "@deepseek-ai/dsh-sandbox";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
//#region lib/types/index.js
|
|
7
|
+
/**
|
|
8
|
+
* Model-facing `str_replace_editor` over the Harness filesystem seam.
|
|
9
|
+
* @module @deepseek-ai/dsh-tool-str-replace-editor
|
|
10
|
+
*/
|
|
11
|
+
const TRUNCATED_MESSAGE = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>";
|
|
12
|
+
const DEFAULT_DESCRIPTION = `
|
|
13
|
+
Custom editing tool for viewing, creating and editing files
|
|
14
|
+
* State is persistent across command calls and discussions with the user
|
|
15
|
+
* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep
|
|
16
|
+
* The \`create\` command cannot be used if the specified \`path\` already exists as a file
|
|
17
|
+
* If a \`command\` generates a long output, it will be truncated and marked with \`<response clipped>\`
|
|
18
|
+
|
|
19
|
+
Notes for using the \`str_replace\` command:
|
|
20
|
+
* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
|
21
|
+
* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique
|
|
22
|
+
* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
|
|
23
|
+
`.trim();
|
|
24
|
+
function maybeTruncate(content, maxOutputChars) {
|
|
25
|
+
return content.length <= maxOutputChars ? content : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE;
|
|
26
|
+
}
|
|
27
|
+
function codepointCompare(left, right) {
|
|
28
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
29
|
+
}
|
|
30
|
+
function matchOffsets(content, search) {
|
|
31
|
+
const offsets = [];
|
|
32
|
+
let offset = 0;
|
|
33
|
+
while (true) {
|
|
34
|
+
const match = content.indexOf(search, offset);
|
|
35
|
+
if (match < 0) return offsets;
|
|
36
|
+
offsets.push(match);
|
|
37
|
+
offset = match + search.length;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function lineNumbersAt(content, offsets) {
|
|
41
|
+
let line = 1;
|
|
42
|
+
let cursor = 0;
|
|
43
|
+
return offsets.map((offset) => {
|
|
44
|
+
while (cursor < offset) {
|
|
45
|
+
if (content[cursor] === "\n") line += 1;
|
|
46
|
+
cursor += 1;
|
|
47
|
+
}
|
|
48
|
+
return line;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
var MutationPolicy = class {
|
|
52
|
+
policy;
|
|
53
|
+
constructor(ctx) {
|
|
54
|
+
this.policy = ctx.fs.sandboxMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
|
|
55
|
+
if (ctx.fs.sandboxMode !== void 0 && this.policy === void 0) throw new Error("tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing");
|
|
56
|
+
}
|
|
57
|
+
resolve(exec) {
|
|
58
|
+
return this.policy?.resolve({ ...exec.agent === void 0 ? {} : { session: exec.agent.session } });
|
|
59
|
+
}
|
|
60
|
+
mapError(error, policy) {
|
|
61
|
+
if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
|
|
62
|
+
const mode = policy.mode;
|
|
63
|
+
return new FsError(sandboxDenialMarker(mode), "FS_SANDBOX_DENIED", { cause: error });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
async function resolveTarget(ctx, path, signal) {
|
|
67
|
+
if (path.trim().length === 0) throw new Error("path must be a non-empty string");
|
|
68
|
+
if (!isAbsolute(path)) throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`);
|
|
69
|
+
return ctx.fs.resolve(path, { signal });
|
|
70
|
+
}
|
|
71
|
+
async function statExisting(ctx, target, command, exec) {
|
|
72
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
73
|
+
if (info === void 0) {
|
|
74
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
75
|
+
throw new FsError(`The path ${target.displayPath} does not exist. Please provide a valid path.`, "FS_NOT_FOUND");
|
|
76
|
+
}
|
|
77
|
+
if (info.type === "directory" && command !== "view") throw new FsError(`The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`, "FS_NOT_REGULAR_FILE");
|
|
78
|
+
return info;
|
|
79
|
+
}
|
|
80
|
+
function requiredForCommand(value, parameter, command, allowEmpty = true) {
|
|
81
|
+
if (value === void 0) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`);
|
|
82
|
+
if (!allowEmpty && value.length === 0) throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function formatFileView(path, content, maxOutputChars, viewRange) {
|
|
86
|
+
const allLines = content.split("\n");
|
|
87
|
+
let lines = allLines;
|
|
88
|
+
let initialLine = 1;
|
|
89
|
+
let finalLine;
|
|
90
|
+
let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`;
|
|
91
|
+
if (viewRange !== void 0) {
|
|
92
|
+
const [requestedInitialLine, requestedFinalLine] = viewRange;
|
|
93
|
+
if (viewRange.length !== 2 || requestedInitialLine === void 0 || requestedFinalLine === void 0 || !viewRange.every(Number.isInteger)) throw new Error("Invalid `view_range`. It should be a list of two integers.");
|
|
94
|
+
initialLine = requestedInitialLine;
|
|
95
|
+
finalLine = requestedFinalLine;
|
|
96
|
+
if (initialLine < 1 || initialLine > allLines.length) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`);
|
|
97
|
+
if (finalLine > allLines.length) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``);
|
|
98
|
+
if (finalLine !== -1 && finalLine < initialLine) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``);
|
|
99
|
+
lines = finalLine === -1 ? allLines.slice(initialLine - 1) : allLines.slice(initialLine - 1, finalLine);
|
|
100
|
+
prompt += ` with view_range=[${initialLine}, ${finalLine}]`;
|
|
101
|
+
}
|
|
102
|
+
const numbered = lines.map((line, index) => `${String(initialLine + index).padStart(6, " ")} ${line}`).join("\n");
|
|
103
|
+
return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars);
|
|
104
|
+
}
|
|
105
|
+
async function listDirectory(ctx, target, maxOutputChars, exec) {
|
|
106
|
+
async function visit(dir, depth) {
|
|
107
|
+
const entries = await ctx.fs.listDir(dir, exec.signal);
|
|
108
|
+
const rows = [];
|
|
109
|
+
for (const entry of entries.filter((candidate) => !candidate.name.startsWith(".") && candidate.name !== "node_modules" && candidate.name !== "__pycache__")) {
|
|
110
|
+
const type = entry.type === "directory" ? "d" : entry.type === "file" ? "f" : "?";
|
|
111
|
+
rows.push(`${type}\t${entry.target.displayPath}`);
|
|
112
|
+
if (entry.type === "directory" && depth < 2) rows.push(...await visit(entry.target, depth + 1));
|
|
113
|
+
}
|
|
114
|
+
return rows;
|
|
115
|
+
}
|
|
116
|
+
const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)];
|
|
117
|
+
rows.sort((left, right) => {
|
|
118
|
+
return codepointCompare(left.slice(left.indexOf(" ") + 1), right.slice(right.indexOf(" ") + 1));
|
|
119
|
+
});
|
|
120
|
+
const listing = maybeTruncate(rows.join("\n") + "\n", maxOutputChars);
|
|
121
|
+
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`;
|
|
122
|
+
}
|
|
123
|
+
async function viewPath(ctx, path, viewRange, maxOutputChars, exec) {
|
|
124
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
125
|
+
const info = await statExisting(ctx, target, "view", exec);
|
|
126
|
+
if (info.type === "directory") {
|
|
127
|
+
if (viewRange !== void 0) throw new Error("The `view_range` parameter is not allowed when `path` points to a directory.");
|
|
128
|
+
return listDirectory(ctx, target, maxOutputChars, exec);
|
|
129
|
+
}
|
|
130
|
+
if (info.type !== "file") throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, "FS_NOT_REGULAR_FILE");
|
|
131
|
+
const content = await ctx.fs.readText(target, exec.signal);
|
|
132
|
+
ctx.emit("fs/observed", target, {
|
|
133
|
+
kind: "present",
|
|
134
|
+
version: info.version
|
|
135
|
+
}, exec);
|
|
136
|
+
return formatFileView(target.displayPath, content, maxOutputChars, viewRange);
|
|
137
|
+
}
|
|
138
|
+
async function createFile(ctx, policy, path, fileText, exec) {
|
|
139
|
+
const content = requiredForCommand(fileText, "file_text", "create");
|
|
140
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
141
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
142
|
+
if (await ctx.fs.stat(target, exec.signal) !== void 0) throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`);
|
|
143
|
+
const intent = await ctx.waterfall("fs/write-intent", target, exec, () => ({ kind: "createIfAbsent" }));
|
|
144
|
+
let outcome;
|
|
145
|
+
try {
|
|
146
|
+
outcome = await ctx.fs.writeText(target, content, intent, exec.signal, sandboxPolicy);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
149
|
+
}
|
|
150
|
+
ctx.emit("fs/observed", target, {
|
|
151
|
+
kind: "present",
|
|
152
|
+
version: outcome.version
|
|
153
|
+
}, exec);
|
|
154
|
+
return `New file created successfully at: ${target.displayPath}`;
|
|
155
|
+
}
|
|
156
|
+
async function replaceInFile(ctx, policy, path, oldStr, newStr, exec) {
|
|
157
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
158
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
159
|
+
const intent = await ctx.waterfall("fs/edit-intent", target, exec, () => void 0);
|
|
160
|
+
const oldValue = requiredForCommand(oldStr, "old_str", "str_replace", false);
|
|
161
|
+
const newValue = newStr ?? "";
|
|
162
|
+
const info = await statExisting(ctx, target, "str_replace", exec);
|
|
163
|
+
if (info.type !== "file") throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
164
|
+
const before = await ctx.fs.readText(target, exec.signal);
|
|
165
|
+
const offsets = matchOffsets(before, oldValue);
|
|
166
|
+
const offset = offsets[0];
|
|
167
|
+
if (offset === void 0) throw new FsError(`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, "FS_EDIT_NOT_FOUND");
|
|
168
|
+
if (offsets.length > 1) throw new FsError(`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lineNumbersAt(before, offsets).join(", ")}]. Please ensure it is unique`, "FS_AMBIGUOUS_EDIT");
|
|
169
|
+
let outcome;
|
|
170
|
+
try {
|
|
171
|
+
outcome = await ctx.fs.writeText(target, before.slice(0, offset) + newValue + before.slice(offset + oldValue.length), intent === void 0 ? {
|
|
172
|
+
kind: "replaceIfVersion",
|
|
173
|
+
version: info.version
|
|
174
|
+
} : {
|
|
175
|
+
kind: "replaceIfVersion",
|
|
176
|
+
version: intent.version
|
|
177
|
+
}, exec.signal, sandboxPolicy);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
180
|
+
}
|
|
181
|
+
ctx.emit("fs/observed", target, {
|
|
182
|
+
kind: "present",
|
|
183
|
+
version: outcome.version
|
|
184
|
+
}, exec);
|
|
185
|
+
return `The file ${target.displayPath} has been edited successfully.`;
|
|
186
|
+
}
|
|
187
|
+
async function insertInFile(ctx, policy, path, insertLine, newStr, exec) {
|
|
188
|
+
if (insertLine === void 0) throw new Error("Parameter `insert_line` is required for command: insert");
|
|
189
|
+
const value = requiredForCommand(newStr, "new_str", "insert");
|
|
190
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
191
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
192
|
+
const intent = await ctx.waterfall("fs/edit-intent", target, exec, () => void 0);
|
|
193
|
+
const info = await statExisting(ctx, target, "insert", exec);
|
|
194
|
+
if (info.type !== "file") throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
195
|
+
const lines = (await ctx.fs.readText(target, exec.signal)).split("\n");
|
|
196
|
+
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) throw new Error(`Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`);
|
|
197
|
+
const after = [
|
|
198
|
+
...lines.slice(0, insertLine),
|
|
199
|
+
...value.split("\n"),
|
|
200
|
+
...lines.slice(insertLine)
|
|
201
|
+
].join("\n");
|
|
202
|
+
const expected = intent === void 0 ? {
|
|
203
|
+
kind: "replaceIfVersion",
|
|
204
|
+
version: info.version
|
|
205
|
+
} : {
|
|
206
|
+
kind: "replaceIfVersion",
|
|
207
|
+
version: intent.version
|
|
208
|
+
};
|
|
209
|
+
let outcome;
|
|
210
|
+
try {
|
|
211
|
+
outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
214
|
+
}
|
|
215
|
+
ctx.emit("fs/observed", target, {
|
|
216
|
+
kind: "present",
|
|
217
|
+
version: outcome.version
|
|
218
|
+
}, exec);
|
|
219
|
+
return `The file ${target.displayPath} has been edited successfully.`;
|
|
220
|
+
}
|
|
221
|
+
function presentEditorCall(args) {
|
|
222
|
+
switch (args.command) {
|
|
223
|
+
case "view": return {
|
|
224
|
+
card: "generic",
|
|
225
|
+
title: `view ${args.path}`,
|
|
226
|
+
kind: "read",
|
|
227
|
+
locations: [{ path: args.path }]
|
|
228
|
+
};
|
|
229
|
+
case "create": return {
|
|
230
|
+
card: "diff",
|
|
231
|
+
title: `create ${args.path}`,
|
|
232
|
+
diffs: [{
|
|
233
|
+
path: args.path,
|
|
234
|
+
oldText: null,
|
|
235
|
+
newText: args.file_text ?? ""
|
|
236
|
+
}],
|
|
237
|
+
locations: [{ path: args.path }]
|
|
238
|
+
};
|
|
239
|
+
case "str_replace": return {
|
|
240
|
+
card: "diff",
|
|
241
|
+
title: `str_replace ${args.path}`,
|
|
242
|
+
diffs: [{
|
|
243
|
+
path: args.path,
|
|
244
|
+
oldText: args.old_str ?? null,
|
|
245
|
+
newText: args.new_str ?? ""
|
|
246
|
+
}],
|
|
247
|
+
locations: [{ path: args.path }]
|
|
248
|
+
};
|
|
249
|
+
case "insert": return {
|
|
250
|
+
card: "generic",
|
|
251
|
+
title: `insert ${args.path}`,
|
|
252
|
+
kind: "edit",
|
|
253
|
+
locations: [{
|
|
254
|
+
path: args.path,
|
|
255
|
+
...args.insert_line === void 0 ? {} : { line: Math.max(1, args.insert_line + 1) }
|
|
256
|
+
}]
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Register the model-facing `str_replace_editor` tool. */
|
|
261
|
+
function registerStrReplaceEditor(ctx, config) {
|
|
262
|
+
const policy = new MutationPolicy(ctx);
|
|
263
|
+
ctx.tools.register(defineTool({
|
|
264
|
+
name: "str_replace_editor",
|
|
265
|
+
description: config.description,
|
|
266
|
+
parameters: {
|
|
267
|
+
command: {
|
|
268
|
+
type: "string",
|
|
269
|
+
required: true,
|
|
270
|
+
enum: [
|
|
271
|
+
"view",
|
|
272
|
+
"create",
|
|
273
|
+
"str_replace",
|
|
274
|
+
"insert"
|
|
275
|
+
],
|
|
276
|
+
description: "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`."
|
|
277
|
+
},
|
|
278
|
+
path: {
|
|
279
|
+
type: "string",
|
|
280
|
+
required: true,
|
|
281
|
+
description: "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
|
282
|
+
},
|
|
283
|
+
file_text: {
|
|
284
|
+
type: "string",
|
|
285
|
+
description: "Required parameter of `create` command, with the content of the file to be created."
|
|
286
|
+
},
|
|
287
|
+
insert_line: {
|
|
288
|
+
type: "integer",
|
|
289
|
+
description: "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
|
|
290
|
+
},
|
|
291
|
+
new_str: {
|
|
292
|
+
type: "string",
|
|
293
|
+
description: "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
|
|
294
|
+
},
|
|
295
|
+
old_str: {
|
|
296
|
+
type: "string",
|
|
297
|
+
description: "Required parameter of `str_replace` command containing the string in `path` to replace."
|
|
298
|
+
},
|
|
299
|
+
view_range: {
|
|
300
|
+
type: "array",
|
|
301
|
+
items: { type: "integer" },
|
|
302
|
+
description: "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
output: {
|
|
306
|
+
schema: { type: "string" },
|
|
307
|
+
render: (_args, value) => [{
|
|
308
|
+
type: "text",
|
|
309
|
+
text: value
|
|
310
|
+
}]
|
|
311
|
+
},
|
|
312
|
+
async execute(args, exec) {
|
|
313
|
+
switch (args.command) {
|
|
314
|
+
case "view": return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, exec);
|
|
315
|
+
case "create": return createFile(ctx, policy, args.path, args.file_text, exec);
|
|
316
|
+
case "str_replace": return replaceInFile(ctx, policy, args.path, args.old_str, args.new_str, exec);
|
|
317
|
+
case "insert": return insertInFile(ctx, policy, args.path, args.insert_line, args.new_str, exec);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
presentCall: presentEditorCall
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
const name = "tool-str-replace-editor";
|
|
324
|
+
const inject = ["tools", "fs"];
|
|
325
|
+
/** Runtime configuration schema for the string-replacement editor tool. */
|
|
326
|
+
const Config = z.object({
|
|
327
|
+
maxOutputChars: z.number().default(16e3),
|
|
328
|
+
description: z.string().default(DEFAULT_DESCRIPTION)
|
|
329
|
+
});
|
|
330
|
+
/** Register one `str_replace_editor` tool over `ctx.fs`. */
|
|
331
|
+
function apply(ctx, config) {
|
|
332
|
+
const resolved = {
|
|
333
|
+
maxOutputChars: config.maxOutputChars ?? 16e3,
|
|
334
|
+
description: config.description ?? DEFAULT_DESCRIPTION
|
|
335
|
+
};
|
|
336
|
+
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error("tool-str-replace-editor: maxOutputChars must be a positive safe integer");
|
|
337
|
+
if (resolved.description.trim().length === 0) throw new Error("tool-str-replace-editor: description must be non-empty");
|
|
338
|
+
registerStrReplaceEditor(ctx, resolved);
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`.
|
|
4
|
+
* @module @deepseek-ai/dsh-tool-str-replace-editor/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-tool-str-replace-editor";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "tool-str-replace-editor-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the tool adapter owns no independent durable state;
|
|
13
|
+
* filesystem mutation relations stay with the provider and policy plugins.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing `str_replace_editor` over the Harness filesystem seam.
|
|
3
|
+
* @module @deepseek-ai/dsh-tool-str-replace-editor
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
export declare const name = "tool-str-replace-editor";
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
/** Configuration for the string-replacement editor tool. */
|
|
10
|
+
export interface Config {
|
|
11
|
+
/** Maximum returned view characters before clipping (default 16000). */
|
|
12
|
+
maxOutputChars?: number;
|
|
13
|
+
/** Model-facing tool description. */
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Runtime configuration schema for the string-replacement editor tool. */
|
|
17
|
+
export declare const Config: z<Config>;
|
|
18
|
+
/** Register one `str_replace_editor` tool over `ctx.fs`. */
|
|
19
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`.
|
|
3
|
+
* @module @deepseek-ai/dsh-tool-str-replace-editor/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "tool-str-replace-editor-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-tool-str-replace-editor",
|
|
3
|
+
"description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/fs/tool-str-replace-editor"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/invariant.js",
|
|
30
|
+
"lib/types/**/*.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"license": "BSD-3-Clause",
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
35
|
+
"@deepseek-ai/dsh-sandbox": "^0.0.1-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1-rc.1",
|
|
37
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
38
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.1",
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
|
46
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-fs-local": "^0.0.1-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-fs-policy": "^0.0.1-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-fs-sandbox": "^0.0.1-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-sandbox": "^0.0.1-rc.1",
|
|
53
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
54
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
55
|
+
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.1"
|
|
58
|
+
}
|
|
59
|
+
}
|