@volcanic-dev/tephra 0.4.1 → 0.5.1
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 +5 -5
- package/package.json +1 -1
- package/server.js +40 -14
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ npx -y @volcanic-dev/tephra
|
|
|
18
18
|
|
|
19
19
|
| Tool | What it does |
|
|
20
20
|
|------|--------------|
|
|
21
|
-
| `copy` | Copy one or more ranges `{start_line, end_line, whole_lines?, start_char?, end_char?, slot?, expect
|
|
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
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
|
|
24
|
-
| `move` | Cut + paste in **one atomic call**: `move(file, ranges, to: {file?, line, char?, expect
|
|
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. |
|
|
25
25
|
| `peek` | List the slots that hold text, or show one slot's size and preview. |
|
|
26
26
|
|
|
27
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`.
|
|
@@ -29,7 +29,7 @@ The commonest workflows are one call each: relocate code with `move`, overwrite
|
|
|
29
29
|
Two semantics worth knowing precisely:
|
|
30
30
|
|
|
31
31
|
- **`move` destinations.** A destination *inside* a moved range is a loud error (a range can't move into itself; nothing is modified). A destination *between* moved ranges is well-defined gather-at-point semantics: unmoved text keeps its relative order and the concatenated payload lands exactly at the destination — moving lines 2–3 and 6–7 of an 8-line file to line 5 yields `1, 4, 2, 3, 6, 7, 5, 8`. A destination at a moved range's own boundary is an in-place move (no change).
|
|
32
|
-
- **`expect` in replace mode anchors at the START of the replaced range.** It is a position check, not a content-of-range check: it verifies the text
|
|
32
|
+
- **`expect` in replace mode anchors at the START of the replaced range.** It is a position check, not a content-of-range check: it verifies the text adjacent to the anchor (and may extend past the range's end), so pass the stale block's first characters — you don't need to reproduce the whole span you're overwriting.
|
|
33
33
|
|
|
34
34
|
## Named slots
|
|
35
35
|
|
|
@@ -40,7 +40,7 @@ Every range and target takes an optional `slot` (default `"default"`). Gather ma
|
|
|
40
40
|
Cutting or pasting lines shifts the numbering of everything below the edit — the classic way agents (especially several agents on one file) end up acting on stale coordinates. Tephra attacks this three ways:
|
|
41
41
|
|
|
42
42
|
- **Bottom-up batches.** All ranges/targets in one call are addressed against the file *as you last read it* and applied from the bottom of the file up, so none invalidates another. One read, one call, no arithmetic.
|
|
43
|
-
- **`expect` anchors —
|
|
43
|
+
- **`expect` anchors — required, because coordinates never travel alone.** Every position named to `copy`, `cut`, `paste`, or `move` must come with a few characters of adjacent text (up to 200 chars): the text starting at the position, or the text ending at it (natural for appends). On mismatch, nothing is modified and the error reports what is actually there and where your expected text lives now, so recovery is one round-trip. This makes stale coordinates fail **closed** (loud no-op) instead of open (silent wrong edit), and it catches *every* source of drift, including edits made outside Tephra — even a hallucinated expect fails safe. The one waiver: pasting into an empty file, where nothing exists to verify against and nothing can drift.
|
|
44
44
|
- **Shift reports.** Every cut and paste result states how line numbers moved (`"former line 4 is now line 3"`) and the file's new addressable line count, so positions noted earlier can be recomputed instead of silently going stale.
|
|
45
45
|
|
|
46
46
|
## Addressing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volcanic-dev/tephra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
@@ -19,9 +19,15 @@
|
|
|
19
19
|
// copied (passwords, tokens) or overwrite what they're about to paste.
|
|
20
20
|
// Nothing is written to disk; the buffer dies with the session.
|
|
21
21
|
//
|
|
22
|
-
// Coordinates are hints; content is truth
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// Coordinates are hints; content is truth — and coordinates never travel
|
|
23
|
+
// alone: every position named to Tephra (copy/cut/paste/move) requires an
|
|
24
|
+
// `expect` anchor, a few characters adjacent to it, verified before anything
|
|
25
|
+
// is touched. A mismatch modifies nothing and reports where that text
|
|
26
|
+
// actually is now. Stale coordinates therefore fail closed (loud no-op)
|
|
27
|
+
// instead of open (silent wrong edit). The anchor may match forward (text
|
|
28
|
+
// starting at the position) or backward (text ending at it — natural for
|
|
29
|
+
// appends); only pasting into an empty file waives it, since nothing exists
|
|
30
|
+
// to verify against.
|
|
25
31
|
// Batched ranges/targets are applied bottom-up, so one read of a file
|
|
26
32
|
// yields coordinates that stay valid for the whole call, and any edit that
|
|
27
33
|
// changes line numbering reports the shift. The commonest workflows are
|
|
@@ -102,11 +108,19 @@ function positionOf(lines, off) {
|
|
|
102
108
|
return { line: lo + 1, char: off - lines[lo].start + 1 }
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
// @tephra-verify — expect anchors: verify content before acting.
|
|
106
|
-
//
|
|
107
|
-
//
|
|
111
|
+
// @tephra-verify — expect anchors: verify content before acting. Two-sided
|
|
112
|
+
// adjacency: the anchor may match the text starting at the position or the
|
|
113
|
+
// text ending at it (so appends at end-of-line/file are satisfiable). On
|
|
114
|
+
// mismatch nothing is modified; the error reports what IS at the position
|
|
115
|
+
// and where the expected text actually lives now, so the agent re-aims in
|
|
116
|
+
// one step.
|
|
117
|
+
function expectRequired(label, hint) {
|
|
118
|
+
return new PositionError(`${label}: expect is required — pass a few characters you believe are adjacent to the position (${hint}). Coordinates are treated as hints; expect is what stops a stale one from touching the wrong text.`)
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
function verifyExpect(text, lines, off, expect, label) {
|
|
109
122
|
if (text.startsWith(expect, off)) return
|
|
123
|
+
if (off >= expect.length && text.slice(off - expect.length, off) === expect) return
|
|
110
124
|
const at = positionOf(lines, off)
|
|
111
125
|
const actual = text.slice(off, off + Math.min(Math.max(expect.length, 20), 80))
|
|
112
126
|
const found = []
|
|
@@ -117,7 +131,7 @@ function verifyExpect(text, lines, off, expect, label) {
|
|
|
117
131
|
if (found.length < 5) found.push(positionOf(lines, i))
|
|
118
132
|
i = text.indexOf(expect, i + 1)
|
|
119
133
|
}
|
|
120
|
-
let msg = `${label}: the
|
|
134
|
+
let msg = `${label}: the expected text ${JSON.stringify(expect.slice(0, 80))} is not adjacent to ${at.line}:${at.char} — it neither begins nor ends there. Forward from that position the file has ${JSON.stringify(actual)}.`
|
|
121
135
|
if (total === 1) {
|
|
122
136
|
msg += ` The expected text IS in the file, at ${found[0].line}:${found[0].char} — the content has moved; re-aim there.`
|
|
123
137
|
} else if (total > 1) {
|
|
@@ -192,7 +206,8 @@ function resolveRanges(text, lines, ranges, forCut) {
|
|
|
192
206
|
if (endOff <= startOff) {
|
|
193
207
|
throw new PositionError(`${label}: the range from ${rangeDesc(r)} is empty or reversed — the end does not come after the start.`)
|
|
194
208
|
}
|
|
195
|
-
if (r.expect
|
|
209
|
+
if (r.expect === undefined) throw expectRequired(label, "e.g. the range's first characters")
|
|
210
|
+
verifyExpect(text, lines, startOff, r.expect, label)
|
|
196
211
|
return { ...r, idx, label, slot, startOff, endOff }
|
|
197
212
|
})
|
|
198
213
|
const seen = new Map()
|
|
@@ -237,14 +252,14 @@ async function guarded(fn) {
|
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
// @tephra-tools
|
|
240
|
-
const server = new McpServer({ name: 'tephra', version: '0.
|
|
255
|
+
const server = new McpServer({ name: 'tephra', version: '0.5.1' })
|
|
241
256
|
|
|
242
257
|
const expectSchema = z
|
|
243
258
|
.string()
|
|
244
259
|
.min(1)
|
|
245
260
|
.max(200)
|
|
246
261
|
.optional()
|
|
247
|
-
.describe('Verification anchor: the text
|
|
262
|
+
.describe('Verification anchor — REQUIRED (only pasting into an empty file may omit it): a few characters adjacent to the position, either the text that starts at it (e.g. a range\'s first characters) or the text that ends at it (natural when appending). Up to 200 chars. On mismatch nothing is modified and the error reports where that text actually is now.')
|
|
248
263
|
|
|
249
264
|
const rangeItem = z.object({
|
|
250
265
|
start_line: z.number().int().describe('Line of the first character (1-indexed)'),
|
|
@@ -273,7 +288,7 @@ server.registerTool(
|
|
|
273
288
|
"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
289
|
'For whole lines (the common case), set whole_lines: true and give line numbers only. ' +
|
|
275
290
|
'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. ' +
|
|
276
|
-
"
|
|
291
|
+
"Every range REQUIRES `expect` (its first characters) — verified before copying, so a stale coordinate fails loudly instead of copying the wrong text. " +
|
|
277
292
|
'The clipboard is private to this session, not the operating system clipboard.',
|
|
278
293
|
inputSchema: rangesSchema,
|
|
279
294
|
},
|
|
@@ -360,7 +375,12 @@ function resolveTarget(text, lines, t, label) {
|
|
|
360
375
|
off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
361
376
|
endOff = off
|
|
362
377
|
}
|
|
363
|
-
|
|
378
|
+
// an empty file has nothing to verify against (and nothing can drift)
|
|
379
|
+
if (t.expect === undefined) {
|
|
380
|
+
if (text.length > 0) throw expectRequired(label, 'the text starting at the anchor, or ending at it when appending; only an empty destination file may omit expect')
|
|
381
|
+
} else {
|
|
382
|
+
verifyExpect(text, lines, off, t.expect, label)
|
|
383
|
+
}
|
|
364
384
|
return { off, endOff, clip, isReplace }
|
|
365
385
|
}
|
|
366
386
|
|
|
@@ -382,7 +402,8 @@ server.registerTool(
|
|
|
382
402
|
'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
403
|
'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. ' +
|
|
384
404
|
'Multiple targets are applied bottom-up, so they all use the coordinates of the file as you last read it. ' +
|
|
385
|
-
'
|
|
405
|
+
'Every target REQUIRES `expect` — text adjacent to the anchor (starting at it, or ending at it when appending to end of line/file); only an empty destination file may omit it. ' +
|
|
406
|
+
"In replace mode expect anchors at the START of the replaced range — a position check, which may extend past the range's end.",
|
|
386
407
|
inputSchema: {
|
|
387
408
|
file: z.string().describe('Absolute path of the file to paste into'),
|
|
388
409
|
targets: z
|
|
@@ -463,6 +484,7 @@ server.registerTool(
|
|
|
463
484
|
'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
485
|
'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
486
|
'The destination may sit between moved ranges (unmoved text keeps its relative order and the payload lands at that point) but never inside one — that is an error. ' +
|
|
487
|
+
'Every range and the destination REQUIRE `expect` (text adjacent to the position), verified before anything moves. ' +
|
|
466
488
|
'For whole lines, set whole_lines: true on the range and give the destination as a line number only.',
|
|
467
489
|
inputSchema: {
|
|
468
490
|
file: z.string().describe('Absolute path of the source file'),
|
|
@@ -488,7 +510,11 @@ server.registerTool(
|
|
|
488
510
|
const destText = sameFile ? text : await readFile(to.file, 'utf8')
|
|
489
511
|
const destLines = sameFile ? lines : indexLines(destText)
|
|
490
512
|
const destOff = charOffset(destText, destLines, to.line, to.char, 'destination', 1)
|
|
491
|
-
if (to.expect
|
|
513
|
+
if (to.expect === undefined) {
|
|
514
|
+
if (destText.length > 0) throw expectRequired('destination', 'the text starting at the destination, or ending at it when appending; only an empty destination file may omit expect')
|
|
515
|
+
} else {
|
|
516
|
+
verifyExpect(destText, destLines, destOff, to.expect, 'destination')
|
|
517
|
+
}
|
|
492
518
|
if (sameFile) {
|
|
493
519
|
for (const r of resolved) {
|
|
494
520
|
if (destOff > r.startOff && destOff < r.endOff) {
|