@volcanic-dev/tephra 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 +9 -7
- package/package.json +1 -1
- package/server.js +48 -18
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,9 +18,9 @@ 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. Never overwrites — text shifts to make room. |
|
|
24
24
|
| `peek` | List the slots that hold text, or show one slot's size and preview. |
|
|
25
25
|
|
|
26
26
|
## Named slots
|
|
@@ -37,9 +37,11 @@ Cutting or pasting lines shifts the numbering of everything below the edit — t
|
|
|
37
37
|
|
|
38
38
|
## Addressing
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
40
|
+
Models read line numbers well and count characters badly, so the common case never counts:
|
|
41
|
+
|
|
42
|
+
- **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.
|
|
43
|
+
- Character positions are optional refinements: omitted `end_char` means end-of-line, omitted `start_char` (or paste `char`) means column 1.
|
|
44
|
+
- When given, everything is **1-indexed** and ranges are **inclusive on both ends**: copying `(2, 5)` → `(2, 7)` yields exactly 3 characters.
|
|
43
45
|
- A file ending in a newline has an addressable empty final line, so appending at end-of-file is `paste` at `(lastLine, 1)`.
|
|
44
46
|
- 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
47
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volcanic-dev/tephra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
|
@@ -153,6 +159,8 @@ function shiftNote(afterLine, delta) {
|
|
|
153
159
|
// addressed against the same read of the file; cut applies them bottom-up so
|
|
154
160
|
// none invalidates another. Multi-range calls must name a distinct slot per
|
|
155
161
|
// range (defaulting them all to "default" would silently overwrite).
|
|
162
|
+
// whole_lines ranges are linewise: they include the end line's terminator,
|
|
163
|
+
// so cutting them removes the lines entirely instead of leaving empty husks.
|
|
156
164
|
function resolveRanges(text, lines, ranges, forCut) {
|
|
157
165
|
const resolved = ranges.map((r, idx) => {
|
|
158
166
|
const label = ranges.length === 1 ? 'range' : `range ${idx + 1}`
|
|
@@ -161,10 +169,25 @@ function resolveRanges(text, lines, ranges, forCut) {
|
|
|
161
169
|
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
170
|
slot = 'default'
|
|
163
171
|
}
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
let startOff
|
|
173
|
+
let endOff
|
|
174
|
+
if (r.whole_lines) {
|
|
175
|
+
if (r.start_char !== undefined || r.end_char !== undefined) {
|
|
176
|
+
throw new PositionError(`${label}: whole_lines ranges take only start_line and end_line — omit start_char and end_char.`)
|
|
177
|
+
}
|
|
178
|
+
startOff = lineAt(lines, r.start_line, `${label} start`).start
|
|
179
|
+
lineAt(lines, r.end_line, `${label} end`)
|
|
180
|
+
endOff = r.end_line < lines.length ? lines[r.end_line].start : text.length
|
|
181
|
+
} else {
|
|
182
|
+
startOff = r.start_char === undefined
|
|
183
|
+
? lineAt(lines, r.start_line, `${label} start`).start
|
|
184
|
+
: charOffset(text, lines, r.start_line, r.start_char, `${label} start`, 0)
|
|
185
|
+
endOff = r.end_char === undefined
|
|
186
|
+
? lineAt(lines, r.end_line, `${label} end`).contentEnd
|
|
187
|
+
: charOffset(text, lines, r.end_line, r.end_char, `${label} end`, 0) + 1 // end is inclusive
|
|
188
|
+
}
|
|
166
189
|
if (endOff <= startOff) {
|
|
167
|
-
throw new PositionError(`${label}: the
|
|
190
|
+
throw new PositionError(`${label}: the range from ${rangeDesc(r)} is empty or reversed — the end does not come after the start.`)
|
|
168
191
|
}
|
|
169
192
|
if (r.expect !== undefined) verifyExpect(text, lines, startOff, r.expect, label)
|
|
170
193
|
return { ...r, idx, label, slot, startOff, endOff }
|
|
@@ -185,8 +208,13 @@ function resolveRanges(text, lines, ranges, forCut) {
|
|
|
185
208
|
return resolved
|
|
186
209
|
}
|
|
187
210
|
|
|
211
|
+
function rangeDesc(r) {
|
|
212
|
+
if (r.whole_lines) return r.start_line === r.end_line ? `line ${r.start_line}` : `lines ${r.start_line}–${r.end_line}`
|
|
213
|
+
return `${r.start_line}:${r.start_char ?? 1} → ${r.end_line}:${r.end_char ?? 'end-of-line'}`
|
|
214
|
+
}
|
|
215
|
+
|
|
188
216
|
function rangeSummary(r, snippet) {
|
|
189
|
-
return `slot "${r.slot}": ${describe(snippet)} (${r
|
|
217
|
+
return `slot "${r.slot}": ${describe(snippet)} (${rangeDesc(r)}) — ${inlinePreview(snippet)}`
|
|
190
218
|
}
|
|
191
219
|
|
|
192
220
|
function ok(message) {
|
|
@@ -206,7 +234,7 @@ async function guarded(fn) {
|
|
|
206
234
|
}
|
|
207
235
|
|
|
208
236
|
// @tephra-tools
|
|
209
|
-
const server = new McpServer({ name: 'tephra', version: '0.
|
|
237
|
+
const server = new McpServer({ name: 'tephra', version: '0.3.0' })
|
|
210
238
|
|
|
211
239
|
const expectSchema = z
|
|
212
240
|
.string()
|
|
@@ -217,9 +245,10 @@ const expectSchema = z
|
|
|
217
245
|
|
|
218
246
|
const rangeItem = z.object({
|
|
219
247
|
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)'),
|
|
248
|
+
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
249
|
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)'),
|
|
250
|
+
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.'),
|
|
251
|
+
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
252
|
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
253
|
expect: expectSchema,
|
|
225
254
|
})
|
|
@@ -238,9 +267,9 @@ server.registerTool(
|
|
|
238
267
|
{
|
|
239
268
|
title: 'Copy ranges to clipboard',
|
|
240
269
|
description:
|
|
241
|
-
"Copy one or more exact
|
|
242
|
-
'
|
|
243
|
-
'
|
|
270
|
+
"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. " +
|
|
271
|
+
'For whole lines (the common case), set whole_lines: true and give line numbers only. ' +
|
|
272
|
+
'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
273
|
"Pass `expect` (the range's first characters) to verify the coordinates still match the content before copying. " +
|
|
245
274
|
'The clipboard is private to this session, not the operating system clipboard.',
|
|
246
275
|
inputSchema: rangesSchema,
|
|
@@ -254,7 +283,7 @@ server.registerTool(
|
|
|
254
283
|
resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
|
|
255
284
|
if (resolved.length === 1) {
|
|
256
285
|
const [r] = resolved
|
|
257
|
-
return ok(`Copied ${describe(snippets[0])} from ${file} (${r
|
|
286
|
+
return ok(`Copied ${describe(snippets[0])} from ${file} (${rangeDesc(r)}) to slot "${r.slot}".\n\nPreview:\n${preview(snippets[0])}`)
|
|
258
287
|
}
|
|
259
288
|
return ok(`Copied ${resolved.length} ranges from ${file}:\n${resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n')}`)
|
|
260
289
|
}),
|
|
@@ -266,6 +295,7 @@ server.registerTool(
|
|
|
266
295
|
title: 'Cut ranges to clipboard',
|
|
267
296
|
description:
|
|
268
297
|
'Same as copy, but also removes the ranges from the source file (they must not overlap). ' +
|
|
298
|
+
'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
299
|
'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
300
|
'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
301
|
inputSchema: rangesSchema,
|
|
@@ -285,7 +315,7 @@ server.registerTool(
|
|
|
285
315
|
const finalCount = indexLines(out).length
|
|
286
316
|
if (resolved.length === 1) {
|
|
287
317
|
const [r] = resolved
|
|
288
|
-
return ok(`Cut ${describe(snippets[0])} from ${file} (${r
|
|
318
|
+
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
319
|
}
|
|
290
320
|
const removedLines = snippets.reduce((acc, s) => acc + newlineCount(s), 0)
|
|
291
321
|
return ok(
|
|
@@ -314,7 +344,7 @@ server.registerTool(
|
|
|
314
344
|
.array(
|
|
315
345
|
z.object({
|
|
316
346
|
line: z.number().int().describe('Line to paste at (1-indexed)'),
|
|
317
|
-
char: z.number().int().describe('Character position to paste at (1-indexed; the clipboard is inserted before this character; line length + 1 means end of line)'),
|
|
347
|
+
char: z.number().int().default(1).describe('Character position to paste at (1-indexed; the clipboard is inserted before this character; line length + 1 means end of line). Omit to paste at the start of the line — the right choice when pasting whole lines.'),
|
|
318
348
|
slot: z.string().min(1).max(64).default('default').describe('Clipboard slot to paste from (defaults to "default")'),
|
|
319
349
|
expect: expectSchema,
|
|
320
350
|
}),
|