dsh-line-patch 0.0.0-stage → 0.2.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,117 @@
1
- # Temporary Holding Version
1
+ # dsh-line-patch
2
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.
3
+ 按「行号 + 行哈希 + 整文件哈希」三重锚定修改文件的 DSH 插件。给两个模型可见工具:`read_hash` 与 `patch_hash`。
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% 的失败。`patch_hash` 把这部分换成「行号 + 两个 4 字符 hash」,定位费降到个位数 token。
17
+
18
+ 同时它和 DSH 内置的版本守卫**互补而非重复**:
19
+
20
+ | 场景 | 内置 stat 版本 | 本插件 sha256 |
21
+ |---|---|---|
22
+ | 内容改了且 mtime 变了 | 拒绝 ✅ | 拒绝 ✅ |
23
+ | 内容改了但 mtime/size 没变(`cp -p`、备份还原) | **漏判 ❌** | 拒绝 ✅ |
24
+ | 内容没变只是被 touch / 重新构建 | **误伤 ❌** | 放行 ✅ |
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ dsh plugin --profile desktop add /path/to/dsh-line-patch
30
+ ```
31
+
32
+ 改完 `lib/` 后需要重新加载:touch 一下 `~/.dsh/profiles/desktop/cordis.patch.yml` 触发 HMR,或重启宿主。
33
+
34
+ ## 用法
35
+
36
+ ```text
37
+ read_hash { file_path: "app.py" }
38
+ → file_hash: 7bb7a279c235cef2
39
+ 1/f3c2: import os
40
+ 2/7182: def main():
41
+ ...
42
+
43
+ patch_hash {
44
+ file_path: "app.py",
45
+ file_hash: "7bb7a279c235cef2",
46
+ line_start: 2, hash_start: "7182",
47
+ line_end: 3, hash_end: "251e",
48
+ replace: "def run():"
49
+ }
50
+ → patched app.py: lines 2-3 replaced (line shift -1). new file_hash: d119fac30942d216
51
+ ```
52
+
53
+ - `file_hash`、`hash_start`、`hash_end` **三项必填**,缺一项 schema 层就拒绝。
54
+ - `replace: ""` 表示删除该行区间。
55
+ - 替换后返回 `new_file_hash` 与 `line_shift`,连续修改时不必整段重读。
56
+ - CRLF 文件按原风格写回;末行无换行符的状态保持。
57
+
58
+ ## 锚点核实(每轮自动)
59
+
60
+ `read_hash` 发出的每个锚点会登记下来,之后每次模型请求前(`agent/request`)复核一次,只覆盖**最近 3 个用户回合**内的文件:
61
+
62
+ ```
63
+ stat 预筛(微秒级,不读文件)
64
+ ├─ 版本没变 → 什么都不做
65
+ └─ 版本变了 → 算 sha256
66
+ ├─ 内容相同(touch / 重新构建) → 只补发 fs/observed 刷新策略底账,上下文零改动
67
+ └─ 内容真变了 → 把当初那条 read 结果原地替换成「已失效 + 新旧 hash + 请重读」
68
+ ```
69
+
70
+ 第二行是关键:内置的 stat 版本守卫会把「内容没变但 mtime 变了」误判为外部修改,这里用内容哈希纠正它,模型不必因为一次 rebuild 就重读整个文件。
71
+
72
+ 工具自己改过的文件(`patch_hash`、内置 `write`/`edit`)会先通过 `fs/observed` 把对应锚点作废,**不会被当成外部变更回报给模型**。
73
+
74
+ ## 实现要点
75
+
76
+ - 所有读写走 `ctx.fs`,不直接碰 `node:fs`,沙箱与远端后端仍然有效。
77
+ - 写入复刻内置 `write` 的 intent 舞蹈:`fs/write-intent` waterfall 取意图 → `writeText(intent)` → `emit('fs/observed')`。**裸调 `writeText` 会静默绕过读后写门禁与版本 CAS**。
78
+ - 读取时同样 `emit('fs/observed')`,否则策略门禁会把目标当作「没读过」。
79
+
80
+ ## 已验证
81
+
82
+ 在真实宿主(desktop profile)上跑通:
83
+
84
+ - `read_hash` 返回逐行 `行号/hash` 与 `file_hash`
85
+ - `patch_hash` 正常替换、行数位移计算正确
86
+ - 过期 `file_hash` → `file_hash mismatch ... read it again`
87
+ - 错误行 hash → `line N hash mismatch ... the line numbers shifted`
88
+ - `replace: ""` 删除区间,文件内容与行位移正确
89
+ - 写入确实经过 `replaceIfVersion` intent(stub 测试断言)
90
+
91
+ 锚点核实也做了真机验证:读文件 → 用 bash 在 `ctx.fs` 之外改写内容 → 下一次模型请求前,会话日志里出现
92
+
93
+ ```
94
+ tool/result seq=1055 surfaceOp={"op":"replace","startSeq":1046,"endSeq":1046} sources=[1046]
95
+ → "[line-patch] …/line-patch-reval.txt was modified after it was read
96
+ (file_hash 4cdfa7d498707b8f -> 417e4745306cbe22). … call read_hash again before patching."
97
+ ```
98
+
99
+ 即原来的 read 结果(seq 1046)被准确替换。
100
+
101
+ 离线测试:`npm test` — 32 个用例(核心逻辑 17 + 工具胶水 10 + 锚点核实 5),全部通过。
102
+
103
+ ## 尚未实现
104
+
105
+ - `presentCall` / `presentResult` 的 diff 卡片(目前是纯文本渲染)。
106
+ - 只核对「本会话登记过的、3 个用户回合内」的文件;更早的锚点按设计丢弃,不做长期追踪。
107
+
108
+ ## 已知限制
109
+
110
+ - 单文件 5 MB 上限。
111
+ - `read_hash` 单次最多 2000 行。
112
+ - `fs/write-intent` 不可用时退化为自行构造的 `replaceIfVersion`;再不可用则退化为无条件写(此时只剩 sha256 与行 hash 保护)。
113
+ - 若 `@deepseek-ai/dsh-tools` 无法解析,`defineTool` 退化为恒等函数:工具仍可用,但参数校验交给插件自己。
114
+
115
+ ## 许可
116
+
117
+ MIT
@@ -0,0 +1,4 @@
1
+ # Anchored line-range editing: read_hash / patch_hash.
2
+ - insert:
3
+ - id: line-patch
4
+ name: dsh-line-patch
package/lib/core.js ADDED
@@ -0,0 +1,191 @@
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
+ import { createHash } from 'node:crypto'
8
+
9
+ /** Characters kept from a line's md5 — enough to spot a changed boundary line. */
10
+ export const LINE_HASH_CHARS = 4
11
+
12
+ /** Characters kept from the whole-file sha256 — enough to spot a changed file. */
13
+ export const FILE_HASH_CHARS = 16
14
+
15
+ /** Fixed hash a blank line carries, so every line has an anchor. */
16
+ export const EMPTY_LINE_HASH = lineHashOf('')
17
+
18
+ function lineHashOf(text) {
19
+ return createHash('md5').update(text, 'utf8').digest('hex').slice(0, LINE_HASH_CHARS)
20
+ }
21
+
22
+ /**
23
+ * The anchor shown next to one line.
24
+ * @param {string} text - the line with its line ending already stripped.
25
+ * @returns {string} a stable 4-character hash.
26
+ */
27
+ export function lineHash(text) {
28
+ return lineHashOf(text)
29
+ }
30
+
31
+ /**
32
+ * The anchor shown for a whole file.
33
+ * @param {string|Buffer} content - raw file content.
34
+ * @returns {string} a 16-character sha256 prefix.
35
+ */
36
+ export function fileHash(content) {
37
+ return createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_CHARS)
38
+ }
39
+
40
+ /**
41
+ * Detect the line ending a file is written in so replacements keep it.
42
+ * @param {string} content - the file text.
43
+ * @returns {string} '\r\n' or '\n'.
44
+ */
45
+ export function detectEol(content) {
46
+ return content.includes('\r\n') ? '\r\n' : '\n'
47
+ }
48
+
49
+ /**
50
+ * Split text into lines while keeping each line's own ending.
51
+ *
52
+ * Rejoining the result must reproduce the input byte for byte; a final line
53
+ * without a newline therefore stays without one.
54
+ * @param {string} content - the file text.
55
+ * @returns {string[]} lines, each ending in '\n' except possibly the last.
56
+ */
57
+ export function splitKeepEnds(content) {
58
+ const out = []
59
+ let start = 0
60
+ while (start < content.length) {
61
+ const nl = content.indexOf('\n', start)
62
+ if (nl < 0) {
63
+ out.push(content.slice(start))
64
+ break
65
+ }
66
+ out.push(content.slice(start, nl + 1))
67
+ start = nl + 1
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** Strip a line's ending terms before hashing it. */
73
+ function bareLine(line) {
74
+ return line.replace(/[\r\n]+$/, '')
75
+ }
76
+
77
+ /**
78
+ * Render the model-facing view of a line range.
79
+ *
80
+ * Each line is prefixed with `<number>/<hash>: ` so the model never counts
81
+ * lines itself and can copy the hash straight into `patch_hash`.
82
+ * @param {string} content - the text to annotate.
83
+ * @param {number} [startLine] - absolute number for the first line.
84
+ * @returns {string} annotated text.
85
+ */
86
+ export function annotateLines(content, startLine = 1) {
87
+ const lines = splitKeepEnds(content)
88
+ return lines
89
+ .map((line, index) => {
90
+ const text = bareLine(line)
91
+ const hash = text.length === 0 ? EMPTY_LINE_HASH : lineHash(text)
92
+ return `${startLine + index}/${hash}: ${text}\n`
93
+ })
94
+ .join('')
95
+ }
96
+
97
+ /** Normalise every line ending in a replacement to the file's own style. */
98
+ function normalizeEol(text, eol) {
99
+ const unified = text.replace(/\r\n|[\r\n]/g, '\n')
100
+ return eol === '\n' ? unified : unified.replace(/\n/g, eol)
101
+ }
102
+
103
+ /** Count the lines a replacement text occupies. */
104
+ function countLines(text) {
105
+ if (text.length === 0) return 0
106
+ const parts = text.split('\n')
107
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
108
+ return parts.length
109
+ }
110
+
111
+ /**
112
+ * Apply an anchored line-range replacement.
113
+ *
114
+ * All three anchors are checked before anything is written, and the returned
115
+ * text is the complete new file: callers never splice in place themselves.
116
+ * @param {object} input - the guarded edit request.
117
+ * @param {string} input.content - current file text.
118
+ * @param {string} input.expectedFileHash - `file_hash` the model was shown.
119
+ * @param {number} input.lineStart - 1-based first line to replace.
120
+ * @param {string} input.hashStart - hash of that line.
121
+ * @param {number} input.lineEnd - 1-based last line to replace (inclusive).
122
+ * @param {string} input.hashEnd - hash of that line.
123
+ * @param {string} input.replace - replacement text; '' deletes the range.
124
+ * @returns {{ok: true, content: string, fileHash: string, lineShift: number, removedLines: number}
125
+ * | {ok: false, code: string, message: string}}
126
+ */
127
+ export function applyPatch(input) {
128
+ const { content, expectedFileHash, lineStart, hashStart, lineEnd, hashEnd, replace } = input
129
+
130
+ const actualFileHash = fileHash(content)
131
+ if (expectedFileHash !== actualFileHash) {
132
+ return {
133
+ ok: false,
134
+ code: 'FILE_HASH_MISMATCH',
135
+ message: `file_hash mismatch: expected ${expectedFileHash}, actual ${actualFileHash} — the file changed since read_hash; read it again.`,
136
+ }
137
+ }
138
+
139
+ const lines = splitKeepEnds(content)
140
+ const total = lines.length
141
+ if (lineStart < 1 || lineStart > total) {
142
+ return {
143
+ ok: false,
144
+ code: 'RANGE_INVALID',
145
+ message: `line_start ${lineStart} is out of range (file has ${total} lines).`,
146
+ }
147
+ }
148
+ if (lineEnd < lineStart || lineEnd > total) {
149
+ return {
150
+ ok: false,
151
+ code: 'RANGE_INVALID',
152
+ message: `line_end ${lineEnd} is out of range (line_start ${lineStart}, file has ${total} lines).`,
153
+ }
154
+ }
155
+
156
+ const startActual = lineHash(bareLine(lines[lineStart - 1]))
157
+ if (startActual !== hashStart) {
158
+ return {
159
+ ok: false,
160
+ code: 'LINE_HASH_MISMATCH',
161
+ message: `line ${lineStart} hash mismatch: expected ${hashStart}, actual ${startActual} — the line numbers shifted; read the file again.`,
162
+ }
163
+ }
164
+ const endActual = lineHash(bareLine(lines[lineEnd - 1]))
165
+ if (endActual !== hashEnd) {
166
+ return {
167
+ ok: false,
168
+ code: 'LINE_HASH_MISMATCH',
169
+ message: `line ${lineEnd} hash mismatch: expected ${hashEnd}, actual ${endActual} — the line numbers shifted; read the file again.`,
170
+ }
171
+ }
172
+
173
+ const eol = detectEol(content)
174
+ const hadTrailingNewline = content.endsWith('\n')
175
+ let body = normalizeEol(replace, eol)
176
+ if (body.length > 0 && !body.endsWith('\n')) body += eol
177
+
178
+ const before = lines.slice(0, lineStart - 1)
179
+ const after = lines.slice(lineEnd)
180
+ if (after.length === 0 && !hadTrailingNewline) body = body.replace(/\r?\n$/, '')
181
+
182
+ const next = before.join('') + body + after.join('')
183
+ const removedLines = lineEnd - lineStart + 1
184
+ return {
185
+ ok: true,
186
+ content: next,
187
+ fileHash: fileHash(next),
188
+ lineShift: countLines(body) - removedLines,
189
+ removedLines,
190
+ }
191
+ }
package/lib/index.js ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * dsh-line-patch — anchored file editing for DeepSeek Harness.
3
+ *
4
+ * `read_hash` shows every line as `<number>/<hash>: <text>` plus a whole-file
5
+ * hash. `patch_hash` replaces a line range only when the file hash and both
6
+ * boundary-line hashes still match, so a stale read can never silently
7
+ * overwrite newer content.
8
+ *
9
+ * The tools never touch `node:fs`: reads and writes go through `ctx.fs` so the
10
+ * mounted filesystem (sandbox, remote, policy gate) stays authoritative.
11
+ */
12
+ import {
13
+ LINE_HASH_CHARS,
14
+ FILE_HASH_CHARS,
15
+ annotateLines,
16
+ applyPatch,
17
+ fileHash,
18
+ splitKeepEnds,
19
+ } from './core.js'
20
+
21
+ /** Plugin name used by loader diagnostics. */
22
+ export const name = 'line-patch'
23
+
24
+ /**
25
+ * `defineTool` types and validates arguments in the host. When the package is
26
+ * absent — a plain `node --test` checkout — the identity fallback keeps the
27
+ * module loadable so the pure logic and tool glue stay testable offline.
28
+ */
29
+ const { defineTool } = await import('@deepseek-ai/dsh-tools')
30
+ .catch(() => {
31
+ console.warn('[line-patch] @deepseek-ai/dsh-tools not resolvable; running without argument validation')
32
+ return { defineTool: definition => definition }
33
+ })
34
+
35
+ /** The tool registry and the filesystem service must be ready before either tool registers. */
36
+ export const inject = ['tools', 'fs']
37
+
38
+ /** Largest file this plugin will read or rewrite in one call. */
39
+ const MAX_FILE_BYTES = 5 * 1024 * 1024
40
+
41
+ /** Default and maximum lines one `read_hash` call returns. */
42
+ const DEFAULT_READ_LIMIT = 2000
43
+
44
+ /** How many user turns an anchor stays eligible for revalidation. */
45
+ const ANCHOR_TURNS = 3
46
+
47
+ /** Cap on tracked anchors so a read-heavy turn cannot grow the registry without bound. */
48
+ const MAX_ANCHORS = 32
49
+
50
+ const READ_DESCRIPTION = [
51
+ 'Read a UTF-8 text file with per-line and whole-file hashes.',
52
+ 'Every line comes back as `<number>/<hash>: <text>`; the result also carries `file_hash`.',
53
+ 'Copy those values into patch_hash — they are the only anchors it accepts.',
54
+ 'Use this instead of read when you intend to modify the file afterwards.',
55
+ ].join(' ')
56
+
57
+ const PATCH_DESCRIPTION = [
58
+ 'Replace an inclusive line range after verifying the whole file and both boundary lines.',
59
+ 'file_hash, hash_start and hash_end are all required and must come from a read_hash result.',
60
+ 'replace is the new text; pass an empty string to delete the range.',
61
+ 'Cheaper than edit for large rewrites: you send line numbers instead of quoting the old text.',
62
+ ].join(' ')
63
+
64
+ /**
65
+ * Resolve the filesystem service or explain why the tool cannot run.
66
+ * @param {object} ctx - the plugin context.
67
+ * @returns {object} the `ctx.fs` service.
68
+ */
69
+ function requireFs(ctx) {
70
+ const fs = ctx?.fs
71
+ if (!fs || typeof fs.resolve !== 'function' || typeof fs.readText !== 'function') {
72
+ throw new Error('line-patch requires the ctx.fs service; mount @deepseek-ai/dsh-fs-local (or another provider).')
73
+ }
74
+ return fs
75
+ }
76
+
77
+ /**
78
+ * Read a file that fits the plugin's single-call budget.
79
+ * @param {object} ctx - the plugin context.
80
+ * @param {string} filePath - model-supplied path.
81
+ * @param {AbortSignal} [signal] - cancellation.
82
+ * @returns {Promise<{target: object, content: string, version: string, size: number}>} the read result.
83
+ */
84
+ async function readWholeFile(ctx, filePath, signal) {
85
+ const fs = requireFs(ctx)
86
+ const target = await fs.resolve(filePath, { signal })
87
+ const info = await fs.stat(target, signal)
88
+ if (!info) throw new Error(`file not found: ${filePath}`)
89
+ if (info.type !== 'file') throw new Error(`not a regular file: ${filePath}`)
90
+ if (typeof info.size === 'number' && info.size > MAX_FILE_BYTES) {
91
+ throw new Error(`file is larger than the ${MAX_FILE_BYTES} byte limit: ${filePath}`)
92
+ }
93
+ const content = await fs.readText(target, signal)
94
+ return { target, content, version: info.version, size: info.size ?? content.length }
95
+ }
96
+
97
+ /** Wrap a numeric line count into the canonical `total_lines` value. */
98
+ function totalLinesOf(content) {
99
+ return splitKeepEnds(content).length
100
+ }
101
+
102
+ /**
103
+ * Record the model-facing anchor so later turns can revalidate it.
104
+ * @param {object} registry - the plugin's anchor registry.
105
+ * @param {object} entry - anchor facts.
106
+ */
107
+ function rememberAnchor(registry, entry) {
108
+ const key = entry.targetKey ?? entry.path
109
+ registry.anchors.set(key, entry)
110
+ registry.order.push(key)
111
+ while (registry.order.length > MAX_ANCHORS) {
112
+ const dropped = registry.order.shift()
113
+ if (dropped !== undefined && dropped !== key) registry.anchors.delete(dropped)
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Replace the tool result that carried a now-void anchor with a notice.
119
+ *
120
+ * The surface is append-only, so "editing history" means appending a
121
+ * replacement event that shadows the original sequence.
122
+ * @param {object} registry - the plugin's anchor registry.
123
+ * @param {object} anchor - the anchor whose read result is stale.
124
+ * @param {string} freshHash - the file hash as it is now.
125
+ */
126
+ function replaceStaleRead(registry, anchor, freshHash) {
127
+ const session = anchor.session ?? registry.session
128
+ if (!session || typeof session.append !== 'function' || typeof anchor.seq !== 'number') return
129
+ const event = session.eventAt?.(anchor.seq)
130
+ const data = event?.data
131
+ if (!data || !data.message) return
132
+ const text = `[line-patch] ${anchor.displayPath} was modified after it was read ` +
133
+ `(file_hash ${anchor.fileHash} -> ${freshHash}). The content and line numbers above are void; ` +
134
+ 'call read_hash again before patching.'
135
+ try {
136
+ session.append(
137
+ 'tool/result',
138
+ { ...data, message: { ...data.message, content: [{ type: 'text', text }] } },
139
+ { surfaceOp: { op: 'replace', startSeq: anchor.seq, endSeq: anchor.seq }, sourceEventSeqs: [anchor.seq] },
140
+ )
141
+ } catch {
142
+ // A already-shadowed or foreign sequence is not ours to rewrite; the patch-time hashes still protect.
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Re-check every live anchor against the file it came from.
148
+ *
149
+ * `stat` is the cheap prefilter: an unchanged stat version means nothing moved
150
+ * and no read happens. A changed version then splits into the two cases that
151
+ * matter — same content (refresh bookkeeping, keep the model's anchors) and
152
+ * different content (void the old result).
153
+ * @param {object} ctx - the plugin context.
154
+ * @param {object} registry - the plugin's anchor registry.
155
+ * @param {object} actor - the request's step context, used as the observation owner.
156
+ */
157
+ async function revalidateAnchors(ctx, registry, actor) {
158
+ const fs = ctx?.fs
159
+ if (!fs || registry.anchors.size === 0) return
160
+ for (const [key, anchor] of [...registry.anchors]) {
161
+ if (registry.turn - anchor.turn > ANCHOR_TURNS) {
162
+ registry.anchors.delete(key)
163
+ continue
164
+ }
165
+ const target = anchor.target ?? { targetKey: key, displayPath: anchor.displayPath }
166
+ let info
167
+ try {
168
+ info = await fs.stat(target)
169
+ } catch {
170
+ continue
171
+ }
172
+ if (!info) {
173
+ registry.anchors.delete(key)
174
+ continue
175
+ }
176
+ if (info.version === anchor.version) continue
177
+ let content
178
+ try {
179
+ content = await fs.readText(target)
180
+ } catch {
181
+ continue
182
+ }
183
+ const fresh = fileHash(content)
184
+ if (fresh === anchor.fileHash) {
185
+ // Byte-identical: a rebuild or a touch moved the stat, not the content.
186
+ anchor.version = info.version
187
+ try {
188
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, actor)
189
+ } catch {
190
+ // no observation gate mounted
191
+ }
192
+ continue
193
+ }
194
+ replaceStaleRead(registry, anchor, fresh)
195
+ registry.anchors.delete(key)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Register both tools plus the anchor bookkeeping on a plugin context.
201
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
202
+ */
203
+ export function apply(ctx) {
204
+ const registry = { anchors: new Map(), order: [], turn: 0, session: undefined }
205
+
206
+ /** Record an observation so `fs/write-intent` sees this file as read. */
207
+ const observe = (target, version, actor) => {
208
+ try {
209
+ ctx.emit('fs/observed', target, { kind: 'present', version }, actor)
210
+ } catch {
211
+ // The gate is optional; a bare provider simply has no listener.
212
+ }
213
+ }
214
+
215
+ // A tool-mediated write refreshes its own observation, so the anchor it
216
+ // invalidates must not later read back as an "external" change.
217
+ try {
218
+ ctx.on('fs/observed', (target, observation) => {
219
+ if (!target || observation?.kind !== 'present') return
220
+ const anchor = registry.anchors.get(target.targetKey)
221
+ if (anchor && anchor.version !== observation.version) registry.anchors.delete(target.targetKey)
222
+ })
223
+ } catch {
224
+ // Hosts without the observation gate simply skip this bookkeeping.
225
+ }
226
+
227
+ // One session/event listener carries three facts the revalidation needs: the
228
+ // live session, the user-turn counter, and the sequence of our own results.
229
+ try {
230
+ ctx.on('session/event', (session, event) => {
231
+ if (session) registry.session = session
232
+ if (!event) return
233
+ if (event.type === 'turn/start') {
234
+ registry.turn += 1
235
+ return
236
+ }
237
+ if (event.type !== 'tool/result') return
238
+ const callId = event.data?.message?.toolCallId
239
+ if (typeof callId !== 'string') return
240
+ for (const anchor of registry.anchors.values()) {
241
+ if (anchor.callId === callId && typeof anchor.seq !== 'number') {
242
+ anchor.seq = event.seq
243
+ anchor.session = session
244
+ }
245
+ }
246
+ })
247
+ } catch {
248
+ // A host without session events still gets working tools; anchors just never expire by age.
249
+ }
250
+
251
+ // `agent/request` runs with an open step and before the model call: the one
252
+ // place where a surface replacement is legal and still useful.
253
+ try {
254
+ ctx.on('agent/request', (...args) => {
255
+ const next = typeof args.at(-1) === 'function' ? args.at(-1) : undefined
256
+ const stepContext = args[0]
257
+ const settled = revalidateAnchors(ctx, registry, stepContext).catch(() => {})
258
+ return next ? settled.then(() => next()) : settled
259
+ })
260
+ } catch {
261
+ // Without the hook, anchors are only checked at patch time.
262
+ }
263
+
264
+ ctx.tools.register(defineTool({
265
+ name: 'read_hash',
266
+ description: READ_DESCRIPTION,
267
+ parameters: {
268
+ file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
269
+ offset: { type: 'number', description: '1-based first line to show. Defaults to 1.' },
270
+ limit: { type: 'number', description: `Maximum lines to show. Defaults to and is capped at ${DEFAULT_READ_LIMIT}.` },
271
+ },
272
+ output: {
273
+ schema: {
274
+ type: 'object',
275
+ additionalProperties: false,
276
+ properties: {
277
+ path: { type: 'string', required: true },
278
+ file_hash: { type: 'string', required: true },
279
+ line_hash_chars: { type: 'integer', required: true },
280
+ offset: { type: 'integer', required: true },
281
+ line_count: { type: 'integer', required: true },
282
+ total_lines: { type: 'integer', required: true },
283
+ content: { type: 'string', required: true },
284
+ truncated: { type: 'boolean', required: true },
285
+ },
286
+ },
287
+ render: (_args, value) => [{
288
+ type: 'text',
289
+ text: `file_hash: ${value.file_hash}\n` +
290
+ `lines ${value.offset}-${value.offset + value.line_count - 1} of ${value.total_lines}\n` +
291
+ `${value.content}` +
292
+ (value.truncated ? `\n... [showing ${value.line_count} lines; narrow the range with offset/limit]` : ''),
293
+ }],
294
+ },
295
+ async execute(args, exec) {
296
+ const fs = requireFs(ctx)
297
+ const offset = Math.max(1, Math.trunc(args.offset ?? 1))
298
+ const limit = Math.min(Math.max(1, Math.trunc(args.limit ?? DEFAULT_READ_LIMIT)), DEFAULT_READ_LIMIT)
299
+ const { target, content, version } = await readWholeFile(ctx, args.file_path, exec.signal)
300
+ const all = splitKeepEnds(content)
301
+ const total = all.length
302
+ const end = Math.min(total, offset + limit - 1)
303
+ const window = end >= offset ? all.slice(offset - 1, end).join('') : ''
304
+ observe(target, version, exec)
305
+ rememberAnchor(registry, {
306
+ path: fs.processPath ? fs.processPath(target) : String(args.file_path),
307
+ targetKey: target.targetKey ?? target.key ?? String(args.file_path),
308
+ displayPath: String(args.file_path),
309
+ fileHash: fileHash(content),
310
+ version,
311
+ turn: registry.turn,
312
+ target,
313
+ session: registry.session,
314
+ callId: exec?.callId,
315
+ })
316
+ return {
317
+ path: fs.processPath ? fs.processPath(target) : String(args.file_path),
318
+ file_hash: fileHash(content),
319
+ line_hash_chars: LINE_HASH_CHARS,
320
+ offset,
321
+ line_count: Math.max(0, end - offset + 1),
322
+ total_lines: total,
323
+ content: annotateLines(window, offset),
324
+ truncated: end < total,
325
+ }
326
+ },
327
+ }))
328
+
329
+ ctx.tools.register(defineTool({
330
+ name: 'patch_hash',
331
+ description: PATCH_DESCRIPTION,
332
+ parameters: {
333
+ file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
334
+ file_hash: { type: 'string', required: true, description: `Whole-file hash from read_hash (${FILE_HASH_CHARS} hex chars).` },
335
+ line_start: { type: 'number', required: true, description: '1-based first line to replace.' },
336
+ hash_start: { type: 'string', required: true, description: `Hash of line_start as shown by read_hash (${LINE_HASH_CHARS} hex chars).` },
337
+ line_end: { type: 'number', required: true, description: '1-based last line to replace, inclusive. Equal to line_start for one line.' },
338
+ hash_end: { type: 'string', required: true, description: `Hash of line_end as shown by read_hash (${LINE_HASH_CHARS} hex chars).` },
339
+ replace: { type: 'string', required: true, description: 'Replacement text. An empty string deletes the range.' },
340
+ },
341
+ output: {
342
+ schema: {
343
+ type: 'object',
344
+ additionalProperties: false,
345
+ properties: {
346
+ path: { type: 'string', required: true },
347
+ replaced: { type: 'array', required: true, items: { type: 'number' } },
348
+ line_shift: { type: 'integer', required: true },
349
+ new_file_hash: { type: 'string', required: true },
350
+ },
351
+ },
352
+ render: (_args, value) => [{
353
+ type: 'text',
354
+ text: `patched ${value.path}: lines ${value.replaced[0]}-${value.replaced[1]} replaced ` +
355
+ `(line shift ${value.line_shift >= 0 ? '+' : ''}${value.line_shift}). ` +
356
+ `new file_hash: ${value.new_file_hash}`,
357
+ }],
358
+ presentationMeta: (args, value) => ({ replaced: value.replaced, path: value.path }),
359
+ },
360
+ async execute(args, exec) {
361
+ const fs = requireFs(ctx)
362
+ const { target, content, version } = await readWholeFile(ctx, args.file_path, exec.signal)
363
+ const result = applyPatch({
364
+ content,
365
+ expectedFileHash: args.file_hash,
366
+ lineStart: Math.trunc(args.line_start),
367
+ hashStart: args.hash_start,
368
+ lineEnd: Math.trunc(args.line_end),
369
+ hashEnd: args.hash_end,
370
+ replace: args.replace,
371
+ })
372
+ if (!result.ok) {
373
+ const error = new Error(result.message)
374
+ error.code = result.code
375
+ throw error
376
+ }
377
+
378
+ // Mirror the built-in write tool: ask the policy gate for the intent, then
379
+ // hand it to the provider so the version CAS (and read-before-write) apply.
380
+ const fallback = { kind: 'replaceIfVersion', version }
381
+ let intent = fallback
382
+ try {
383
+ const decided = await ctx.waterfall('fs/write-intent', target, exec, () => fallback)
384
+ if (decided) intent = decided
385
+ } catch {
386
+ intent = fallback
387
+ }
388
+
389
+ const outcome = await fs.writeText(target, result.content, intent, exec.signal)
390
+ observe(target, outcome?.version ?? version, exec)
391
+
392
+ rememberAnchor(registry, {
393
+ path: fs.processPath ? fs.processPath(target) : String(args.file_path),
394
+ targetKey: target.targetKey ?? target.key ?? String(args.file_path),
395
+ displayPath: String(args.file_path),
396
+ fileHash: result.fileHash,
397
+ version: outcome?.version ?? version,
398
+ turn: registry.turn,
399
+ target,
400
+ // No callId: a patch result is never rewritten, it is only bookkeeping.
401
+ })
402
+
403
+ return {
404
+ path: fs.processPath ? fs.processPath(target) : String(args.file_path),
405
+ replaced: [Math.trunc(args.line_start), Math.trunc(args.line_end)],
406
+ line_shift: result.lineShift,
407
+ new_file_hash: result.fileHash,
408
+ }
409
+ },
410
+ }))
411
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
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.2.0",
4
+ "description": "Anchored file editing for DeepSeek Harness: read_hash hands out per-line and whole-file hashes, patch_hash rewrites a line range only when the whole file and both boundary lines 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
+ "node": ">=20"
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/dsh-tools": "*"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test test/*.test.mjs",
49
+ "check": "node --check lib/core.js && node --check lib/index.js"
50
+ },
51
+ "license": "MIT",
52
+ "author": "kingofpowers",
53
+ "peerDependenciesMeta": {
54
+ "@deepseek-ai/dsh-tools": {
55
+ "optional": true
56
+ }
57
+ }
58
+ }