@volcanic-dev/tephra 0.1.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 +22 -8
  2. package/package.json +1 -1
  3. package/server.js +283 -67
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,16 +18,30 @@ npx -y @volcanic-dev/tephra
18
18
 
19
19
  | Tool | What it does |
20
20
  |------|--------------|
21
- | `copy` | Copy `(start_line, start_char)` `(end_line, end_char)` from a file to the clipboard. Both ends inclusive, 1-indexed. Newlines inside the range are included. |
22
- | `cut` | Same as `copy`, but removes the range from the source file. |
23
- | `paste` | Insert the clipboard into a file before the character at `(line, char)`. `char` may be line length + 1 to append to a line. Never overwrites — text shifts to make room. |
24
- | `peek` | Show the clipboard's current size and a preview. |
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
+ | `peek` | List the slots that hold text, or show one slot's size and preview. |
25
+
26
+ ## Named slots
27
+
28
+ 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.
29
+
30
+ ## Staying robust as line numbers shift
31
+
32
+ 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:
33
+
34
+ - **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.
35
+ - **`expect` anchors — coordinates are hints, content is truth.** Pass the text you believe is at the position (up to 200 chars). On mismatch, nothing is modified and the error reports what is actually there and where your expected text lives now (`"the content has moved; re-aim at 43:12"`), so recovery is one round-trip. This catches *every* source of drift, including edits made outside Tephra.
36
+ - **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.
25
37
 
26
38
  ## Addressing
27
39
 
28
- - Lines and characters are **1-indexed**: the first character of a file is `(1, 1)`.
29
- - Copy/cut ranges are **inclusive on both ends**: copying `(2, 5)` → `(2, 7)` yields exactly 3 characters.
30
- - 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.
31
45
  - A file ending in a newline has an addressable empty final line, so appending at end-of-file is `paste` at `(lastLine, 1)`.
32
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.
33
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volcanic-dev/tephra",
3
- "version": "0.1.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,17 +2,30 @@
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
13
19
  // copied (passwords, tokens) or overwrite what they're about to paste.
14
20
  // Nothing is written to disk; the buffer dies with the session.
15
21
  //
22
+ // Coordinates are hints; content is truth. Any range or paste target can
23
+ // carry an `expect` anchor that is verified before anything is touched — a
24
+ // mismatch modifies nothing and reports where that text actually is now.
25
+ // Batched ranges/targets are applied bottom-up, so one read of a file
26
+ // yields coordinates that stay valid for the whole call, and any edit that
27
+ // changes line numbering reports the shift.
28
+ //
16
29
  // Tools: copy, cut, paste, peek.
17
30
 
18
31
  import { readFile, writeFile } from 'node:fs/promises'
@@ -20,8 +33,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
20
33
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
21
34
  import { z } from 'zod'
22
35
 
23
- // @tephra-buffer — the private clipboard; in-memory only, per-session
24
- let clipboard = ''
36
+ // @tephra-buffer — the private clipboard; in-memory only, per-session.
37
+ // Named slots let an agent hold several snippets at once (gather many
38
+ // ranges, then paste them in any order) without round-tripping content.
39
+ const slots = new Map()
40
+
41
+ function slotList() {
42
+ return [...slots.entries()].map(([name, text]) => `"${name}" (${describe(text)})`).join(', ')
43
+ }
25
44
 
26
45
  // @tephra-positions — 1-indexed (line, char) ↔ absolute offset math
27
46
  // indexLines keeps a trailing empty line when the file ends with \n, so
@@ -57,8 +76,8 @@ function charOffset(text, lines, line, char, label, maxExtra) {
57
76
  const len = L.contentEnd - L.start
58
77
  const max = len + maxExtra
59
78
  if (!Number.isInteger(char) || char < 1 || char > max) {
60
- const preview = text.slice(L.start, L.contentEnd)
61
- const shown = preview.length > 80 ? `${preview.slice(0, 80)}…` : preview
79
+ const previewText = text.slice(L.start, L.contentEnd)
80
+ const shown = previewText.length > 80 ? `${previewText.slice(0, 80)}…` : previewText
62
81
  throw new PositionError(
63
82
  `${label}: char ${char} is out of range on line ${line}, which has ${len} character${len === 1 ? '' : 's'}` +
64
83
  (maxExtra ? ` (valid: 1–${max}, where ${max} means end of line)` : ` (valid: 1–${Math.max(max, 1)})`) +
@@ -68,34 +87,134 @@ function charOffset(text, lines, line, char, label, maxExtra) {
68
87
  return L.start + (char - 1)
69
88
  }
70
89
 
90
+ // positionOf: absolute offset → 1-indexed { line, char }
91
+ function positionOf(lines, off) {
92
+ let lo = 0
93
+ let hi = lines.length - 1
94
+ while (lo < hi) {
95
+ const mid = (lo + hi + 1) >> 1
96
+ if (lines[mid].start <= off) lo = mid
97
+ else hi = mid - 1
98
+ }
99
+ return { line: lo + 1, char: off - lines[lo].start + 1 }
100
+ }
101
+
102
+ // @tephra-verify — expect anchors: verify content before acting. On mismatch
103
+ // nothing is modified; the error reports what IS at the position and where
104
+ // the expected text actually lives now, so the agent re-aims in one step.
105
+ function verifyExpect(text, lines, off, expect, label) {
106
+ if (text.startsWith(expect, off)) return
107
+ const at = positionOf(lines, off)
108
+ const actual = text.slice(off, off + Math.min(Math.max(expect.length, 20), 80))
109
+ const found = []
110
+ let total = 0
111
+ let i = text.indexOf(expect)
112
+ while (i !== -1) {
113
+ total++
114
+ if (found.length < 5) found.push(positionOf(lines, i))
115
+ i = text.indexOf(expect, i + 1)
116
+ }
117
+ let msg = `${label}: the file does not have the expected text at ${at.line}:${at.char}. It has ${JSON.stringify(actual)} there, not ${JSON.stringify(expect.slice(0, 80))}.`
118
+ if (total === 1) {
119
+ msg += ` The expected text IS in the file, at ${found[0].line}:${found[0].char} — the content has moved; re-aim there.`
120
+ } else if (total > 1) {
121
+ msg += ` The expected text appears ${total} times: at ${found.map((p) => `${p.line}:${p.char}`).join(', ')}${total > found.length ? ', …' : ''}. Pick the right one and re-aim.`
122
+ } else {
123
+ msg += ' The expected text does not appear anywhere in the file — re-read the file before retrying.'
124
+ }
125
+ throw new PositionError(`${msg} Nothing was modified.`)
126
+ }
127
+
71
128
  // @tephra-preview — head/tail preview so results stay small even for big copies
72
129
  function preview(text) {
73
130
  if (text.length <= 300) return text
74
131
  return `${text.slice(0, 200)}\n… [${text.length - 300} chars omitted] …\n${text.slice(-100)}`
75
132
  }
76
133
 
134
+ function inlinePreview(text) {
135
+ return JSON.stringify(text.length <= 60 ? text : `${text.slice(0, 45)}…${text.slice(-10)}`)
136
+ }
137
+
77
138
  function describe(text) {
78
139
  const lines = text.length === 0 ? 0 : text.split('\n').length
79
140
  return `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`
80
141
  }
81
142
 
82
- // endPosition: (line, char) of the character right after `text` inserted at (line, char)
83
- function endPosition(startLine, startChar, text) {
84
- const parts = text.split('\n')
85
- if (parts.length === 1) return { line: startLine, char: startChar + text.length }
86
- return { line: startLine + parts.length - 1, char: parts[parts.length - 1].length + 1 }
143
+ function newlineCount(text) {
144
+ let n = 0
145
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') n++
146
+ return n
87
147
  }
88
148
 
89
- // @tephra-extractshared range extraction for copy/cut
90
- async function extractRange(file, startLine, startChar, endLine, endChar) {
91
- const text = await readFile(file, 'utf8')
92
- const lines = indexLines(text)
93
- const startOff = charOffset(text, lines, startLine, startChar, 'start', 0)
94
- const endOff = charOffset(text, lines, endLine, endChar, 'end', 0)
95
- if (endOff < startOff) {
96
- throw new PositionError(`The end position (line ${endLine}, char ${endChar}) comes before the start position (line ${startLine}, char ${startChar}).`)
149
+ // @tephra-shiftcut/paste change the file's line numbering; say so explicitly,
150
+ // so agents (and other agents coordinating on the same file) can recalibrate
151
+ // instead of acting on stale positions.
152
+ function shiftNote(afterLine, delta) {
153
+ if (delta === 0) return ''
154
+ const dir = delta > 0 ? 'down' : 'up'
155
+ return `\nLine numbers have shifted: every line that was below line ${afterLine} moved ${dir} by ${Math.abs(delta)} (former line ${afterLine + 1} is now line ${afterLine + 1 + delta}). Recompute any positions you noted earlier before using them.`
156
+ }
157
+
158
+ // @tephra-extract — shared range resolution for copy/cut. All ranges are
159
+ // addressed against the same read of the file; cut applies them bottom-up so
160
+ // none invalidates another. Multi-range calls must name a distinct slot per
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.
164
+ function resolveRanges(text, lines, ranges, forCut) {
165
+ const resolved = ranges.map((r, idx) => {
166
+ const label = ranges.length === 1 ? 'range' : `range ${idx + 1}`
167
+ let slot = r.slot
168
+ if (slot === undefined) {
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).`)
170
+ slot = 'default'
171
+ }
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
+ }
189
+ if (endOff <= startOff) {
190
+ throw new PositionError(`${label}: the range from ${rangeDesc(r)} is empty or reversed — the end does not come after the start.`)
191
+ }
192
+ if (r.expect !== undefined) verifyExpect(text, lines, startOff, r.expect, label)
193
+ return { ...r, idx, label, slot, startOff, endOff }
194
+ })
195
+ const seen = new Map()
196
+ for (const r of resolved) {
197
+ if (seen.has(r.slot)) throw new PositionError(`${r.label} and ${seen.get(r.slot)} both target slot "${r.slot}" — one would overwrite the other. Use distinct slots.`)
198
+ seen.set(r.slot, r.label)
199
+ }
200
+ if (forCut) {
201
+ const byOff = [...resolved].sort((a, b) => a.startOff - b.startOff)
202
+ for (let i = 1; i < byOff.length; i++) {
203
+ if (byOff[i].startOff < byOff[i - 1].endOff) {
204
+ throw new PositionError(`${byOff[i - 1].label} and ${byOff[i].label} overlap — cut ranges must be disjoint. Nothing was modified.`)
205
+ }
206
+ }
97
207
  }
98
- return { text, startOff, endOff: endOff + 1 } // end is inclusive
208
+ return resolved
209
+ }
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
+
216
+ function rangeSummary(r, snippet) {
217
+ return `slot "${r.slot}": ${describe(snippet)} (${rangeDesc(r)}) — ${inlinePreview(snippet)}`
99
218
  }
100
219
 
101
220
  function ok(message) {
@@ -115,52 +234,97 @@ async function guarded(fn) {
115
234
  }
116
235
 
117
236
  // @tephra-tools
118
- const server = new McpServer({ name: 'tephra', version: '0.1.0' })
237
+ const server = new McpServer({ name: 'tephra', version: '0.3.0' })
238
+
239
+ const expectSchema = z
240
+ .string()
241
+ .min(1)
242
+ .max(200)
243
+ .optional()
244
+ .describe('Verification anchor: the text you believe is at this position (up to 200 chars). On mismatch nothing is modified and the error reports where that text actually is now. Strongly recommended whenever the file may have changed since you read it.')
119
245
 
120
- const rangeSchema = {
121
- file: z.string().describe('Absolute path of the file to copy from'),
122
- start_line: z.number().int().describe('Line of the first character to copy (1-indexed)'),
123
- start_char: z.number().int().describe('Character position on start_line of the first character to copy (1-indexed)'),
124
- end_line: z.number().int().describe('Line of the last character to copy (1-indexed, inclusive)'),
125
- end_char: z.number().int().describe('Character position on end_line of the last character to copy (1-indexed, inclusive)'),
246
+ const rangeItem = z.object({
247
+ start_line: z.number().int().describe('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.'),
249
+ end_line: z.number().int().describe('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.'),
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.'),
253
+ expect: expectSchema,
254
+ })
255
+
256
+ const rangesSchema = {
257
+ file: z.string().describe('Absolute path of the file to read from'),
258
+ ranges: z
259
+ .array(rangeItem)
260
+ .min(1)
261
+ .max(20)
262
+ .describe('One or more ranges, all addressed against the file as it is right now — they are applied bottom-up, so earlier ranges never invalidate later ones.'),
126
263
  }
127
264
 
128
265
  server.registerTool(
129
266
  'copy',
130
267
  {
131
- title: 'Copy range to clipboard',
268
+ title: 'Copy ranges to clipboard',
132
269
  description:
133
- "Copy an exact character range from a file to Tephra's clipboard, byte-for-byte. " +
134
- 'The range runs from (start_line, start_char) through (end_line, end_char), inclusive on both ends; ' +
135
- 'newlines inside the range are included. 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. ' +
273
+ "Pass `expect` (the range's first characters) to verify the coordinates still match the content before copying. " +
136
274
  'The clipboard is private to this session, not the operating system clipboard.',
137
- inputSchema: rangeSchema,
275
+ inputSchema: rangesSchema,
138
276
  },
139
- async ({ file, start_line, start_char, end_line, end_char }) =>
277
+ async ({ file, ranges }) =>
140
278
  guarded(async () => {
141
- const { text, startOff, endOff } = await extractRange(file, start_line, start_char, end_line, end_char)
142
- const snippet = text.slice(startOff, endOff)
143
- clipboard = snippet
144
- return ok(`Copied ${describe(snippet)} from ${file} (${start_line}:${start_char} → ${end_line}:${end_char}) to the clipboard.\n\nPreview:\n${preview(snippet)}`)
279
+ const text = await readFile(file, 'utf8')
280
+ const lines = indexLines(text)
281
+ const resolved = resolveRanges(text, lines, ranges, false)
282
+ const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
283
+ resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
284
+ if (resolved.length === 1) {
285
+ const [r] = resolved
286
+ return ok(`Copied ${describe(snippets[0])} from ${file} (${rangeDesc(r)}) to slot "${r.slot}".\n\nPreview:\n${preview(snippets[0])}`)
287
+ }
288
+ return ok(`Copied ${resolved.length} ranges from ${file}:\n${resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n')}`)
145
289
  }),
146
290
  )
147
291
 
148
292
  server.registerTool(
149
293
  'cut',
150
294
  {
151
- title: 'Cut range to clipboard',
295
+ title: 'Cut ranges to clipboard',
152
296
  description:
153
- 'Same as copy, but also removes the range from the source file. ' +
154
- 'The range is inclusive on both ends. The file is written back immediately.',
155
- inputSchema: rangeSchema,
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. ' +
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. ' +
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.',
301
+ inputSchema: rangesSchema,
156
302
  },
157
- async ({ file, start_line, start_char, end_line, end_char }) =>
303
+ async ({ file, ranges }) =>
158
304
  guarded(async () => {
159
- const { text, startOff, endOff } = await extractRange(file, start_line, start_char, end_line, end_char)
160
- const snippet = text.slice(startOff, endOff)
161
- clipboard = snippet
162
- await writeFile(file, text.slice(0, startOff) + text.slice(endOff), 'utf8')
163
- return ok(`Cut ${describe(snippet)} from ${file} (${start_line}:${start_char} → ${end_line}:${end_char}) to the clipboard. The file has been updated.\n\nPreview:\n${preview(snippet)}`)
305
+ const text = await readFile(file, 'utf8')
306
+ const lines = indexLines(text)
307
+ const resolved = resolveRanges(text, lines, ranges, true)
308
+ const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
309
+ resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
310
+ let out = text
311
+ for (const r of [...resolved].sort((a, b) => b.startOff - a.startOff)) {
312
+ out = out.slice(0, r.startOff) + out.slice(r.endOff)
313
+ }
314
+ await writeFile(file, out, 'utf8')
315
+ const finalCount = indexLines(out).length
316
+ if (resolved.length === 1) {
317
+ const [r] = resolved
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])}`)
319
+ }
320
+ const removedLines = snippets.reduce((acc, s) => acc + newlineCount(s), 0)
321
+ return ok(
322
+ `Cut ${resolved.length} ranges from ${file}, applied bottom-up (file updated, ${finalCount} addressable lines):\n` +
323
+ resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n') +
324
+ (removedLines > 0
325
+ ? `\n${removedLines} line${removedLines === 1 ? '' : 's'} of numbering removed in total — line numbers below each cut have shifted up; recompute any positions you noted earlier before using them.`
326
+ : ''),
327
+ )
164
328
  }),
165
329
  )
166
330
 
@@ -169,25 +333,68 @@ server.registerTool(
169
333
  {
170
334
  title: 'Paste clipboard into file',
171
335
  description:
172
- "Insert Tephra's clipboard contents into a file at an exact position. " +
173
- 'The clipboard text is inserted before the character at (line, char); char may be one past the end of the line to append to it. ' +
174
- 'Existing text is never overwritten it shifts to make room.',
336
+ "Insert Tephra clipboard slot contents into a file at one or more exact positions. " +
337
+ 'Text is inserted before the character at (line, char); char may be one past the end of the line to append to it. ' +
338
+ 'Multiple targets are applied bottom-up, so they all use the coordinates of the file as you last read it. ' +
339
+ 'Pass `expect` (the text that currently begins at the position) to verify before pasting. ' +
340
+ 'Existing text is never overwritten — it shifts to make room, and the result says how line numbers moved.',
175
341
  inputSchema: {
176
342
  file: z.string().describe('Absolute path of the file to paste into'),
177
- line: z.number().int().describe('Line to paste at (1-indexed)'),
178
- 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)'),
343
+ targets: z
344
+ .array(
345
+ z.object({
346
+ line: z.number().int().describe('Line to paste at (1-indexed)'),
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.'),
348
+ slot: z.string().min(1).max(64).default('default').describe('Clipboard slot to paste from (defaults to "default")'),
349
+ expect: expectSchema,
350
+ }),
351
+ )
352
+ .min(1)
353
+ .max(20)
354
+ .describe('One or more paste positions, all addressed against the file as it is right now — they are applied bottom-up, so earlier targets never invalidate later ones.'),
179
355
  },
180
356
  },
181
- async ({ file, line, char }) =>
357
+ async ({ file, targets }) =>
182
358
  guarded(async () => {
183
- const clip = clipboard
184
- if (clip.length === 0) return fail('The clipboard is empty — nothing to paste. Use copy or cut first.')
185
359
  const text = await readFile(file, 'utf8')
186
360
  const lines = indexLines(text)
187
- const off = charOffset(text, lines, line, char, 'paste position', 1)
188
- await writeFile(file, text.slice(0, off) + clip + text.slice(off), 'utf8')
189
- const end = endPosition(line, char, clip)
190
- return ok(`Pasted ${describe(clip)} into ${file} at ${line}:${char}. The inserted text now spans ${line}:${char} → ${end.line}:${end.char} (exclusive end).\n\nPreview:\n${preview(clip)}`)
361
+ const resolved = targets.map((t, idx) => {
362
+ const label = targets.length === 1 ? 'paste position' : `target ${idx + 1}`
363
+ const clip = slots.get(t.slot)
364
+ if (clip === undefined || clip.length === 0) {
365
+ 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.'}`)
366
+ }
367
+ const off = charOffset(text, lines, t.line, t.char, label, 1)
368
+ if (t.expect !== undefined) verifyExpect(text, lines, off, t.expect, label)
369
+ return { ...t, idx, label, off, clip }
370
+ })
371
+ let out = text
372
+ for (const t of [...resolved].sort((a, b) => b.off - a.off || b.idx - a.idx)) {
373
+ out = out.slice(0, t.off) + t.clip + out.slice(t.off)
374
+ }
375
+ await writeFile(file, out, 'utf8')
376
+ const finalLines = indexLines(out)
377
+ // final coordinates: shift each target by everything inserted above it
378
+ const placed = resolved.map((t) => {
379
+ const delta = resolved.reduce((acc, o) => acc + (o.off < t.off || (o.off === t.off && o.idx < t.idx) ? o.clip.length : 0), 0)
380
+ return {
381
+ t,
382
+ start: positionOf(finalLines, t.off + delta),
383
+ end: positionOf(finalLines, t.off + delta + t.clip.length),
384
+ }
385
+ })
386
+ if (placed.length === 1) {
387
+ const [{ t, start, end }] = placed
388
+ return ok(`Pasted ${describe(t.clip)} from slot "${t.slot}" into ${file} at ${t.line}:${t.char}. The inserted text now spans ${start.line}:${start.char} → ${end.line}:${end.char} (exclusive end).${shiftNote(t.line, newlineCount(t.clip))}\n\nPreview:\n${preview(t.clip)}`)
389
+ }
390
+ const addedLines = resolved.reduce((acc, t) => acc + newlineCount(t.clip), 0)
391
+ return ok(
392
+ `Pasted ${placed.length} targets into ${file}, applied bottom-up (file updated, ${finalLines.length} addressable lines):\n` +
393
+ placed.map(({ t, start, end }) => `- slot "${t.slot}" (${describe(t.clip)}) at ${t.line}:${t.char} → final span ${start.line}:${start.char} → ${end.line}:${end.char}`).join('\n') +
394
+ (addedLines > 0
395
+ ? `\n${addedLines} line${addedLines === 1 ? '' : 's'} of numbering inserted in total — line numbers below each paste have shifted down; recompute any positions you noted earlier before using them.`
396
+ : ''),
397
+ )
191
398
  }),
192
399
  )
193
400
 
@@ -195,13 +402,22 @@ server.registerTool(
195
402
  'peek',
196
403
  {
197
404
  title: 'Peek at clipboard',
198
- description: "Show what is currently on Tephra's clipboard (size and a preview) without modifying anything.",
199
- inputSchema: {},
405
+ description:
406
+ "Show what is currently on Tephra's clipboard without modifying anything. " +
407
+ "With a slot name: that slot's size and a preview. Without: a listing of every slot that holds text.",
408
+ inputSchema: {
409
+ slot: z.string().min(1).max(64).optional().describe('Slot to inspect; omit to list all slots'),
410
+ },
200
411
  },
201
- async () =>
412
+ async ({ slot }) =>
202
413
  guarded(async () => {
203
- if (clipboard.length === 0) return ok('The clipboard is empty.')
204
- return ok(`Clipboard holds ${describe(clipboard)}.\n\nPreview:\n${preview(clipboard)}`)
414
+ if (slot !== undefined) {
415
+ const clip = slots.get(slot)
416
+ if (clip === undefined || clip.length === 0) return ok(`Slot "${slot}" is empty.`)
417
+ return ok(`Slot "${slot}" holds ${describe(clip)}.\n\nPreview:\n${preview(clip)}`)
418
+ }
419
+ if (slots.size === 0) return ok('The clipboard is empty — no slots hold text.')
420
+ return ok(`Slots holding text: ${slotList()}. Pass a slot name to peek to see its contents.`)
205
421
  }),
206
422
  )
207
423