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/lib/index.js CHANGED
@@ -1,411 +1,472 @@
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
- }
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
+ }