@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.
Files changed (3) hide show
  1. package/README.md +9 -7
  2. package/package.json +1 -1
  3. package/server.js +48 -18
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tephra
2
2
 
3
- **A clipboard for AI agents.** Tephra is an MCP server that lets a model copy an exact character range from a file start `(line, char)` to end `(line, char)` and paste it at an exact position somewhere else. Byte-for-byte, no retyping, no transcription drift, no tokens spent regenerating code that already exists.
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, start_char, end_line, 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). |
23
- | `paste` | Insert clipboard slots into a file at one or more targets `{line, char, slot?, expect?}` — before the character at `(line, char)`; `char` may be line length + 1 to append to a line. Never overwrites — text shifts to make room. |
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
- - Lines and characters are **1-indexed**: the first character of a file is `(1, 1)`.
41
- - Copy/cut ranges are **inclusive on both ends**: copying `(2, 5)` → `(2, 7)` yields exactly 3 characters.
42
- - To copy whole lines 1014: start `(10, 1)`, end `(14, <length of line 14>)`.
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.2.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: moving 80 lines of code by regenerating them
6
- // invites transcription drift and burns tokens. Tephra lets an agent name an
7
- // exact range — start (line, char) to end (line, char), both 1-indexed and
8
- // inclusive — copy it byte-for-byte, and paste it at an exact position in
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
- const startOff = charOffset(text, lines, r.start_line, r.start_char, `${label} start`, 0)
165
- const endOff = charOffset(text, lines, r.end_line, r.end_char, `${label} end`, 0) + 1 // end is inclusive
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 end position (line ${r.end_line}, char ${r.end_char}) comes before the start position (line ${r.start_line}, char ${r.start_char}).`)
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.start_line}:${r.start_char} → ${r.end_line}:${r.end_char}) — ${inlinePreview(snippet)}`
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.2.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 character ranges from a file into Tephra's clipboard slots, byte-for-byte. " +
242
- 'Each range runs (start_line, start_char) → (end_line, end_char), inclusive on both ends, 1-indexed; newlines inside a range are included. ' +
243
- 'Use this instead of retyping text you intend to move or duplicate. ' +
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.start_line}:${r.start_char} → ${r.end_line}:${r.end_char}) to slot "${r.slot}".\n\nPreview:\n${preview(snippets[0])}`)
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.start_line}:${r.start_char} → ${r.end_line}:${r.end_char}) 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])}`)
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
  }),