dsh-line-patch 0.2.0 → 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/README.md +167 -117
- package/cordis.patch.yml +4 -4
- package/lib/core.js +475 -191
- package/lib/index.js +472 -411
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -1,411 +1,472 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-line-patch — anchored file editing for DeepSeek Harness.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* mounted filesystem (sandbox, remote, policy gate) stays authoritative.
|
|
11
|
-
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*/
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
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
|
+
}
|