dsh-line-patch 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kingofpowers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-line-patch
2
+
3
+ 给 DSH 的内置文件工具加一层「锚点补丁」:`read` / `write` / `edit` 的结果末尾多一行 `file_hash`,新增一个 `patch` 工具按「整文件 hash + 行号 + 边界行内容」改文件。不新增读工具,模型仍然用它已经熟悉的 `read`。
4
+
5
+ ## 为什么需要它
6
+
7
+ 内置 `edit` 用字面量 `old_string` 定位,**定位费按输出 token 计价**。本机 308 个会话、891 次 `edit` 的实测数据:
8
+
9
+ | 指标 | 数值 |
10
+ |---|---|
11
+ | `old_string` 累计 | 285,035 字符 ≈ **81,439 输出 token** |
12
+ | 占 `new_string` 的比例 | **36%** |
13
+ | 引文 >30 行的调用 | 4.6% |
14
+ | `edit` 失败构成 | 未先读 2.9% / 文件已变 2.8% / **原文找不到仅 0.7%** |
15
+
16
+ 也就是说:抄原文这件事吃掉了三成输出,而它换来的定位可靠性只解决 0.7% 的失败。
17
+
18
+ 同时它和 DSH 内置的版本守卫**互补而非重复**:
19
+
20
+ | 场景 | 内置 stat 版本(本机 provider:`dev:ino:size:mtimeNs:ctimeNs`) | 本插件 file_hash |
21
+ |---|---|---|
22
+ | 内容改了 | 拒绝 ✅ | 拒绝 ✅ |
23
+ | 版本只含 mtime/size 的 provider 上,内容改了而 mtime/size 未变(`cp -p`、备份还原) | **漏判 ❌** | 拒绝 ✅ |
24
+ | 内容没变只是被 touch / 重新构建 | **误伤 ❌**(版本变了) | 放行 ✅(内容一致) |
25
+
26
+ 本机 provider 的版本含 `ctimeNs`,而写文件必然改 ctime,所以「改了内容却版本不变」只会在粗时间戳文件系统或自定义 provider 上出现;`file_hash` 的价值是**不依赖 provider 的版本语义**:内容一样就放行,内容不一样就拒绝。它也正好是 `patch` 能在不重读的前提下自纠的依据(见「失败怎么读」)。
27
+
28
+ ### 为什么不是逐行 hash
29
+
30
+ 0.2.x 给每行加了 `行号/4位hash` 标记。对 agentVillage 那次 2,794 次请求的长会话逐行核算后:
31
+
32
+ | 项 | 一次性 token | 折算(含缓存命中与上下文驻留) |
33
+ |---|---|---|
34
+ | 读取侧逐行锚点 (+2.55 tok/行,读取量 +9.6%) | +31,264 | **+4.15M 输入当量** |
35
+ | `patch` 省下 `old_string` | −14,004 | −3.80M |
36
+
37
+ 逐行锚点的读税**几乎正好吃掉**补丁的全部收益,而且它在安全性上是冗余的:`patch` 第一步校验整文件 hash,hash 对得上就说明文件与读取时一致(6 位前缀足以覆盖日常改动,概率数字见「已知限制」)。所以 0.3.0 只保留**整文件 6 位 hash**,边界改用**行内内容切片**(前 16 / 末 16 字符),模型照抄即可,读取侧零额外标记。
38
+
39
+ ## 安装
40
+
41
+ ```bash
42
+ dsh plugin --profile desktop add /path/to/dsh-line-patch
43
+ ```
44
+
45
+ 改完 `lib/` 后需要重新加载:touch 一下 `~/.dsh/profiles/desktop/cordis.patch.yml` 触发 HMR,或重启宿主。
46
+
47
+ ## 用法
48
+
49
+ `read` / `write` / `edit` 的结果末尾会多出一行 `file_hash: xxxxxx`(6 位十六进制):
50
+
51
+ ```text
52
+ read { file_path: "app.py" }
53
+ → 1: import os
54
+ 2: def main():
55
+ 3: return 0
56
+ file_hash: 9f2c1a
57
+ ```
58
+
59
+ 然后直接用它改:
60
+
61
+ ```text
62
+ patch {
63
+ file_path: "app.py",
64
+ file_hash: "9f2c1a",
65
+ line_start: 2, start_anchor: "def main():",
66
+ line_end: 3, end_anchor: "return 0",
67
+ replace: "def run():\n return 1"
68
+ }
69
+ → patched app.py: lines 2-3 replaced (line shift +0).
70
+ file_hash: d119fa
71
+ ```
72
+
73
+ - `file_hash` 取自**最近一次** `read`/`write`/`edit` 该文件结果末尾那行,或上一次 `patch` 结果里回的那行;三处都可以连续接龙。
74
+ - `start_anchor` = 起始行的前 16 个字符,`end_anchor` = 结束行的末 16 个字符,**抄 read 结果里 `N: ` 前缀之后的那段文字**(前缀本身不是锚点的一部分);行短于 16 字符时整行就是锚点。
75
+ - `start_anchor` 是否带缩进决定比较方式:带了按缩进严格比(`if (err) {` ≠ ` if (err) {`,挡「层级看错、行号差一行」);不带就与折叠空白后的行比较。内部空白与行尾空白始终折叠。**建议总是连缩进一起抄。**
76
+ - `end_anchor` 是行尾切片,前后空白都折叠后按后缀比较(行尾的空白字符不算内容)。
77
+ - 空白行用空字符串 `""` 作锚点;缩进超过 16 列的行,前 16 字符全是空白,工具会提示改从第一个非空白字符开始抄。
78
+ - 单行修改(`line_start == line_end`)两个锚点落在同一行:`start_anchor` 按前缀比,`end_anchor` 允许后缀或前缀。
79
+ - 锚点必须抄满(16 字符,或整行),只抄前几个字符会被 `ANCHOR_TOO_SHORT` 拒绝。
80
+ - 若 16 字符锚点同时命中多行(例如两行都以 `const value = co` 开头),工具会以 `ANCHOR_AMBIGUOUS` 拒绝,并给出**已保证唯一的更长锚点**(可能是整行)——照它再发一次即可,不必重读。start 和 end 各有一个歧义时,各需要一次重发。
81
+ - `replace: ""` 删除该行区间;`line_shift` 给出位移,连续修改不必整段重读。
82
+ - CRLF 文件按原风格写回;末行无换行符的状态保持。
83
+
84
+ ## 失败怎么读
85
+
86
+ | 错误 | 含义 | 模型该做什么 |
87
+ |---|---|---|
88
+ | `file_hash mismatch` | 文件真被改了 | **重读**(不可省) |
89
+ | `RANGE_INVALID` | 行号超出文件范围 | 改行号重试,**不要重读** |
90
+ | `ANCHOR_TOO_SHORT` | 锚点没抄满,或整段是空白 | 按错误给出的范围重抄,**不要重读** |
91
+ | `ANCHOR_AMBIGUOUS` | 16 字符锚点命中多行 | 照错误给出的更长锚点重发,**不要重读** |
92
+ | `LINE_ANCHOR_MISMATCH` | 文件没变,但该行不是你说的内容 | 错误里会回显该行**真正的锚点**;改锚点或行号重试,**不要重读** |
93
+ | `BAD_ARGUMENT` | 行号不是整数、`replace` 不是字符串、或 `file_hash` 不是 6 位小写十六进制 | 修正参数后重试;若从未见过 `file_hash` 行,说明注解没生效,改用 `edit`/`write` |
94
+
95
+ 只有 `file_hash mismatch` 需要重读;其余错误都在「文件没变」的前提下发生,一轮自纠即可——这正是「一次额外往返 ≈ 一整轮上下文」的价值所在。
96
+
97
+ ## 锚点核实(每轮自动)
98
+
99
+ 每次 `read` 都会把「文件 hash + 版本」登记下来,之后每次模型请求前(`agent/request`)复核一次,只覆盖**最近 3 个用户回合**内的文件:
100
+
101
+ ```
102
+ stat 预筛(微秒级,不读文件)
103
+ ├─ 版本没变 → 什么都不做
104
+ └─ 版本变了 → 算 file_hash
105
+ ├─ 内容相同(touch / 重新构建) → 只补发 fs/observed 刷新策略底账,上下文零改动
106
+ └─ 内容真变了 → 把当初那条 read 结果原地替换成「已失效 + 新旧 hash + 请重读」
107
+ ```
108
+
109
+ 第二行是关键:内置的 stat 版本守卫会把「内容没变但 mtime 变了」误判为外部修改,这里用内容 hash 纠正它,模型不必因为一次 rebuild 就重读整个文件。
110
+
111
+ ## 实现要点
112
+
113
+ - 结果标注走宿主全局 waterfall **`tools/post-execute`**:监听 `read`/`write`/`edit`,把 hash 行**并入最后一个 text block**(不新增 block,否则会打断 `dsh-context-archive` 这类要求单 block 的注解插件);不改写工具本身,也不需要覆盖同名工具(宿主不允许重名注册)。
114
+ - cordis 的 `waterfall` 里 `next()` **不收参数**,最外层监听器的返回值才是宿主应用的结果,所以监听器是 `const d = await next(); return { ...d, content: ... }`。
115
+ - 追加 hash 时插件会按路径**再读一次文件**(页面缓存热),并比对工具自己广播的 `fs/observed` 版本:版本在「工具读取」与「插件复核」之间变过就**不标注**,避免把模型没看过的字节的 hash 交给它。
116
+ - `patch` 的读取与写入走 `ctx.fs`,不直接碰 `node:fs`,沙箱与远端后端仍然有效;相对路径按会话工作目录解析(与内置 fs 工具一致)。
117
+ - 写入复刻内置 `write` 的 intent 舞蹈:`fs/write-intent` waterfall 取意图 → `writeText(intent)` → `emit('fs/observed')`。**裸调 `writeText` 会静默绕过读后写门禁与版本 CAS**。
118
+
119
+ ## 从 0.2.x 升级
120
+
121
+ 0.3.0 是**破坏性改版**:`read_hash` 与 `patch_hash` 两个工具都已删除。
122
+
123
+ | 0.2.x | 0.3.0 |
124
+ |---|---|
125
+ | `read_hash` 输出 `1/f3c2: 内容` + `file_hash`(16 位) | 用内置 `read`;结果末尾多一行 `file_hash: xxxxxx`(6 位) |
126
+ | `write` / `edit` 不返回 hash | 两者的结果都追加 `file_hash` |
127
+ | `patch_hash` 工具 | `patch` 工具(`file_hash` + `line_start` + `start_anchor` + `line_end` + `end_anchor` + `replace`) |
128
+ | `hash_start` / `hash_end` | `start_anchor` / `end_anchor`(16 字符内容切片) |
129
+ | `total_lines` | 已移除 |
130
+
131
+ 另外,0.3.0 新增了 `engines.dsh: ">=0.2.1-rc.1 <0.3.0"`。这不只是元数据:声明了 `engines.dsh` 之后,插件管理器只按这一条判断,`peerDependencies` 不再兜底——装到范围外的宿主上会被直接拒绝。
132
+
133
+ ## 已验证
134
+
135
+ 0.2.x 在真实宿主上验证过替换、行位移、过期 `file_hash`、`replace: ""` 与 `replaceIfVersion` intent;0.3.0 的 hash 标注、锚点匹配、空白宽容、缩进严格性、短行/空行、EOF/BOF、错误分流由离线测试覆盖。
136
+
137
+ 离线测试:`npm test` — 86 个用例(核心逻辑 55 + 工具胶水 23 + 锚点核实 8),全部通过。测试桩严格按 cordis waterfall 语义实现(`next()` 不收参数、外层返回值即结果),并断言链路被继续、下游决策被组合、注解落在单个 text block 内。
138
+
139
+ 0.3.0 **尚未在真实宿主上做端到端验证**:`tools/post-execute` 的行为是按宿主源码(`dsh-tools` 0.2.1-rc.5)核对的,首次装进 desktop profile 时应当先跑一遍 `read` → `patch`。
140
+
141
+ ## 尚未实现
142
+
143
+ - `presentCall` / `presentResult` 的 diff 卡片(目前是纯文本渲染)。
144
+ - 只核对「本会话登记过的、3 个用户回合内」的文件;更早的锚点按设计丢弃,不做长期追踪。
145
+ - **无法删除内置 `edit`**:插件不能注销或覆盖已注册的全局工具,所以 `edit` 会继续存在;`patch` 只是新增的另一个选择。
146
+ - 沙箱策略:内置 `write` 会把 `sandbox.resolvePolicy('write', …)` 的结果传给 `writeText`,本插件没有传(沿用 0.2.x 的行为),在「会话级策略严于部署默认」的部署里可能被当成默认策略放行。这是既有问题,未在本次范围内修。
147
+
148
+ ## 已知限制
149
+
150
+ - 单文件 5 MB 上限:超过上限的文件不标注 hash(模型只会看到「没有 `file_hash` 行」),`patch` 也会拒绝;此时请用 `edit`/`write`。
151
+ - **6 位 hash 是「文件是否变过」的检测器,不是防篡改凭证**。它挡的是没人针对你的改动(并发写、格式化、构建、别人保存):随机撞车 1/1600 万,可忽略。能写这个文件的人本来也不需要骗过它——那种场景靠 provider 的版本 CAS(含 ctime)与备份,不在这层。需要更长的检测位可调 `FILE_HASH_CHARS`。
152
+ - **锚点只看边界行,且只看 16+16 个字符**。两类行仍无法靠内容区分,只能靠行号:
153
+ 1. 整行内容完全相同的行(多个孤立的 `}`、多个空行、重复的 `export function `);
154
+ 2. 前 16 字符与末 16 字符都相同、只有中间不同的行——这类现在会被 `ANCHOR_AMBIGUOUS` 拦下并给出更长锚点,除非整行本身就重复。
155
+
156
+ 整文件 hash 对这两种情况同样无能为力(它只证明「文件没变」)。带缩进一起抄能把「只差缩进」的那类行分开(缩进 ≥16 列的行例外,见上文)。
157
+ - 追加 hash 依赖宿主提供 `tools/post-execute`;宿主没有这个 waterfall 时 `read` 不带 hash,`patch` 也就无法使用(不会静默改错)。
158
+ - 「版本守卫」只在 provider 广播 `fs/observed` 且带版本时生效;不广播的 provider 上插件会直接标注(此时只剩 `patch` 自己的内容 hash 校验)。
159
+ - **带 hash 的 `read` 结果在 Web UI 里会退化成通用文本卡片**(代码高亮/行号卡片需要结果文本以 `</content>` 结尾,追加的一行破坏了该判定)。只影响显示,不影响模型看到的文本。
160
+ - **PTC(`run_code`)模式拿不到这个 hash**:程序侧只收到工具的 `{ isError, value }`,而内置 `read` 的 value schema(`additionalProperties: false`)里没有 hash 字段,插件无法追加。PTC 会话里请直接用 `edit`/`write`。
161
+ - 若文件在「工具读取」与「插件复核」之间被改,插件不标注 hash(见实现要点);更窄的窗口(插件 stat 之后、写入之前)仍由 provider 版本 CAS 兜底。
162
+ - `fs/write-intent` 不可用时退化为自行构造的 `replaceIfVersion`;再被 provider 忽略时该次写入没有 CAS 保护。
163
+ - 若 `@deepseek-ai/dsh-tools` 无法解析,`defineTool` 退化为恒等函数:工具仍可用,但参数校验交给插件自己(核心逻辑里仍有整数/类型/哈希格式校验)。
164
+
165
+ ## 许可
166
+
167
+ MIT
@@ -0,0 +1,4 @@
1
+ # Anchored line-range editing: annotates read/write/edit results with a file hash and adds the patch tool.
2
+ - insert:
3
+ - id: line-patch
4
+ name: dsh-line-patch
package/lib/core.js ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * dsh-line-patch — pure anchoring logic.
3
+ *
4
+ * No I/O and no host API lives here: every function takes text in and returns
5
+ * text out, so the whole editing contract is unit-testable without a Harness.
6
+ *
7
+ * The anchors a model copies are *content* slices, not hashes: the first and
8
+ * last characters of the boundary lines are visible in a normal read, so
9
+ * nothing has to be recomputed to write them back.
10
+ */
11
+ import { createHash } from 'node:crypto'
12
+
13
+ /** Characters kept from a boundary line — enough to identify it, cheap to copy. */
14
+ export const ANCHOR_CHARS = 16
15
+
16
+ /** Characters kept from the whole-file sha256 — enough to spot a changed file. */
17
+ export const FILE_HASH_CHARS = 6
18
+
19
+ /**
20
+ * The hash shown underneath a read/write/edit result.
21
+ * @param {string|Buffer} content - raw file content.
22
+ * @returns {string} a 6-character sha256 prefix.
23
+ */
24
+ export function fileHash(content) {
25
+ return createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_CHARS)
26
+ }
27
+
28
+ /**
29
+ * Detect the line ending a file is written in so replacements keep it.
30
+ * @param {string} content - the file text.
31
+ * @returns {string} '\r\n' or '\n'.
32
+ */
33
+ export function detectEol(content) {
34
+ return content.includes('\r\n') ? '\r\n' : '\n'
35
+ }
36
+
37
+ /**
38
+ * Split text into lines while keeping each line's own ending.
39
+ *
40
+ * Rejoining the result must reproduce the input byte for byte; a final line
41
+ * without a newline therefore stays without one.
42
+ * @param {string} content - the file text.
43
+ * @returns {string[]} lines, each ending in '\n' except possibly the last.
44
+ */
45
+ export function splitKeepEnds(content) {
46
+ const out = []
47
+ let start = 0
48
+ while (start < content.length) {
49
+ const nl = content.indexOf('\n', start)
50
+ if (nl < 0) {
51
+ out.push(content.slice(start))
52
+ break
53
+ }
54
+ out.push(content.slice(start, nl + 1))
55
+ start = nl + 1
56
+ }
57
+ return out
58
+ }
59
+
60
+ /** Strip a line's ending terms before comparing or showing it. */
61
+ function bareLine(line) {
62
+ return line.replace(/[\r\n]+$/, '')
63
+ }
64
+
65
+ /**
66
+ * Reduce an anchor or a line to a canonical comparison form.
67
+ *
68
+ * Indentation drops away, runs of whitespace collapse to one space, and the
69
+ * ends are trimmed, so a model may copy an anchor with or without the layout
70
+ * whitespace around the code.
71
+ * @param {unknown} text - an anchor candidate or a real line.
72
+ * @returns {string} whitespace-collapsed text.
73
+ */
74
+ export function normalizeAnchor(text) {
75
+ return String(text ?? '').replace(/\s+/g, ' ').trim()
76
+ }
77
+
78
+ /**
79
+ * Canonical form that keeps the line's own indentation.
80
+ *
81
+ * A model that copies the indentation it was shown gets a stricter comparison:
82
+ * `if (err) {` and ` if (err) {` are different lines, which is exactly the
83
+ * pair that a nesting mistake confuses. Inner whitespace still collapses.
84
+ * @param {unknown} text - an anchor candidate or a real line.
85
+ * @returns {string} indentation-preserving text.
86
+ */
87
+ export function normalizeIndented(text) {
88
+ const raw = String(text ?? '')
89
+ const lead = raw.match(/^\s+/)?.[0] ?? ''
90
+ const rest = raw.slice(lead.length).replace(/\s+/g, ' ')
91
+ return `${lead}${rest}`.replace(/\s+$/, '')
92
+ }
93
+
94
+ /** Anchors that start with whitespace are compared with indentation kept. */
95
+ function keepsIndent(given) {
96
+ return /^\s/.test(String(given ?? ''))
97
+ }
98
+
99
+ /**
100
+ * The anchor a line offers at its start: its first `ANCHOR_CHARS` characters.
101
+ *
102
+ * Whitespace is collapsed and the leading indentation dropped, so this is the
103
+ * *shortest* anchor the contract accepts — a model that copies the read display
104
+ * including indentation sends a stricter (indented) one. Used to build anchors
105
+ * in tests and by callers that need the canonical short form; never for matching.
106
+ * @param {string} line - the line without its ending.
107
+ * @returns {string} the start anchor.
108
+ */
109
+ export function startAnchorOf(line) {
110
+ return normalizeAnchor(line).slice(0, ANCHOR_CHARS)
111
+ }
112
+
113
+ /**
114
+ * The anchor a line offers at its end: its last `ANCHOR_CHARS` characters.
115
+ *
116
+ * Whitespace is collapsed the same way; the whitespace itself is not part of a
117
+ * comparable end anchor.
118
+ * @param {string} line - the line without its ending.
119
+ * @returns {string} the end anchor.
120
+ */
121
+ export function endAnchorOf(line) {
122
+ return normalizeAnchor(line).slice(-ANCHOR_CHARS)
123
+ }
124
+
125
+ /**
126
+ * Does a model-supplied anchor identify this line's start?
127
+ *
128
+ * An empty anchor means "this line is blank". A start anchor is a slice of the
129
+ * line's first characters, so leading whitespace in it *is* the indentation:
130
+ * when the model carries it, the comparison keeps indentation and `if (err) {`
131
+ * stops matching ` if (err) {`. A copy without the indentation still matches
132
+ * the collapsed form — the length contract is enforced by `applyPatch`.
133
+ * @param {string} line - the line without its ending.
134
+ * @param {string} given - the anchor the model sent.
135
+ * @returns {boolean} true when the anchor identifies the line.
136
+ */
137
+ export function matchesStart(line, given) {
138
+ const strict = keepsIndent(given)
139
+ const actual = strict ? normalizeIndented(line) : normalizeAnchor(line)
140
+ const want = strict ? normalizeIndented(given) : normalizeAnchor(given)
141
+ if (want.length === 0) return actual.length === 0
142
+ return actual.startsWith(want)
143
+ }
144
+
145
+ /**
146
+ * Does a model-supplied anchor identify this line's end?
147
+ *
148
+ * An end anchor is a slice of the line's *last* characters, so whitespace at
149
+ * its edge is content, not indentation; both sides are compared collapsed.
150
+ * @param {string} line - the line without its ending.
151
+ * @param {string} given - the anchor the model sent.
152
+ * @returns {boolean} true when the anchor identifies the line.
153
+ */
154
+ export function matchesEnd(line, given) {
155
+ const actual = normalizeAnchor(line)
156
+ const want = normalizeAnchor(given)
157
+ if (want.length === 0) return actual.length === 0
158
+ return actual.endsWith(want)
159
+ }
160
+
161
+ /**
162
+ * Is the anchor as long as the contract requires?
163
+ *
164
+ * The model is told to copy a whole anchor (16 characters, or the whole line
165
+ * when it is shorter). A shorter copy still identifies a line by prefix, which
166
+ * would quietly turn a 16-character check into a two-character one.
167
+ * @param {string} line - the line the anchor should identify.
168
+ * @param {string} given - the anchor the model sent.
169
+ * @returns {boolean} true when the anchor is shorter than the contract allows.
170
+ */
171
+ export function anchorTooShort(line, given) {
172
+ const raw = String(given ?? '')
173
+ if (raw.length >= ANCHOR_CHARS) return false
174
+ if (normalizeAnchor(given).length === 0) return false
175
+ return normalizeAnchor(given) !== normalizeAnchor(line)
176
+ }
177
+
178
+ /** Render an anchor for an error message, naming the blank case. */
179
+ function shown(anchor) {
180
+ return anchor.length === 0 ? '(blank)' : JSON.stringify(anchor)
181
+ }
182
+
183
+ /**
184
+ * Longest widening this plugin will suggest.
185
+ *
186
+ * The read renderer truncates displayed lines at 2,000 characters, so a longer
187
+ * anchor is text the model never saw and cannot usefully copy back.
188
+ */
189
+ const SUGGESTION_CHARS = 2000
190
+
191
+ /** Every 1-based line number whose text matches the anchor on that boundary. */
192
+ function matchingLines(lines, given, boundary) {
193
+ const found = []
194
+ for (let index = 0; index < lines.length; index += 1) {
195
+ const line = bareLine(lines[index])
196
+ const matched = boundary === 'start' ? matchesStart(line, given) : matchesEnd(line, given)
197
+ if (matched) found.push(index + 1)
198
+ }
199
+ return found
200
+ }
201
+
202
+ /** Render a short line-number list for an error message. */
203
+ function describeLines(numbers) {
204
+ const head = numbers.slice(0, 8).join(', ')
205
+ return numbers.length > 8 ? `lines ${head}, …` : `lines ${head}`
206
+ }
207
+
208
+ /**
209
+ * The shortest widening of `given` that matches exactly one line, or null.
210
+ *
211
+ * Only the lines that already matched the short anchor can ever match a longer
212
+ * one, so the search walks those (usually two or three) instead of the whole
213
+ * file — otherwise a pair of long generated lines would cost O(file × length).
214
+ * The search stops at `SUGGESTION_CHARS`: past the read display's own truncation
215
+ * the model has never seen the characters, so a longer suggestion cannot be
216
+ * copied back and the edit is left to the line number instead.
217
+ * @param {string[]} lines - the file's lines, endings included.
218
+ * @param {number[]} matches - 1-based lines the short anchor already matched.
219
+ * @param {string} full - the named line's normalized text.
220
+ * @param {string} given - the anchor the model sent.
221
+ * @param {'start'|'end'} boundary - which end of the line the anchor covers.
222
+ * @returns {string|null} a unique anchor, or null when none is worth suggesting.
223
+ */
224
+ function uniqueWidening(lines, matches, full, given, boundary) {
225
+ const from = Math.max(normalizeAnchor(given).length, ANCHOR_CHARS) + 1
226
+ const limit = Math.min(full.length, SUGGESTION_CHARS)
227
+ for (let length = from; length <= limit; length += 1) {
228
+ const candidate = boundary === 'start' ? full.slice(0, length) : full.slice(-length)
229
+ let hits = 0
230
+ for (const number of matches) {
231
+ const line = bareLine(lines[number - 1])
232
+ const matched = boundary === 'start' ? matchesStart(line, candidate) : matchesEnd(line, candidate)
233
+ if (matched) hits += 1
234
+ if (hits > 1) break
235
+ }
236
+ if (hits === 1) return candidate
237
+ }
238
+ return null
239
+ }
240
+
241
+ /**
242
+ * Would this anchor silently point at more than one line, and can widening fix it?
243
+ *
244
+ * Two lines sharing their first (or last) 16 characters are indistinguishable
245
+ * from those two slices alone; if the line is longer, the model can copy more
246
+ * of it and be exact, so that is worth one corrective turn. When even the whole
247
+ * line repeats, no anchor can disambiguate it — the line number is the only
248
+ * signal, and rejecting would block edits that are in fact unambiguous.
249
+ * @param {string[]} lines - the file's lines, endings included.
250
+ * @param {number} lineNumber - the line the model named.
251
+ * @param {string} given - the anchor the model sent.
252
+ * @param {'start'|'end'} boundary - which end of the line the anchor covers.
253
+ * @returns {{matches: number[], suggestion: string}|null} the ambiguity, or null.
254
+ */
255
+ function ambiguousAnchor(lines, lineNumber, given, boundary) {
256
+ const matches = matchingLines(lines, given, boundary)
257
+ if (matches.length <= 1) return null
258
+ const full = normalizeAnchor(bareLine(lines[lineNumber - 1]))
259
+ if (full.length <= normalizeAnchor(given).length) return null
260
+ const suggestion = uniqueWidening(lines, matches, full, given, boundary)
261
+ // No widening separates them (repeated whole line): the line number is the only
262
+ // signal, and rejecting would block edits that are in fact unambiguous.
263
+ if (suggestion === null) return null
264
+ return { matches, suggestion }
265
+ }
266
+
267
+ /** A non-empty anchor that carries no characters at all. */
268
+ function isWhitespaceOnly(anchor) {
269
+ const raw = String(anchor ?? '')
270
+ return raw.length > 0 && raw.trim().length === 0
271
+ }
272
+
273
+ /** Normalise every line ending in a replacement to the file's own style. */
274
+ function normalizeEol(text, eol) {
275
+ const unified = text.replace(/\r\n|[\r\n]/g, '\n')
276
+ return eol === '\n' ? unified : unified.replace(/\n/g, eol)
277
+ }
278
+
279
+ /** Count the lines a replacement text occupies. */
280
+ function countLines(text) {
281
+ if (text.length === 0) return 0
282
+ const parts = text.split('\n')
283
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
284
+ return parts.length
285
+ }
286
+
287
+ /** Reject an argument the tool cannot act on, instead of throwing later. */
288
+ function badArgument(message) {
289
+ return { ok: false, code: 'BAD_ARGUMENT', message }
290
+ }
291
+
292
+ /**
293
+ * Apply an anchored line-range replacement.
294
+ *
295
+ * The whole-file hash is checked first: matching it proves the file is exactly
296
+ * the one the model read, so a later anchor mismatch cannot be a stale read —
297
+ * the model fixes the anchor or the line number without rereading.
298
+ * @param {object} input - the guarded edit request.
299
+ * @param {string} input.content - current file text.
300
+ * @param {string} input.expectedFileHash - `file_hash` the model was shown.
301
+ * @param {number} input.lineStart - 1-based first line to replace.
302
+ * @param {string} input.startAnchor - start anchor of that line.
303
+ * @param {number} input.lineEnd - 1-based last line to replace (inclusive).
304
+ * @param {string} input.endAnchor - end anchor of that line.
305
+ * @param {string} input.replace - replacement text; '' deletes the range.
306
+ * @returns {{ok: true, content: string, fileHash: string, lineShift: number, removedLines: number}
307
+ * | {ok: false, code: string, message: string}}
308
+ */
309
+ export function applyPatch(input) {
310
+ const { content, expectedFileHash, lineStart, startAnchor, lineEnd, endAnchor, replace } = input
311
+
312
+ if (!Number.isInteger(lineStart) || !Number.isInteger(lineEnd)) {
313
+ return badArgument(
314
+ `line_start and line_end must be whole line numbers (got ${String(lineStart)} and ${String(lineEnd)}).`,
315
+ )
316
+ }
317
+ if (typeof replace !== 'string') {
318
+ return badArgument(`replace must be a string (got ${typeof replace}); pass "" to delete the range.`)
319
+ }
320
+ // A hash of the wrong shape is a different mistake from a stale file: saying
321
+ // "the file changed" would send the model to reread for nothing.
322
+ if (typeof expectedFileHash !== 'string' || !new RegExp(`^[0-9a-f]{${FILE_HASH_CHARS}}$`).test(expectedFileHash)) {
323
+ return badArgument(
324
+ `file_hash must be the ${FILE_HASH_CHARS}-character lowercase hex hash printed on the line under a read/write/edit result ` +
325
+ `(got ${JSON.stringify(expectedFileHash)}). If you were never shown such a line, this plugin's annotation is not active — use edit or write instead.`,
326
+ )
327
+ }
328
+
329
+ const actualFileHash = fileHash(content)
330
+ if (expectedFileHash !== actualFileHash) {
331
+ return {
332
+ ok: false,
333
+ code: 'FILE_HASH_MISMATCH',
334
+ message: `file_hash mismatch: expected ${expectedFileHash}, actual ${actualFileHash} — the file changed since it was read; read it again.`,
335
+ }
336
+ }
337
+
338
+ const lines = splitKeepEnds(content)
339
+ const total = lines.length
340
+ if (total === 0) {
341
+ return {
342
+ ok: false,
343
+ code: 'RANGE_INVALID',
344
+ message: 'the file is empty, so there is no line to patch — use the write tool to add content instead.',
345
+ }
346
+ }
347
+ if (lineStart < 1 || lineStart > total) {
348
+ return {
349
+ ok: false,
350
+ code: 'RANGE_INVALID',
351
+ message: `line_start ${lineStart} is out of range (file has ${total} lines). ` +
352
+ 'The file itself is unchanged — fix the line number and retry; do not reread the file.',
353
+ }
354
+ }
355
+ if (lineEnd < lineStart || lineEnd > total) {
356
+ return {
357
+ ok: false,
358
+ code: 'RANGE_INVALID',
359
+ message: `line_end ${lineEnd} is out of range (line_start ${lineStart}, file has ${total} lines). ` +
360
+ 'The file itself is unchanged — fix the line number and retry; do not reread the file.',
361
+ }
362
+ }
363
+
364
+ const startLine = bareLine(lines[lineStart - 1])
365
+ const startWant = normalizeAnchor(startAnchor)
366
+ if (isWhitespaceOnly(startAnchor)) {
367
+ const blankLine = normalizeAnchor(startLine).length === 0
368
+ return {
369
+ ok: false,
370
+ code: 'ANCHOR_TOO_SHORT',
371
+ message: blankLine
372
+ ? `start_anchor is only whitespace and line ${lineStart} is blank — pass "" to address a blank line. Do not reread the file.`
373
+ : `start_anchor is only whitespace — line ${lineStart} is indented past ${ANCHOR_CHARS} characters. ` +
374
+ `Copy ${ANCHOR_CHARS} characters starting at its first non-blank character (or the whole line). Do not reread the file.`,
375
+ }
376
+ }
377
+ if (anchorTooShort(startLine, startAnchor)) {
378
+ return {
379
+ ok: false,
380
+ code: 'ANCHOR_TOO_SHORT',
381
+ message: `start_anchor ${shown(startWant)} is too short to identify a line — copy the first ${ANCHOR_CHARS} ` +
382
+ `characters of line ${lineStart}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
383
+ }
384
+ }
385
+ if (!matchesStart(startLine, startAnchor)) {
386
+ return {
387
+ ok: false,
388
+ code: 'LINE_ANCHOR_MISMATCH',
389
+ message: `start_anchor ${shown(String(startAnchor ?? ''))} does not match line ${lineStart}, ` +
390
+ `which starts with ${shown(startLine.slice(0, ANCHOR_CHARS))}. ` +
391
+ 'Copy it exactly, including the indentation; then fix start_anchor or line_start and retry — ' +
392
+ 'do not reread the file (file_hash still matches).',
393
+ }
394
+ }
395
+ const startAmbiguity = ambiguousAnchor(lines, lineStart, startAnchor, 'start')
396
+ if (startAmbiguity) {
397
+ return {
398
+ ok: false,
399
+ code: 'ANCHOR_AMBIGUOUS',
400
+ message: `start_anchor ${shown(startWant)} matches ${describeLines(startAmbiguity.matches)} — ` +
401
+ `line ${lineStart} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
402
+ `for example ${JSON.stringify(startAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
403
+ }
404
+ }
405
+
406
+ const endLine = bareLine(lines[lineEnd - 1])
407
+ const endWant = normalizeAnchor(endAnchor)
408
+ if (isWhitespaceOnly(endAnchor)) {
409
+ const blankLine = normalizeAnchor(endLine).length === 0
410
+ return {
411
+ ok: false,
412
+ code: 'ANCHOR_TOO_SHORT',
413
+ message: blankLine
414
+ ? `end_anchor is only whitespace and line ${lineEnd} is blank — pass "" to address a blank line. Do not reread the file.`
415
+ : `end_anchor is only whitespace — line ${lineEnd} is padded with trailing whitespace. ` +
416
+ `Copy the last ${ANCHOR_CHARS} characters before that padding (or the whole line). Do not reread the file.`,
417
+ }
418
+ }
419
+ if (anchorTooShort(endLine, endAnchor)) {
420
+ return {
421
+ ok: false,
422
+ code: 'ANCHOR_TOO_SHORT',
423
+ message: `end_anchor ${shown(endWant)} is too short to identify a line — copy the last ${ANCHOR_CHARS} ` +
424
+ `characters of line ${lineEnd}'s text (the part after the "N: " prefix in the read result; the whole line text when it is shorter). Do not reread the file.`,
425
+ }
426
+ }
427
+ // A single-line range carries both anchors on one physical line, so a model
428
+ // that copied the head into both slots is right, not wrong.
429
+ const endOk = lineStart === lineEnd
430
+ ? matchesEnd(endLine, endAnchor) || matchesStart(endLine, endAnchor)
431
+ : matchesEnd(endLine, endAnchor)
432
+ if (!endOk) {
433
+ return {
434
+ ok: false,
435
+ code: 'LINE_ANCHOR_MISMATCH',
436
+ message: `end_anchor ${shown(String(endAnchor ?? ''))} does not match line ${lineEnd}, ` +
437
+ `which ends with ${shown(endAnchorOf(endLine))}. ` +
438
+ 'Fix end_anchor or line_end and retry; do not reread the file (file_hash still matches).',
439
+ }
440
+ }
441
+ const endAmbiguity = ambiguousAnchor(lines, lineEnd, endAnchor, 'end')
442
+ if (endAmbiguity) {
443
+ return {
444
+ ok: false,
445
+ code: 'ANCHOR_AMBIGUOUS',
446
+ message: `end_anchor ${shown(endWant)} matches ${describeLines(endAmbiguity.matches)} — ` +
447
+ `line ${lineEnd} is longer than ${ANCHOR_CHARS} characters, so copy more of it, ` +
448
+ `for example ${JSON.stringify(endAmbiguity.suggestion)}. Do not reread the file (file_hash still matches).`,
449
+ }
450
+ }
451
+
452
+ const eol = detectEol(content)
453
+ const hadTrailingNewline = content.endsWith('\n')
454
+ let body = normalizeEol(replace, eol)
455
+ if (body.length > 0 && !body.endsWith('\n')) body += eol
456
+
457
+ const before = lines.slice(0, lineStart - 1)
458
+ const after = lines.slice(lineEnd)
459
+ if (after.length === 0 && !hadTrailingNewline) {
460
+ // Keep the "no trailing newline" shape, but never strip away the only line
461
+ // a `replace` of just a line terminator asked to insert.
462
+ const stripped = body.replace(/\r?\n$/, '')
463
+ if (stripped.length > 0 && countLines(stripped) === countLines(body)) body = stripped
464
+ }
465
+
466
+ const next = before.join('') + body + after.join('')
467
+ const removedLines = lineEnd - lineStart + 1
468
+ return {
469
+ ok: true,
470
+ content: next,
471
+ fileHash: fileHash(next),
472
+ lineShift: countLines(body) - removedLines,
473
+ removedLines,
474
+ }
475
+ }
package/lib/index.js ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * dsh-line-patch — anchored file editing for DeepSeek Harness.
3
+ *
4
+ * The plugin adds no read tool. Instead every `read`, `write` and `edit`
5
+ * result is annotated with a short `file_hash` line, and one new `patch` tool
6
+ * replaces a line range only when that hash and both boundary anchors still
7
+ * match. A model therefore keeps using the filesystem tools it already knows.
8
+ *
9
+ * Nothing here touches `node:fs`: reads and writes go through `ctx.fs` so the
10
+ * mounted filesystem (sandbox, remote, policy gate) stays authoritative.
11
+ */
12
+ import { ANCHOR_CHARS, FILE_HASH_CHARS, applyPatch, fileHash } from './core.js'
13
+
14
+ /** Plugin name used by loader diagnostics. */
15
+ export const name = 'line-patch'
16
+
17
+ /**
18
+ * `defineTool` types and validates arguments in the host. When the package is
19
+ * absent — a plain `node --test` checkout — the identity fallback keeps the
20
+ * module loadable so the pure logic and tool glue stay testable offline.
21
+ */
22
+ const { defineTool } = await import('@deepseek-ai/dsh-tools')
23
+ .catch(() => {
24
+ console.warn('[line-patch] @deepseek-ai/dsh-tools not resolvable; running without argument validation')
25
+ return { defineTool: definition => definition }
26
+ })
27
+
28
+ /** The tool registry and the filesystem service must be ready before `patch` registers. */
29
+ export const inject = ['tools', 'fs']
30
+
31
+ /** Built-in tools whose result carries the file hash. */
32
+ const ANNOTATED_TOOLS = new Set(['read', 'write', 'edit'])
33
+
34
+ /** Largest file this plugin will read to compute a hash. */
35
+ const MAX_FILE_BYTES = 5 * 1024 * 1024
36
+
37
+ /** How many user turns an anchor stays eligible for revalidation. */
38
+ const ANCHOR_TURNS = 3
39
+
40
+ /** Cap on tracked anchors so a read-heavy turn cannot grow the registry without bound. */
41
+ const MAX_ANCHORS = 32
42
+
43
+ const PATCH_DESCRIPTION = [
44
+ 'Replace an inclusive line range after verifying the whole file and both boundary lines.',
45
+ `file_hash is the ${FILE_HASH_CHARS}-character hash printed under the most recent read/write/edit result for this file, or under a previous patch result.`,
46
+ `start_anchor is the first ${ANCHOR_CHARS} characters of line_start's text — the part after the "N: " prefix a read result prints — and end_anchor the last ${ANCHOR_CHARS} characters of line_end's text. Use the whole line text when it is shorter than ${ANCHOR_CHARS}; never include the "N: " prefix.`,
47
+ 'Keep the indentation in start_anchor: one that carries it is compared strictly, which separates nested lines that look alike. Whitespace at either edge is otherwise collapsed.',
48
+ 'replace is the new text; pass an empty string to delete the range.',
49
+ 'If file_hash matches but an anchor does not, fix the anchor or the line number and retry without rereading the file.',
50
+ 'If no read/write/edit result showed a file_hash line (a >5 MB file, or run_code/PTC mode where tool text is not visible), this tool cannot be used — fall back to edit or write.',
51
+ ].join(' ')
52
+
53
+ /**
54
+ * Resolve the filesystem service or explain why the plugin cannot run.
55
+ * @param {object} ctx - the plugin context.
56
+ * @returns {object} the `ctx.fs` service.
57
+ */
58
+ function requireFs(ctx) {
59
+ const fs = ctx?.fs
60
+ if (!fs || typeof fs.resolve !== 'function' || typeof fs.readText !== 'function') {
61
+ throw new Error('line-patch requires the ctx.fs service; mount @deepseek-ai/dsh-fs-local (or another provider).')
62
+ }
63
+ return fs
64
+ }
65
+
66
+ /**
67
+ * The working directory the tool ran in, when the host can provide one.
68
+ *
69
+ * Built-in fs tools resolve relative paths against the agent's working
70
+ * directory; without it `fs.resolve` falls back to the provider default and a
71
+ * relative path would hash — and later patch — a different file.
72
+ * @param {object} ctx - the plugin context.
73
+ * @param {object} exec - the tool execution being finalized.
74
+ * @returns {Promise<string|undefined>} the working directory, when known.
75
+ */
76
+ async function workingDirectoryOf(ctx, exec) {
77
+ // The built-ins skip the lookup entirely for an agentless call; doing the same
78
+ // avoids a pointless throw on every root-level tool dispatch.
79
+ if (exec?.agent === undefined) return undefined
80
+ try {
81
+ const service = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : ctx.workingDirectory
82
+ if (service && typeof service.ensure === 'function') return await service.ensure(exec?.agent, exec?.signal)
83
+ } catch {
84
+ // No working-directory service, or the step was aborted: fall back to the provider default.
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ /**
90
+ * Read a file that fits the plugin's single-call budget, resolved the same way
91
+ * the built-in fs tools resolve it.
92
+ * @param {object} ctx - the plugin context.
93
+ * @param {string} filePath - model-supplied path.
94
+ * @param {object} [exec] - the tool execution carrying the agent and signal.
95
+ * @returns {Promise<{target: object, content: string, version: string}>} the read result.
96
+ */
97
+ async function readWholeFile(ctx, filePath, exec) {
98
+ const fs = requireFs(ctx)
99
+ const signal = exec?.signal
100
+ const cwd = await workingDirectoryOf(ctx, exec)
101
+ const target = await fs.resolve(filePath, cwd === undefined ? { signal } : { cwd, signal })
102
+ const info = await fs.stat(target, signal)
103
+ if (!info) throw new Error(`file not found: ${filePath}`)
104
+ if (info.type !== 'file') throw new Error(`not a regular file: ${filePath}`)
105
+ if (typeof info.size === 'number' && info.size > MAX_FILE_BYTES) {
106
+ throw new Error(`file is larger than the ${MAX_FILE_BYTES} byte limit: ${filePath}`)
107
+ }
108
+ const content = await fs.readText(target, signal)
109
+ return { target, content, version: info.version }
110
+ }
111
+
112
+ /**
113
+ * Record the model-facing anchor so later turns can revalidate it.
114
+ * @param {object} registry - the plugin's anchor registry.
115
+ * @param {object} entry - anchor facts.
116
+ */
117
+ function rememberAnchor(registry, entry) {
118
+ const key = entry.targetKey ?? entry.path
119
+ registry.anchors.set(key, entry)
120
+ registry.order.push(key)
121
+ while (registry.order.length > MAX_ANCHORS) {
122
+ const dropped = registry.order.shift()
123
+ if (dropped !== undefined && dropped !== key) registry.anchors.delete(dropped)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Replace the tool result that carried a now-void anchor with a notice.
129
+ *
130
+ * The surface is append-only, so "editing history" means appending a
131
+ * replacement event that shadows the original sequence.
132
+ * @param {object} registry - the plugin's anchor registry.
133
+ * @param {object} anchor - the anchor whose read result is stale.
134
+ * @param {string} freshHash - the file hash as it is now.
135
+ */
136
+ function replaceStaleRead(registry, anchor, freshHash) {
137
+ const session = anchor.session ?? registry.session
138
+ if (!session || typeof session.append !== 'function' || typeof anchor.seq !== 'number') return
139
+ const event = session.eventAt?.(anchor.seq)
140
+ const data = event?.data
141
+ if (!data || !data.message) return
142
+ const text = `[line-patch] ${anchor.displayPath} was modified after it was read ` +
143
+ `(file_hash ${anchor.fileHash} -> ${freshHash}). The content and line numbers above are void; ` +
144
+ 'read it again before patching.'
145
+ try {
146
+ session.append(
147
+ 'tool/result',
148
+ { ...data, message: { ...data.message, content: [{ type: 'text', text }] } },
149
+ { surfaceOp: { op: 'replace', startSeq: anchor.seq, endSeq: anchor.seq }, sourceEventSeqs: [anchor.seq] },
150
+ )
151
+ } catch {
152
+ // A already-shadowed or foreign sequence is not ours to rewrite; the patch-time hashes still protect.
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Re-check every live anchor against the file it came from.
158
+ *
159
+ * `stat` is the cheap prefilter: an unchanged stat version means nothing moved
160
+ * and no read happens. A changed version then splits into the two cases that
161
+ * matter — same content (refresh bookkeeping, keep the model's anchors) and
162
+ * different content (void the old result).
163
+ * @param {object} ctx - the plugin context.
164
+ * @param {object} registry - the plugin's anchor registry.
165
+ * @param {object} actor - the request's step context, used as the observation owner.
166
+ */
167
+ async function revalidateAnchors(ctx, registry, actor) {
168
+ const fs = ctx?.fs
169
+ if (!fs || registry.anchors.size === 0) return
170
+ for (const [key, anchor] of [...registry.anchors]) {
171
+ if (registry.turn - anchor.turn > ANCHOR_TURNS) {
172
+ registry.anchors.delete(key)
173
+ continue
174
+ }
175
+ const target = anchor.target ?? { targetKey: key }
176
+ let info
177
+ try {
178
+ info = await fs.stat(target)
179
+ } catch {
180
+ continue
181
+ }
182
+ if (!info) {
183
+ registry.anchors.delete(key)
184
+ continue
185
+ }
186
+ if (info.version === anchor.version) continue
187
+ let content
188
+ try {
189
+ content = await fs.readText(target)
190
+ } catch {
191
+ continue
192
+ }
193
+ const fresh = fileHash(content)
194
+ if (fresh === anchor.fileHash) {
195
+ // Byte-identical: a rebuild or a touch moved the stat, not the content.
196
+ anchor.version = info.version
197
+ try {
198
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, actor)
199
+ } catch {
200
+ // no observation gate mounted
201
+ }
202
+ continue
203
+ }
204
+ replaceStaleRead(registry, anchor, fresh)
205
+ registry.anchors.delete(key)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * What this result should carry, or null when it should carry nothing.
211
+ *
212
+ * Runs before the rest of the `tools/post-execute` chain. Reading the file
213
+ * again here is what makes the hash describe the file itself rather than the
214
+ * tool's (possibly partial) view. Registering the anchor is left to the caller,
215
+ * because a blocked result must not become a tracked read.
216
+ * @param {object} ctx - the plugin context.
217
+ * @param {object} registry - the plugin's anchor registry.
218
+ * @param {object} exec - the tool execution being finalized.
219
+ * @param {object} result - its outcome.
220
+ * @returns {Promise<{line: string, anchor: object|null}|null>} the annotation.
221
+ */
222
+ async function annotationFor(ctx, registry, exec, result) {
223
+ const toolName = exec?.name
224
+ if (!ANNOTATED_TOOLS.has(toolName) || result?.isError) return null
225
+ if (!Array.isArray(result.content) || !result.content.some(block => block?.type === 'text')) return null
226
+ const filePath = exec.arguments?.file_path
227
+ if (typeof filePath !== 'string') return null
228
+
229
+ let read
230
+ try {
231
+ read = await readWholeFile(ctx, filePath, exec)
232
+ } catch {
233
+ // A path this plugin cannot hash (binary view, oversized file, vanished) simply gets no hash.
234
+ return null
235
+ }
236
+ // The hook re-reads the file after the tool did. If the stat version moved in
237
+ // between, the hash would attest content the model never saw — annotate
238
+ // nothing and let the next read produce a fresh pair.
239
+ const observedKey = read.target.targetKey ?? read.target.key
240
+ if (observedKey !== undefined && registry.observed.has(observedKey)
241
+ && registry.observed.get(observedKey) !== read.version) {
242
+ return null
243
+ }
244
+ const hash = fileHash(read.content)
245
+ const anchor = toolName === 'read'
246
+ ? {
247
+ targetKey: read.target.targetKey ?? read.target.key ?? filePath,
248
+ displayPath: filePath,
249
+ fileHash: hash,
250
+ version: read.version,
251
+ turn: registry.turn,
252
+ target: read.target,
253
+ session: registry.session,
254
+ callId: exec.callId,
255
+ }
256
+ : null
257
+ return { line: `file_hash: ${hash}`, anchor }
258
+ }
259
+
260
+ /** Does this content already end with a hash line (a second loaded instance)? */
261
+ function alreadyAnnotated(content) {
262
+ const last = Array.isArray(content) ? content.at(-1) : undefined
263
+ if (last?.type !== 'text' || typeof last.text !== 'string') return false
264
+ return /(?:^|\n)file_hash: [0-9a-f]+$/.test(last.text)
265
+ }
266
+
267
+ /**
268
+ * Append the hash line *inside* the last text block.
269
+ *
270
+ * A result with two text blocks breaks plugins that annotate single-block
271
+ * results (dsh-context-archive requires `content.length === 1`), so the new
272
+ * line joins the existing block instead of becoming a block of its own.
273
+ * @param {object[]} content - the result content blocks.
274
+ * @param {string} line - the annotation line.
275
+ * @returns {object[]} the content blocks to return.
276
+ */
277
+ function appendAnnotation(content, line) {
278
+ const blocks = Array.isArray(content) ? [...content] : []
279
+ const last = blocks.at(-1)
280
+ if (last?.type === 'text' && typeof last.text === 'string') {
281
+ const separator = last.text.endsWith('\n') ? '' : '\n'
282
+ blocks[blocks.length - 1] = { ...last, text: `${last.text}${separator}${line}` }
283
+ return blocks
284
+ }
285
+ return [...blocks, { type: 'text', text: line }]
286
+ }
287
+
288
+ /**
289
+ * Register the `patch` tool plus the hash annotation and anchor bookkeeping.
290
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
291
+ */
292
+ export function apply(ctx) {
293
+ const registry = { anchors: new Map(), order: [], turn: 0, session: undefined, observed: new Map() }
294
+
295
+ /** Record an observation so `fs/write-intent` sees this file as read. */
296
+ const observe = (target, version, actor) => {
297
+ try {
298
+ ctx.emit('fs/observed', target, { kind: 'present', version }, actor)
299
+ } catch {
300
+ // The gate is optional; a bare provider simply has no listener.
301
+ }
302
+ }
303
+
304
+ // A tool-mediated write refreshes its own observation, so the anchor it
305
+ // invalidates must not later read back as an "external" change. The same
306
+ // table tells the annotator whether the file moved between the tool's own
307
+ // observation and the hook's verifying read.
308
+ try {
309
+ ctx.on('fs/observed', (target, observation) => {
310
+ if (!target || observation?.kind !== 'present') return
311
+ const key = target.targetKey ?? target.key
312
+ if (key !== undefined) {
313
+ registry.observed.set(key, observation.version)
314
+ while (registry.observed.size > MAX_ANCHORS * 2) {
315
+ const oldest = registry.observed.keys().next().value
316
+ registry.observed.delete(oldest)
317
+ }
318
+ }
319
+ const anchor = registry.anchors.get(key)
320
+ if (anchor && anchor.version !== observation.version) registry.anchors.delete(key)
321
+ })
322
+ } catch {
323
+ // Hosts without the observation gate simply skip this bookkeeping.
324
+ }
325
+
326
+ // Every read/write/edit result is annotated here; `read` also registers the
327
+ // anchor that the per-request revalidation later checks.
328
+ try {
329
+ ctx.on('tools/post-execute', (...args) => {
330
+ const next = typeof args.at(-1) === 'function' ? args.at(-1) : undefined
331
+ const [exec, result] = args
332
+ // Cordis waterfall: `next()` takes no arguments and returns the downstream
333
+ // decision; the outermost listener's RETURN VALUE is what the host applies.
334
+ return annotationFor(ctx, registry, exec, result)
335
+ .catch(() => null)
336
+ .then(async annotation => {
337
+ const downstream = next ? await next() : { kind: 'accept' }
338
+ // Never argue with a block, a value replacement (the host forbids value
339
+ // and content together), or a result that already carries a hash.
340
+ if (!annotation || !downstream || downstream.kind !== 'accept') return downstream
341
+ if (Object.hasOwn(downstream, 'value')) return downstream
342
+ const base = Array.isArray(downstream.content) ? downstream.content : result.content ?? []
343
+ if (alreadyAnnotated(base)) return downstream
344
+ // Only an accepted read becomes a tracked anchor; a blocked one must not
345
+ // later be rewritten into a "file changed" notice.
346
+ if (annotation.anchor) rememberAnchor(registry, annotation.anchor)
347
+ return { ...downstream, content: appendAnnotation(base, annotation.line) }
348
+ })
349
+ })
350
+ } catch {
351
+ // Without the waterfall the model simply sees no file_hash; `patch` is then unusable, not wrong.
352
+ }
353
+
354
+ // One session/event listener carries three facts the revalidation needs: the
355
+ // live session, the user-turn counter, and the sequence of our own results.
356
+ try {
357
+ ctx.on('session/event', (session, event) => {
358
+ if (session) registry.session = session
359
+ if (!event) return
360
+ if (event.type === 'turn/start') {
361
+ registry.turn += 1
362
+ return
363
+ }
364
+ if (event.type !== 'tool/result') return
365
+ const callId = event.data?.message?.toolCallId
366
+ if (typeof callId !== 'string') return
367
+ for (const anchor of registry.anchors.values()) {
368
+ if (anchor.callId === callId && typeof anchor.seq !== 'number') {
369
+ anchor.seq = event.seq
370
+ anchor.session = session
371
+ }
372
+ }
373
+ })
374
+ } catch {
375
+ // A host without session events still gets a working patch; anchors just never expire by age.
376
+ }
377
+
378
+ // `agent/request` runs with an open step and before the model call: the one
379
+ // place where a surface replacement is legal and still useful.
380
+ try {
381
+ ctx.on('agent/request', (...args) => {
382
+ const next = typeof args.at(-1) === 'function' ? args.at(-1) : undefined
383
+ const stepContext = args[0]
384
+ const settled = revalidateAnchors(ctx, registry, stepContext).catch(() => {})
385
+ return next ? settled.then(() => next()) : settled
386
+ })
387
+ } catch {
388
+ // Without the hook, anchors are only checked at patch time.
389
+ }
390
+
391
+ ctx.tools.register(defineTool({
392
+ name: 'patch',
393
+ description: PATCH_DESCRIPTION,
394
+ parameters: {
395
+ file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
396
+ file_hash: { type: 'string', required: true, description: `The ${FILE_HASH_CHARS}-character hash printed under the latest read/write/edit result for this file, or under a previous patch result.` },
397
+ line_start: { type: 'number', required: true, description: '1-based first line to replace.' },
398
+ start_anchor: { type: 'string', required: true, description: `First ${ANCHOR_CHARS} characters of line_start's text — after the "N: " prefix a read result prints — indentation included (the whole line text when it is shorter).` },
399
+ line_end: { type: 'number', required: true, description: '1-based last line to replace, inclusive. Equal to line_start for one line.' },
400
+ end_anchor: { type: 'string', required: true, description: `Last ${ANCHOR_CHARS} characters of line_end's text — after the "N: " prefix; whitespace at either edge is collapsed before comparing (the whole line text when it is shorter).` },
401
+ replace: { type: 'string', required: true, description: 'Replacement text. An empty string deletes the range.' },
402
+ },
403
+ output: {
404
+ schema: {
405
+ type: 'object',
406
+ additionalProperties: false,
407
+ properties: {
408
+ path: { type: 'string', required: true },
409
+ replaced: { type: 'array', required: true, items: { type: 'number' } },
410
+ line_shift: { type: 'integer', required: true },
411
+ new_file_hash: { type: 'string', required: true },
412
+ },
413
+ },
414
+ render: (_args, value) => [{
415
+ type: 'text',
416
+ text: `patched ${value.path}: lines ${value.replaced[0]}-${value.replaced[1]} replaced ` +
417
+ `(line shift ${value.line_shift >= 0 ? '+' : ''}${value.line_shift}).\n` +
418
+ `file_hash: ${value.new_file_hash}`,
419
+ }],
420
+ presentationMeta: (args, value) => ({ replaced: value.replaced, path: value.path }),
421
+ },
422
+ async execute(args, exec) {
423
+ const fs = requireFs(ctx)
424
+ const { target, content, version } = await readWholeFile(ctx, args.file_path, exec)
425
+ const result = applyPatch({
426
+ content,
427
+ expectedFileHash: args.file_hash,
428
+ lineStart: args.line_start,
429
+ startAnchor: args.start_anchor,
430
+ lineEnd: args.line_end,
431
+ endAnchor: args.end_anchor,
432
+ replace: args.replace,
433
+ })
434
+ if (!result.ok) {
435
+ const error = new Error(result.message)
436
+ error.code = result.code
437
+ throw error
438
+ }
439
+
440
+ // Mirror the built-in write tool: ask the policy gate for the intent, then
441
+ // hand it to the provider so the version CAS (and read-before-write) apply.
442
+ const fallback = { kind: 'replaceIfVersion', version }
443
+ let intent = fallback
444
+ try {
445
+ const decided = await ctx.waterfall('fs/write-intent', target, exec, () => fallback)
446
+ if (decided) intent = decided
447
+ } catch {
448
+ intent = fallback
449
+ }
450
+
451
+ const outcome = await fs.writeText(target, result.content, intent, exec.signal)
452
+ observe(target, outcome?.version ?? version, exec)
453
+
454
+ rememberAnchor(registry, {
455
+ targetKey: target.targetKey ?? target.key ?? String(args.file_path),
456
+ displayPath: String(args.file_path),
457
+ fileHash: result.fileHash,
458
+ version: outcome?.version ?? version,
459
+ turn: registry.turn,
460
+ target,
461
+ // No callId: a patch result is never rewritten, it is only bookkeeping.
462
+ })
463
+
464
+ return {
465
+ path: fs.processPath ? fs.processPath(target) : String(args.file_path),
466
+ replaced: [args.line_start, args.line_end],
467
+ line_shift: result.lineShift,
468
+ new_file_hash: result.fileHash,
469
+ }
470
+ },
471
+ }))
472
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
- {
2
- "name": "dsh-line-patch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "dsh-line-patch",
3
+ "version": "0.3.0",
4
+ "description": "Anchored line patching for DeepSeek Harness: read/write/edit results carry a short file hash, and patch rewrites a line range only when that hash and both boundary-line anchors still match.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/kingofpowers/dsh-line-patch.git"
8
+ },
9
+ "homepage": "https://github.com/kingofpowers/dsh-line-patch#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/kingofpowers/dsh-line-patch/issues"
12
+ },
13
+ "type": "module",
14
+ "main": "./lib/index.js",
15
+ "exports": {
16
+ ".": "./lib/index.js",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "keywords": [
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "dsh",
29
+ "cordis",
30
+ "edit",
31
+ "patch",
32
+ "hash",
33
+ "anchor"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "manifestVersion": 1
40
+ },
41
+ "engines": {
42
+ "dsh": ">=0.2.1-rc.1 <0.3.0",
43
+ "node": ">=20"
44
+ },
45
+ "peerDependencies": {
46
+ "@deepseek-ai/dsh-tools": "*"
47
+ },
48
+ "scripts": {
49
+ "test": "node --test test/*.test.mjs",
50
+ "check": "node --check lib/core.js && node --check lib/index.js"
51
+ },
52
+ "license": "MIT",
53
+ "author": "kingofpowers",
54
+ "peerDependenciesMeta": {
55
+ "@deepseek-ai/dsh-tools": {
56
+ "optional": true
57
+ }
58
+ }
59
+ }