@volcanic-dev/tephra 0.2.0 → 0.4.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 +12 -7
- package/package.json +1 -1
- package/server.js +210 -44
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Tephra
|
|
2
2
|
|
|
3
|
-
**A clipboard for AI agents.**
|
|
3
|
+
**A clipboard for AI agents.** When a model moves code by regenerating it, it retypes 200 lines from memory — and sometimes what lands isn't quite what left. That transcription drift is a correctness bug, not just a token cost. Tephra is an MCP server for byte-exact copy and paste: name whole lines (or exact `(line, char)` ranges), copy them byte-for-byte, paste them at an exact position anywhere else. **Your agent will never subtly mangle code it's moving.**
|
|
4
4
|
|
|
5
5
|
The clipboard is a **private in-process buffer, deliberately not the OS clipboard**. An agent can never read what you last copied (passwords, tokens, anything) and can never overwrite what you're about to paste. Nothing touches disk; the buffer dies with the session.
|
|
6
6
|
|
|
@@ -18,11 +18,14 @@ npx -y @volcanic-dev/tephra
|
|
|
18
18
|
|
|
19
19
|
| Tool | What it does |
|
|
20
20
|
|------|--------------|
|
|
21
|
-
| `copy` | Copy one or more ranges `{start_line,
|
|
22
|
-
| `cut` | Same as `copy`, but removes the ranges from the source file (ranges must be disjoint). |
|
|
23
|
-
| `paste` | Insert clipboard slots into a file at one or more targets `{line, char
|
|
21
|
+
| `copy` | Copy one or more ranges `{start_line, end_line, whole_lines?, start_char?, end_char?, slot?, expect?}` from a file into clipboard slots. Both ends inclusive, 1-indexed. Newlines inside a range are included. |
|
|
22
|
+
| `cut` | Same as `copy`, but removes the ranges from the source file (ranges must be disjoint). Whole-line cuts take the newline too — no empty line left behind. |
|
|
23
|
+
| `paste` | Insert clipboard slots into a file at one or more targets `{line, char?, slot?, expect?}` — before the character at `(line, char)`; `char` defaults to 1 and may be line length + 1 to append to a line. Add `end_line` (or `whole_lines: true`) to **replace** that range with the slot instead — the result shows exactly what was removed. |
|
|
24
|
+
| `move` | Cut + paste in **one atomic call**: `move(file, ranges, to: {file?, line, char?, expect?})`. Source ranges *and* destination are all given in the file's current coordinates — the server does the shift arithmetic, so "move lines 120–184 to line 200" just works even though the cut renumbers line 200. Cross-file moves allowed. |
|
|
24
25
|
| `peek` | List the slots that hold text, or show one slot's size and preview. |
|
|
25
26
|
|
|
27
|
+
The commonest workflows are one call each: relocate code with `move`, overwrite a stale block with a replace-mode `paste`, duplicate with `copy` + `paste`.
|
|
28
|
+
|
|
26
29
|
## Named slots
|
|
27
30
|
|
|
28
31
|
Every range and target takes an optional `slot` (default `"default"`). Gather many snippets in one pass — each into its own slot — then paste them in any order, any number of times. Multi-range calls must name a distinct slot per range.
|
|
@@ -37,9 +40,11 @@ Cutting or pasting lines shifts the numbering of everything below the edit — t
|
|
|
37
40
|
|
|
38
41
|
## Addressing
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
+
Models read line numbers well and count characters badly, so the common case never counts:
|
|
44
|
+
|
|
45
|
+
- **Whole lines** (the usual move): `{start_line: 10, end_line: 14, whole_lines: true}`. Linewise, vim-style — the trailing newline comes along, so a cut lifts the lines out cleanly and a paste at `{line: 42}` drops them in as complete lines.
|
|
46
|
+
- Character positions are optional refinements: omitted `end_char` means end-of-line, omitted `start_char` (or paste `char`) means column 1.
|
|
47
|
+
- When given, everything is **1-indexed** and ranges are **inclusive on both ends**: copying `(2, 5)` → `(2, 7)` yields exactly 3 characters.
|
|
43
48
|
- A file ending in a newline has an addressable empty final line, so appending at end-of-file is `paste` at `(lastLine, 1)`.
|
|
44
49
|
- Out-of-range positions return the line's real length and a preview of it, so an agent can self-correct in one step.
|
|
45
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volcanic-dev/tephra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MCP server that gives AI agents a clipboard of their own: copy an exact line:char range from a file, paste it anywhere — byte-for-byte, without touching the OS clipboard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/server.js
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
// @tephra-server
|
|
3
3
|
// Tephra — an MCP server that gives AI agents a clipboard of their own.
|
|
4
4
|
//
|
|
5
|
-
// Models are bad at retyping:
|
|
6
|
-
//
|
|
7
|
-
// exact range —
|
|
8
|
-
// inclusive — copy it byte-for-byte, and paste
|
|
9
|
-
// another file.
|
|
5
|
+
// Models are bad at retyping: regenerating 200 lines to move them invites
|
|
6
|
+
// transcription drift — a correctness bug, not just a token cost. Tephra
|
|
7
|
+
// lets an agent name an exact range — whole lines, or (line, char) to
|
|
8
|
+
// (line, char), 1-indexed and inclusive — copy it byte-for-byte, and paste
|
|
9
|
+
// it at an exact position in another file.
|
|
10
|
+
//
|
|
11
|
+
// Models are also bad at counting characters within a line, so character
|
|
12
|
+
// positions are optional everywhere: whole_lines ranges take line numbers
|
|
13
|
+
// only (linewise, including the trailing newline, so cuts leave no empty
|
|
14
|
+
// husk), an omitted end_char means end-of-line, an omitted start_char or
|
|
15
|
+
// paste char means column 1.
|
|
10
16
|
//
|
|
11
17
|
// The clipboard is a private in-process buffer, deliberately NOT the OS
|
|
12
18
|
// clipboard: an agent must not be able to read whatever the human last
|
|
@@ -18,9 +24,12 @@
|
|
|
18
24
|
// mismatch modifies nothing and reports where that text actually is now.
|
|
19
25
|
// Batched ranges/targets are applied bottom-up, so one read of a file
|
|
20
26
|
// yields coordinates that stay valid for the whole call, and any edit that
|
|
21
|
-
// changes line numbering reports the shift.
|
|
27
|
+
// changes line numbering reports the shift. The commonest workflows are
|
|
28
|
+
// single atomic calls: `move` is cut + paste with the server doing the
|
|
29
|
+
// shift arithmetic, and a paste target with end_line/whole_lines REPLACES
|
|
30
|
+
// a range instead of inserting.
|
|
22
31
|
//
|
|
23
|
-
// Tools: copy, cut, paste, peek.
|
|
32
|
+
// Tools: copy, cut, paste, move, peek.
|
|
24
33
|
|
|
25
34
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
26
35
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
@@ -153,6 +162,8 @@ function shiftNote(afterLine, delta) {
|
|
|
153
162
|
// addressed against the same read of the file; cut applies them bottom-up so
|
|
154
163
|
// none invalidates another. Multi-range calls must name a distinct slot per
|
|
155
164
|
// range (defaulting them all to "default" would silently overwrite).
|
|
165
|
+
// whole_lines ranges are linewise: they include the end line's terminator,
|
|
166
|
+
// so cutting them removes the lines entirely instead of leaving empty husks.
|
|
156
167
|
function resolveRanges(text, lines, ranges, forCut) {
|
|
157
168
|
const resolved = ranges.map((r, idx) => {
|
|
158
169
|
const label = ranges.length === 1 ? 'range' : `range ${idx + 1}`
|
|
@@ -161,10 +172,25 @@ function resolveRanges(text, lines, ranges, forCut) {
|
|
|
161
172
|
if (ranges.length > 1) throw new PositionError(`${label}: when passing multiple ranges, every range must name its own slot (otherwise they would overwrite each other).`)
|
|
162
173
|
slot = 'default'
|
|
163
174
|
}
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
let startOff
|
|
176
|
+
let endOff
|
|
177
|
+
if (r.whole_lines) {
|
|
178
|
+
if (r.start_char !== undefined || r.end_char !== undefined) {
|
|
179
|
+
throw new PositionError(`${label}: whole_lines ranges take only start_line and end_line — omit start_char and end_char.`)
|
|
180
|
+
}
|
|
181
|
+
startOff = lineAt(lines, r.start_line, `${label} start`).start
|
|
182
|
+
lineAt(lines, r.end_line, `${label} end`)
|
|
183
|
+
endOff = r.end_line < lines.length ? lines[r.end_line].start : text.length
|
|
184
|
+
} else {
|
|
185
|
+
startOff = r.start_char === undefined
|
|
186
|
+
? lineAt(lines, r.start_line, `${label} start`).start
|
|
187
|
+
: charOffset(text, lines, r.start_line, r.start_char, `${label} start`, 0)
|
|
188
|
+
endOff = r.end_char === undefined
|
|
189
|
+
? lineAt(lines, r.end_line, `${label} end`).contentEnd
|
|
190
|
+
: charOffset(text, lines, r.end_line, r.end_char, `${label} end`, 0) + 1 // end is inclusive
|
|
191
|
+
}
|
|
166
192
|
if (endOff <= startOff) {
|
|
167
|
-
throw new PositionError(`${label}: the
|
|
193
|
+
throw new PositionError(`${label}: the range from ${rangeDesc(r)} is empty or reversed — the end does not come after the start.`)
|
|
168
194
|
}
|
|
169
195
|
if (r.expect !== undefined) verifyExpect(text, lines, startOff, r.expect, label)
|
|
170
196
|
return { ...r, idx, label, slot, startOff, endOff }
|
|
@@ -185,8 +211,13 @@ function resolveRanges(text, lines, ranges, forCut) {
|
|
|
185
211
|
return resolved
|
|
186
212
|
}
|
|
187
213
|
|
|
214
|
+
function rangeDesc(r) {
|
|
215
|
+
if (r.whole_lines) return r.start_line === r.end_line ? `line ${r.start_line}` : `lines ${r.start_line}–${r.end_line}`
|
|
216
|
+
return `${r.start_line}:${r.start_char ?? 1} → ${r.end_line}:${r.end_char ?? 'end-of-line'}`
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
function rangeSummary(r, snippet) {
|
|
189
|
-
return `slot "${r.slot}": ${describe(snippet)} (${r
|
|
220
|
+
return `slot "${r.slot}": ${describe(snippet)} (${rangeDesc(r)}) — ${inlinePreview(snippet)}`
|
|
190
221
|
}
|
|
191
222
|
|
|
192
223
|
function ok(message) {
|
|
@@ -206,7 +237,7 @@ async function guarded(fn) {
|
|
|
206
237
|
}
|
|
207
238
|
|
|
208
239
|
// @tephra-tools
|
|
209
|
-
const server = new McpServer({ name: 'tephra', version: '0.
|
|
240
|
+
const server = new McpServer({ name: 'tephra', version: '0.4.0' })
|
|
210
241
|
|
|
211
242
|
const expectSchema = z
|
|
212
243
|
.string()
|
|
@@ -217,9 +248,10 @@ const expectSchema = z
|
|
|
217
248
|
|
|
218
249
|
const rangeItem = z.object({
|
|
219
250
|
start_line: z.number().int().describe('Line of the first character (1-indexed)'),
|
|
220
|
-
start_char: z.number().int().describe('Character position on start_line of the first character (1-indexed)'),
|
|
251
|
+
start_char: z.number().int().optional().describe('Character position on start_line of the first character (1-indexed). Omit to start at the beginning of the line.'),
|
|
221
252
|
end_line: z.number().int().describe('Line of the last character (1-indexed, inclusive)'),
|
|
222
|
-
end_char: z.number().int().describe('Character position on end_line of the last character (1-indexed, inclusive)'),
|
|
253
|
+
end_char: z.number().int().optional().describe('Character position on end_line of the last character (1-indexed, inclusive). Omit to run to the end of the line. Prefer omitting over counting characters.'),
|
|
254
|
+
whole_lines: z.boolean().optional().describe('Take start_line through end_line as complete lines, INCLUDING the trailing newline (so cutting removes the lines entirely, leaving no empty line behind). With this set, omit start_char/end_char. Preferred for any whole-line move.'),
|
|
223
255
|
slot: z.string().min(1).max(64).optional().describe('Clipboard slot to store this range in. Optional for a single range (defaults to "default"); required, and distinct, when passing multiple ranges.'),
|
|
224
256
|
expect: expectSchema,
|
|
225
257
|
})
|
|
@@ -238,9 +270,9 @@ server.registerTool(
|
|
|
238
270
|
{
|
|
239
271
|
title: 'Copy ranges to clipboard',
|
|
240
272
|
description:
|
|
241
|
-
"Copy one or more exact
|
|
242
|
-
'
|
|
243
|
-
'
|
|
273
|
+
"Copy one or more exact ranges from a file into Tephra's clipboard slots, byte-for-byte — use this instead of retyping text you intend to move or duplicate, so nothing gets subtly mangled in transit. " +
|
|
274
|
+
'For whole lines (the common case), set whole_lines: true and give line numbers only. ' +
|
|
275
|
+
'Character positions are optional: each range runs (start_line, start_char) → (end_line, end_char), inclusive on both ends, 1-indexed; omit start_char for column 1 and end_char for end-of-line rather than counting characters. ' +
|
|
244
276
|
"Pass `expect` (the range's first characters) to verify the coordinates still match the content before copying. " +
|
|
245
277
|
'The clipboard is private to this session, not the operating system clipboard.',
|
|
246
278
|
inputSchema: rangesSchema,
|
|
@@ -254,7 +286,7 @@ server.registerTool(
|
|
|
254
286
|
resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
|
|
255
287
|
if (resolved.length === 1) {
|
|
256
288
|
const [r] = resolved
|
|
257
|
-
return ok(`Copied ${describe(snippets[0])} from ${file} (${r
|
|
289
|
+
return ok(`Copied ${describe(snippets[0])} from ${file} (${rangeDesc(r)}) to slot "${r.slot}".\n\nPreview:\n${preview(snippets[0])}`)
|
|
258
290
|
}
|
|
259
291
|
return ok(`Copied ${resolved.length} ranges from ${file}:\n${resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n')}`)
|
|
260
292
|
}),
|
|
@@ -266,6 +298,7 @@ server.registerTool(
|
|
|
266
298
|
title: 'Cut ranges to clipboard',
|
|
267
299
|
description:
|
|
268
300
|
'Same as copy, but also removes the ranges from the source file (they must not overlap). ' +
|
|
301
|
+
'For moving whole lines, set whole_lines: true — the trailing newline comes along, so the lines are removed entirely with no empty line left behind. ' +
|
|
269
302
|
'Ranges are removed bottom-up, so every range is addressed against the file exactly as you last read it. The file is written back immediately. ' +
|
|
270
303
|
'CAUTION: removing lines shifts the line numbers of everything below a cut — the result says by how much; recompute stale positions before reusing them.',
|
|
271
304
|
inputSchema: rangesSchema,
|
|
@@ -285,7 +318,7 @@ server.registerTool(
|
|
|
285
318
|
const finalCount = indexLines(out).length
|
|
286
319
|
if (resolved.length === 1) {
|
|
287
320
|
const [r] = resolved
|
|
288
|
-
return ok(`Cut ${describe(snippets[0])} from ${file} (${r
|
|
321
|
+
return ok(`Cut ${describe(snippets[0])} from ${file} (${rangeDesc(r)}) to slot "${r.slot}". The file has been updated (${finalCount} addressable lines).${shiftNote(r.end_line, -newlineCount(snippets[0]))}\n\nPreview:\n${preview(snippets[0])}`)
|
|
289
322
|
}
|
|
290
323
|
const removedLines = snippets.reduce((acc, s) => acc + newlineCount(s), 0)
|
|
291
324
|
return ok(
|
|
@@ -298,30 +331,75 @@ server.registerTool(
|
|
|
298
331
|
}),
|
|
299
332
|
)
|
|
300
333
|
|
|
334
|
+
// @tephra-paste-targets — a paste target either inserts at an anchor or, when
|
|
335
|
+
// end_line/whole_lines is present, REPLACES a range with the slot contents.
|
|
336
|
+
function resolveTarget(text, lines, t, label) {
|
|
337
|
+
const clip = slots.get(t.slot)
|
|
338
|
+
if (clip === undefined || clip.length === 0) {
|
|
339
|
+
throw new PositionError(`${label}: slot "${t.slot}" is empty — nothing to paste. ${slots.size > 0 ? `Slots that do hold text: ${slotList()}.` : 'Use copy or cut first.'}`)
|
|
340
|
+
}
|
|
341
|
+
const isReplace = t.end_line !== undefined || t.whole_lines === true
|
|
342
|
+
if (t.end_char !== undefined && t.end_line === undefined) {
|
|
343
|
+
throw new PositionError(`${label}: end_char requires end_line.`)
|
|
344
|
+
}
|
|
345
|
+
let off
|
|
346
|
+
let endOff
|
|
347
|
+
if (isReplace && t.whole_lines) {
|
|
348
|
+
if (t.end_char !== undefined) throw new PositionError(`${label}: whole_lines replacement takes only line and end_line — omit end_char.`)
|
|
349
|
+
off = lineAt(lines, t.line, label).start
|
|
350
|
+
const endLine = t.end_line ?? t.line
|
|
351
|
+
lineAt(lines, endLine, `${label} end`)
|
|
352
|
+
endOff = endLine < lines.length ? lines[endLine].start : text.length
|
|
353
|
+
} else if (isReplace) {
|
|
354
|
+
off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
355
|
+
endOff = t.end_char === undefined
|
|
356
|
+
? lineAt(lines, t.end_line, `${label} end`).contentEnd
|
|
357
|
+
: charOffset(text, lines, t.end_line, t.end_char, `${label} end`, 0) + 1
|
|
358
|
+
if (endOff <= off) throw new PositionError(`${label}: the range to replace is empty or reversed — the end does not come after the start.`)
|
|
359
|
+
} else {
|
|
360
|
+
off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
361
|
+
endOff = off
|
|
362
|
+
}
|
|
363
|
+
if (t.expect !== undefined) verifyExpect(text, lines, off, t.expect, label)
|
|
364
|
+
return { off, endOff, clip, isReplace }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function targetDesc(t) {
|
|
368
|
+
if (!(t.end_line !== undefined || t.whole_lines === true)) return `${t.line}:${t.char}`
|
|
369
|
+
if (t.whole_lines) {
|
|
370
|
+
const endLine = t.end_line ?? t.line
|
|
371
|
+
return t.line === endLine ? `line ${t.line}` : `lines ${t.line}–${endLine}`
|
|
372
|
+
}
|
|
373
|
+
return `${t.line}:${t.char} → ${t.end_line}:${t.end_char ?? 'end-of-line'}`
|
|
374
|
+
}
|
|
375
|
+
|
|
301
376
|
server.registerTool(
|
|
302
377
|
'paste',
|
|
303
378
|
{
|
|
304
379
|
title: 'Paste clipboard into file',
|
|
305
380
|
description:
|
|
306
|
-
"Insert Tephra clipboard slot contents into a file at one or more exact positions. " +
|
|
307
|
-
'
|
|
381
|
+
"Insert Tephra clipboard slot contents into a file at one or more exact positions, or replace ranges with them. " +
|
|
382
|
+
'By default text is inserted before the character at (line, char) and nothing is overwritten; char defaults to 1 (right for whole lines) and may be one past the end of the line to append to it. ' +
|
|
383
|
+
'REPLACE MODE: give end_line (plus optional end_char, or whole_lines: true for complete lines) and the slot contents replace that range instead — the result shows exactly what was removed. ' +
|
|
308
384
|
'Multiple targets are applied bottom-up, so they all use the coordinates of the file as you last read it. ' +
|
|
309
|
-
'Pass `expect` (the text that currently begins at the position) to verify before
|
|
310
|
-
'Existing text is never overwritten — it shifts to make room, and the result says how line numbers moved.',
|
|
385
|
+
'Pass `expect` (the text that currently begins at the position) to verify before touching anything.',
|
|
311
386
|
inputSchema: {
|
|
312
387
|
file: z.string().describe('Absolute path of the file to paste into'),
|
|
313
388
|
targets: z
|
|
314
389
|
.array(
|
|
315
390
|
z.object({
|
|
316
|
-
line: z.number().int().describe('Line
|
|
317
|
-
char: z.number().int().describe('Character position
|
|
391
|
+
line: z.number().int().describe('Line of the anchor (1-indexed); in replace mode, the first line of the range being replaced'),
|
|
392
|
+
char: z.number().int().default(1).describe('Character position of the anchor (1-indexed; text is inserted before this character; line length + 1 means end of line). Omit for column 1 — the right choice for whole lines.'),
|
|
393
|
+
end_line: z.number().int().optional().describe('Replace mode: replace from the anchor through this line (1-indexed, inclusive) with the slot contents instead of inserting'),
|
|
394
|
+
end_char: z.number().int().optional().describe('Replace mode: last character replaced on end_line (1-indexed, inclusive). Omit for end-of-line.'),
|
|
395
|
+
whole_lines: z.boolean().optional().describe('Replace mode: replace complete lines line..end_line (end_line defaults to line), INCLUDING the trailing newline. Omit char/end_char.'),
|
|
318
396
|
slot: z.string().min(1).max(64).default('default').describe('Clipboard slot to paste from (defaults to "default")'),
|
|
319
397
|
expect: expectSchema,
|
|
320
398
|
}),
|
|
321
399
|
)
|
|
322
400
|
.min(1)
|
|
323
401
|
.max(20)
|
|
324
|
-
.describe('One or more paste
|
|
402
|
+
.describe('One or more paste/replace targets, all addressed against the file as it is right now — they are applied bottom-up, so earlier targets never invalidate later ones.'),
|
|
325
403
|
},
|
|
326
404
|
},
|
|
327
405
|
async ({ file, targets }) =>
|
|
@@ -329,45 +407,133 @@ server.registerTool(
|
|
|
329
407
|
const text = await readFile(file, 'utf8')
|
|
330
408
|
const lines = indexLines(text)
|
|
331
409
|
const resolved = targets.map((t, idx) => {
|
|
332
|
-
const label = targets.length === 1 ? 'paste
|
|
333
|
-
|
|
334
|
-
if (clip === undefined || clip.length === 0) {
|
|
335
|
-
throw new PositionError(`${label}: slot "${t.slot}" is empty — nothing to paste. ${slots.size > 0 ? `Slots that do hold text: ${slotList()}.` : 'Use copy or cut first.'}`)
|
|
336
|
-
}
|
|
337
|
-
const off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
338
|
-
if (t.expect !== undefined) verifyExpect(text, lines, off, t.expect, label)
|
|
339
|
-
return { ...t, idx, label, off, clip }
|
|
410
|
+
const label = targets.length === 1 ? 'paste target' : `target ${idx + 1}`
|
|
411
|
+
return { ...t, idx, label, ...resolveTarget(text, lines, t, label) }
|
|
340
412
|
})
|
|
413
|
+
const byOff = [...resolved].sort((a, b) => a.off - b.off || a.idx - b.idx)
|
|
414
|
+
for (let i = 1; i < byOff.length; i++) {
|
|
415
|
+
if (byOff[i].off < byOff[i - 1].endOff) {
|
|
416
|
+
throw new PositionError(`${byOff[i].label} falls inside the range replaced by ${byOff[i - 1].label} — targets must not overlap. Nothing was modified.`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
341
419
|
let out = text
|
|
342
420
|
for (const t of [...resolved].sort((a, b) => b.off - a.off || b.idx - a.idx)) {
|
|
343
|
-
out = out.slice(0, t.off) + t.clip + out.slice(t.
|
|
421
|
+
out = out.slice(0, t.off) + t.clip + out.slice(t.endOff)
|
|
344
422
|
}
|
|
345
423
|
await writeFile(file, out, 'utf8')
|
|
346
424
|
const finalLines = indexLines(out)
|
|
347
|
-
// final coordinates: shift each target by everything
|
|
425
|
+
// final coordinates: shift each target by the net effect of everything applied above it
|
|
348
426
|
const placed = resolved.map((t) => {
|
|
349
|
-
const delta = resolved.reduce((acc, o) => acc + (o.off < t.off || (o.off === t.off && o.idx < t.idx) ? o.clip.length : 0), 0)
|
|
427
|
+
const delta = resolved.reduce((acc, o) => acc + (o.off < t.off || (o.off === t.off && o.idx < t.idx) ? o.clip.length - (o.endOff - o.off) : 0), 0)
|
|
350
428
|
return {
|
|
351
429
|
t,
|
|
430
|
+
removed: text.slice(t.off, t.endOff),
|
|
352
431
|
start: positionOf(finalLines, t.off + delta),
|
|
353
432
|
end: positionOf(finalLines, t.off + delta + t.clip.length),
|
|
354
433
|
}
|
|
355
434
|
})
|
|
356
435
|
if (placed.length === 1) {
|
|
357
|
-
const [{ t, start, end }] = placed
|
|
358
|
-
|
|
436
|
+
const [{ t, removed, start, end }] = placed
|
|
437
|
+
const span = `now spans ${start.line}:${start.char} → ${end.line}:${end.char} (exclusive end)`
|
|
438
|
+
if (t.isReplace) {
|
|
439
|
+
const anchorLine = t.whole_lines || t.end_char === undefined ? (t.end_line ?? t.line) : t.end_line
|
|
440
|
+
return ok(`Replaced ${targetDesc(t)} in ${file} (removed ${describe(removed)} — ${inlinePreview(removed)}) with slot "${t.slot}" (${describe(t.clip)}). The new text ${span}.${shiftNote(anchorLine, newlineCount(t.clip) - newlineCount(removed))}\n\nPreview:\n${preview(t.clip)}`)
|
|
441
|
+
}
|
|
442
|
+
return ok(`Pasted ${describe(t.clip)} from slot "${t.slot}" into ${file} at ${t.line}:${t.char}. The inserted text ${span}.${shiftNote(t.line, newlineCount(t.clip))}\n\nPreview:\n${preview(t.clip)}`)
|
|
359
443
|
}
|
|
360
|
-
const
|
|
444
|
+
const netLines = placed.reduce((acc, { t, removed }) => acc + newlineCount(t.clip) - newlineCount(removed), 0)
|
|
361
445
|
return ok(
|
|
362
|
-
`
|
|
363
|
-
placed.map(({ t, start, end }) =>
|
|
364
|
-
|
|
365
|
-
|
|
446
|
+
`Applied ${placed.length} targets to ${file}, bottom-up (file updated, ${finalLines.length} addressable lines):\n` +
|
|
447
|
+
placed.map(({ t, removed, start, end }) =>
|
|
448
|
+
`- slot "${t.slot}" (${describe(t.clip)}) ${t.isReplace ? `replaced ${targetDesc(t)} (removed ${describe(removed)})` : `at ${t.line}:${t.char}`} → final span ${start.line}:${start.char} → ${end.line}:${end.char}`,
|
|
449
|
+
).join('\n') +
|
|
450
|
+
(netLines !== 0
|
|
451
|
+
? `\nNet line-numbering change: ${netLines > 0 ? '+' : ''}${netLines} — recompute any positions you noted earlier before using them.`
|
|
366
452
|
: ''),
|
|
367
453
|
)
|
|
368
454
|
}),
|
|
369
455
|
)
|
|
370
456
|
|
|
457
|
+
server.registerTool(
|
|
458
|
+
'move',
|
|
459
|
+
{
|
|
460
|
+
title: 'Move ranges (atomic cut + paste)',
|
|
461
|
+
description:
|
|
462
|
+
'Move one or more ranges to a destination in ONE atomic call — the compound of cut and paste. ' +
|
|
463
|
+
'All coordinates, source ranges AND destination, are addressed against the file(s) exactly as they are right now: do not pre-adjust the destination for the lines the cut removes — the server does that arithmetic. ' +
|
|
464
|
+
'The destination may be in a different file. Multiple ranges are concatenated at the destination in listed order, and the moved text is also stored in the clipboard slot(s). ' +
|
|
465
|
+
'For whole lines, set whole_lines: true on the range and give the destination as a line number only.',
|
|
466
|
+
inputSchema: {
|
|
467
|
+
file: z.string().describe('Absolute path of the source file'),
|
|
468
|
+
ranges: rangesSchema.ranges,
|
|
469
|
+
to: z
|
|
470
|
+
.object({
|
|
471
|
+
file: z.string().optional().describe('Destination file (defaults to the source file)'),
|
|
472
|
+
line: z.number().int().describe('Destination line (1-indexed), in the destination file as it is NOW — do not adjust for the cut'),
|
|
473
|
+
char: z.number().int().default(1).describe('Destination character (1-indexed; the text is inserted before it; line length + 1 means end of line). Omit for column 1 — the right choice for whole lines.'),
|
|
474
|
+
expect: expectSchema,
|
|
475
|
+
})
|
|
476
|
+
.describe('Where the text goes; it is inserted before the character at (line, char)'),
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
async ({ file, ranges, to }) =>
|
|
480
|
+
guarded(async () => {
|
|
481
|
+
const text = await readFile(file, 'utf8')
|
|
482
|
+
const lines = indexLines(text)
|
|
483
|
+
const resolved = resolveRanges(text, lines, ranges, true)
|
|
484
|
+
const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
|
|
485
|
+
const payload = snippets.join('')
|
|
486
|
+
const sameFile = to.file === undefined || to.file === file
|
|
487
|
+
const destText = sameFile ? text : await readFile(to.file, 'utf8')
|
|
488
|
+
const destLines = sameFile ? lines : indexLines(destText)
|
|
489
|
+
const destOff = charOffset(destText, destLines, to.line, to.char, 'destination', 1)
|
|
490
|
+
if (to.expect !== undefined) verifyExpect(destText, destLines, destOff, to.expect, 'destination')
|
|
491
|
+
if (sameFile) {
|
|
492
|
+
for (const r of resolved) {
|
|
493
|
+
if (destOff > r.startOff && destOff < r.endOff) {
|
|
494
|
+
throw new PositionError(`destination ${to.line}:${to.char} falls inside ${r.label} (${rangeDesc(r)}) — a range cannot be moved into itself. Nothing was modified.`)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
|
|
499
|
+
let srcOut = text
|
|
500
|
+
for (const r of [...resolved].sort((a, b) => b.startOff - a.startOff)) {
|
|
501
|
+
srcOut = srcOut.slice(0, r.startOff) + srcOut.slice(r.endOff)
|
|
502
|
+
}
|
|
503
|
+
let start
|
|
504
|
+
let end
|
|
505
|
+
let destFinalCount
|
|
506
|
+
if (sameFile) {
|
|
507
|
+
const adjDest = destOff - resolved.reduce((acc, r) => acc + (r.endOff <= destOff ? r.endOff - r.startOff : 0), 0)
|
|
508
|
+
const out = srcOut.slice(0, adjDest) + payload + srcOut.slice(adjDest)
|
|
509
|
+
await writeFile(file, out, 'utf8')
|
|
510
|
+
const finalLines = indexLines(out)
|
|
511
|
+
destFinalCount = finalLines.length
|
|
512
|
+
start = positionOf(finalLines, adjDest)
|
|
513
|
+
end = positionOf(finalLines, adjDest + payload.length)
|
|
514
|
+
} else {
|
|
515
|
+
// insert into the destination before cutting the source, so a failure
|
|
516
|
+
// between the two writes duplicates text rather than losing it
|
|
517
|
+
const destOut = destText.slice(0, destOff) + payload + destText.slice(destOff)
|
|
518
|
+
await writeFile(to.file, destOut, 'utf8')
|
|
519
|
+
await writeFile(file, srcOut, 'utf8')
|
|
520
|
+
const finalLines = indexLines(destOut)
|
|
521
|
+
destFinalCount = finalLines.length
|
|
522
|
+
start = positionOf(finalLines, destOff)
|
|
523
|
+
end = positionOf(finalLines, destOff + payload.length)
|
|
524
|
+
}
|
|
525
|
+
const destName = sameFile ? file : to.file
|
|
526
|
+
const slotsDesc = resolved.length === 1 ? `slot "${resolved[0].slot}"` : `slots ${resolved.map((r) => `"${r.slot}"`).join(', ')}`
|
|
527
|
+
return ok(
|
|
528
|
+
`Moved ${describe(payload)} from ${file} (${resolved.map((r) => rangeDesc(r)).join(', ')}) to ${destName} ${to.line}:${to.char}. ` +
|
|
529
|
+
`It now spans ${start.line}:${start.char} → ${end.line}:${end.char} (final coordinates, exclusive end; ${sameFile ? 'file' : 'destination file'} has ${destFinalCount} addressable lines${sameFile ? '' : `; source file has ${indexLines(srcOut).length}`}). ` +
|
|
530
|
+
`Also stored in ${slotsDesc}.` +
|
|
531
|
+
'\nLine numbers around both the cut and the insertion have shifted — the span above is authoritative; recompute anything you noted earlier.' +
|
|
532
|
+
`\n\nPreview:\n${preview(payload)}`,
|
|
533
|
+
)
|
|
534
|
+
}),
|
|
535
|
+
)
|
|
536
|
+
|
|
371
537
|
server.registerTool(
|
|
372
538
|
'peek',
|
|
373
539
|
{
|