@volcanic-dev/tephra 0.1.0 → 0.2.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 +16 -4
  2. package/package.json +1 -1
  3. package/server.js +248 -62
package/README.md CHANGED
@@ -18,10 +18,22 @@ 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, 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. |
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volcanic-dev/tephra",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
@@ -13,6 +13,13 @@
13
13
  // copied (passwords, tokens) or overwrite what they're about to paste.
14
14
  // Nothing is written to disk; the buffer dies with the session.
15
15
  //
16
+ // Coordinates are hints; content is truth. Any range or paste target can
17
+ // carry an `expect` anchor that is verified before anything is touched — a
18
+ // mismatch modifies nothing and reports where that text actually is now.
19
+ // Batched ranges/targets are applied bottom-up, so one read of a file
20
+ // yields coordinates that stay valid for the whole call, and any edit that
21
+ // changes line numbering reports the shift.
22
+ //
16
23
  // Tools: copy, cut, paste, peek.
17
24
 
18
25
  import { readFile, writeFile } from 'node:fs/promises'
@@ -20,8 +27,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
20
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
21
28
  import { z } from 'zod'
22
29
 
23
- // @tephra-buffer — the private clipboard; in-memory only, per-session
24
- let clipboard = ''
30
+ // @tephra-buffer — the private clipboard; in-memory only, per-session.
31
+ // Named slots let an agent hold several snippets at once (gather many
32
+ // ranges, then paste them in any order) without round-tripping content.
33
+ const slots = new Map()
34
+
35
+ function slotList() {
36
+ return [...slots.entries()].map(([name, text]) => `"${name}" (${describe(text)})`).join(', ')
37
+ }
25
38
 
26
39
  // @tephra-positions — 1-indexed (line, char) ↔ absolute offset math
27
40
  // indexLines keeps a trailing empty line when the file ends with \n, so
@@ -57,8 +70,8 @@ function charOffset(text, lines, line, char, label, maxExtra) {
57
70
  const len = L.contentEnd - L.start
58
71
  const max = len + maxExtra
59
72
  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
73
+ const previewText = text.slice(L.start, L.contentEnd)
74
+ const shown = previewText.length > 80 ? `${previewText.slice(0, 80)}…` : previewText
62
75
  throw new PositionError(
63
76
  `${label}: char ${char} is out of range on line ${line}, which has ${len} character${len === 1 ? '' : 's'}` +
64
77
  (maxExtra ? ` (valid: 1–${max}, where ${max} means end of line)` : ` (valid: 1–${Math.max(max, 1)})`) +
@@ -68,34 +81,112 @@ function charOffset(text, lines, line, char, label, maxExtra) {
68
81
  return L.start + (char - 1)
69
82
  }
70
83
 
84
+ // positionOf: absolute offset → 1-indexed { line, char }
85
+ function positionOf(lines, off) {
86
+ let lo = 0
87
+ let hi = lines.length - 1
88
+ while (lo < hi) {
89
+ const mid = (lo + hi + 1) >> 1
90
+ if (lines[mid].start <= off) lo = mid
91
+ else hi = mid - 1
92
+ }
93
+ return { line: lo + 1, char: off - lines[lo].start + 1 }
94
+ }
95
+
96
+ // @tephra-verify — expect anchors: verify content before acting. On mismatch
97
+ // nothing is modified; the error reports what IS at the position and where
98
+ // the expected text actually lives now, so the agent re-aims in one step.
99
+ function verifyExpect(text, lines, off, expect, label) {
100
+ if (text.startsWith(expect, off)) return
101
+ const at = positionOf(lines, off)
102
+ const actual = text.slice(off, off + Math.min(Math.max(expect.length, 20), 80))
103
+ const found = []
104
+ let total = 0
105
+ let i = text.indexOf(expect)
106
+ while (i !== -1) {
107
+ total++
108
+ if (found.length < 5) found.push(positionOf(lines, i))
109
+ i = text.indexOf(expect, i + 1)
110
+ }
111
+ 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))}.`
112
+ if (total === 1) {
113
+ msg += ` The expected text IS in the file, at ${found[0].line}:${found[0].char} — the content has moved; re-aim there.`
114
+ } else if (total > 1) {
115
+ 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.`
116
+ } else {
117
+ msg += ' The expected text does not appear anywhere in the file — re-read the file before retrying.'
118
+ }
119
+ throw new PositionError(`${msg} Nothing was modified.`)
120
+ }
121
+
71
122
  // @tephra-preview — head/tail preview so results stay small even for big copies
72
123
  function preview(text) {
73
124
  if (text.length <= 300) return text
74
125
  return `${text.slice(0, 200)}\n… [${text.length - 300} chars omitted] …\n${text.slice(-100)}`
75
126
  }
76
127
 
128
+ function inlinePreview(text) {
129
+ return JSON.stringify(text.length <= 60 ? text : `${text.slice(0, 45)}…${text.slice(-10)}`)
130
+ }
131
+
77
132
  function describe(text) {
78
133
  const lines = text.length === 0 ? 0 : text.split('\n').length
79
134
  return `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`
80
135
  }
81
136
 
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 }
137
+ function newlineCount(text) {
138
+ let n = 0
139
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') n++
140
+ return n
87
141
  }
88
142
 
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}).`)
143
+ // @tephra-shiftcut/paste change the file's line numbering; say so explicitly,
144
+ // so agents (and other agents coordinating on the same file) can recalibrate
145
+ // instead of acting on stale positions.
146
+ function shiftNote(afterLine, delta) {
147
+ if (delta === 0) return ''
148
+ const dir = delta > 0 ? 'down' : 'up'
149
+ 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.`
150
+ }
151
+
152
+ // @tephra-extract — shared range resolution for copy/cut. All ranges are
153
+ // addressed against the same read of the file; cut applies them bottom-up so
154
+ // none invalidates another. Multi-range calls must name a distinct slot per
155
+ // range (defaulting them all to "default" would silently overwrite).
156
+ function resolveRanges(text, lines, ranges, forCut) {
157
+ const resolved = ranges.map((r, idx) => {
158
+ const label = ranges.length === 1 ? 'range' : `range ${idx + 1}`
159
+ let slot = r.slot
160
+ if (slot === undefined) {
161
+ 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
+ slot = 'default'
163
+ }
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
166
+ 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}).`)
168
+ }
169
+ if (r.expect !== undefined) verifyExpect(text, lines, startOff, r.expect, label)
170
+ return { ...r, idx, label, slot, startOff, endOff }
171
+ })
172
+ const seen = new Map()
173
+ for (const r of resolved) {
174
+ 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.`)
175
+ seen.set(r.slot, r.label)
176
+ }
177
+ if (forCut) {
178
+ const byOff = [...resolved].sort((a, b) => a.startOff - b.startOff)
179
+ for (let i = 1; i < byOff.length; i++) {
180
+ if (byOff[i].startOff < byOff[i - 1].endOff) {
181
+ throw new PositionError(`${byOff[i - 1].label} and ${byOff[i].label} overlap — cut ranges must be disjoint. Nothing was modified.`)
182
+ }
183
+ }
97
184
  }
98
- return { text, startOff, endOff: endOff + 1 } // end is inclusive
185
+ return resolved
186
+ }
187
+
188
+ 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)}`
99
190
  }
100
191
 
101
192
  function ok(message) {
@@ -115,52 +206,95 @@ async function guarded(fn) {
115
206
  }
116
207
 
117
208
  // @tephra-tools
118
- const server = new McpServer({ name: 'tephra', version: '0.1.0' })
209
+ const server = new McpServer({ name: 'tephra', version: '0.2.0' })
210
+
211
+ const expectSchema = z
212
+ .string()
213
+ .min(1)
214
+ .max(200)
215
+ .optional()
216
+ .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.')
217
+
218
+ const rangeItem = z.object({
219
+ 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)'),
221
+ 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)'),
223
+ 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
+ expect: expectSchema,
225
+ })
119
226
 
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)'),
227
+ const rangesSchema = {
228
+ file: z.string().describe('Absolute path of the file to read from'),
229
+ ranges: z
230
+ .array(rangeItem)
231
+ .min(1)
232
+ .max(20)
233
+ .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
234
  }
127
235
 
128
236
  server.registerTool(
129
237
  'copy',
130
238
  {
131
- title: 'Copy range to clipboard',
239
+ title: 'Copy ranges to clipboard',
132
240
  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. ' +
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. ' +
244
+ "Pass `expect` (the range's first characters) to verify the coordinates still match the content before copying. " +
136
245
  'The clipboard is private to this session, not the operating system clipboard.',
137
- inputSchema: rangeSchema,
246
+ inputSchema: rangesSchema,
138
247
  },
139
- async ({ file, start_line, start_char, end_line, end_char }) =>
248
+ async ({ file, ranges }) =>
140
249
  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)}`)
250
+ const text = await readFile(file, 'utf8')
251
+ const lines = indexLines(text)
252
+ const resolved = resolveRanges(text, lines, ranges, false)
253
+ const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
254
+ resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
255
+ if (resolved.length === 1) {
256
+ 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])}`)
258
+ }
259
+ return ok(`Copied ${resolved.length} ranges from ${file}:\n${resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n')}`)
145
260
  }),
146
261
  )
147
262
 
148
263
  server.registerTool(
149
264
  'cut',
150
265
  {
151
- title: 'Cut range to clipboard',
266
+ title: 'Cut ranges to clipboard',
152
267
  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,
268
+ 'Same as copy, but also removes the ranges from the source file (they must not overlap). ' +
269
+ '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
+ '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
+ inputSchema: rangesSchema,
156
272
  },
157
- async ({ file, start_line, start_char, end_line, end_char }) =>
273
+ async ({ file, ranges }) =>
158
274
  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)}`)
275
+ const text = await readFile(file, 'utf8')
276
+ const lines = indexLines(text)
277
+ const resolved = resolveRanges(text, lines, ranges, true)
278
+ const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
279
+ resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
280
+ let out = text
281
+ for (const r of [...resolved].sort((a, b) => b.startOff - a.startOff)) {
282
+ out = out.slice(0, r.startOff) + out.slice(r.endOff)
283
+ }
284
+ await writeFile(file, out, 'utf8')
285
+ const finalCount = indexLines(out).length
286
+ if (resolved.length === 1) {
287
+ 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])}`)
289
+ }
290
+ const removedLines = snippets.reduce((acc, s) => acc + newlineCount(s), 0)
291
+ return ok(
292
+ `Cut ${resolved.length} ranges from ${file}, applied bottom-up (file updated, ${finalCount} addressable lines):\n` +
293
+ resolved.map((r, i) => `- ${rangeSummary(r, snippets[i])}`).join('\n') +
294
+ (removedLines > 0
295
+ ? `\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.`
296
+ : ''),
297
+ )
164
298
  }),
165
299
  )
166
300
 
@@ -169,25 +303,68 @@ server.registerTool(
169
303
  {
170
304
  title: 'Paste clipboard into file',
171
305
  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.',
306
+ "Insert Tephra clipboard slot contents into a file at one or more exact positions. " +
307
+ 'Text is inserted before the character at (line, char); char may be one past the end of the line to append to it. ' +
308
+ '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 pasting. ' +
310
+ 'Existing text is never overwritten — it shifts to make room, and the result says how line numbers moved.',
175
311
  inputSchema: {
176
312
  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)'),
313
+ targets: z
314
+ .array(
315
+ z.object({
316
+ 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)'),
318
+ slot: z.string().min(1).max(64).default('default').describe('Clipboard slot to paste from (defaults to "default")'),
319
+ expect: expectSchema,
320
+ }),
321
+ )
322
+ .min(1)
323
+ .max(20)
324
+ .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
325
  },
180
326
  },
181
- async ({ file, line, char }) =>
327
+ async ({ file, targets }) =>
182
328
  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
329
  const text = await readFile(file, 'utf8')
186
330
  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)}`)
331
+ const resolved = targets.map((t, idx) => {
332
+ const label = targets.length === 1 ? 'paste position' : `target ${idx + 1}`
333
+ const clip = slots.get(t.slot)
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 }
340
+ })
341
+ let out = text
342
+ 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.off)
344
+ }
345
+ await writeFile(file, out, 'utf8')
346
+ const finalLines = indexLines(out)
347
+ // final coordinates: shift each target by everything inserted above it
348
+ 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)
350
+ return {
351
+ t,
352
+ start: positionOf(finalLines, t.off + delta),
353
+ end: positionOf(finalLines, t.off + delta + t.clip.length),
354
+ }
355
+ })
356
+ if (placed.length === 1) {
357
+ const [{ t, start, end }] = placed
358
+ 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)}`)
359
+ }
360
+ const addedLines = resolved.reduce((acc, t) => acc + newlineCount(t.clip), 0)
361
+ return ok(
362
+ `Pasted ${placed.length} targets into ${file}, applied bottom-up (file updated, ${finalLines.length} addressable lines):\n` +
363
+ 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') +
364
+ (addedLines > 0
365
+ ? `\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.`
366
+ : ''),
367
+ )
191
368
  }),
192
369
  )
193
370
 
@@ -195,13 +372,22 @@ server.registerTool(
195
372
  'peek',
196
373
  {
197
374
  title: 'Peek at clipboard',
198
- description: "Show what is currently on Tephra's clipboard (size and a preview) without modifying anything.",
199
- inputSchema: {},
375
+ description:
376
+ "Show what is currently on Tephra's clipboard without modifying anything. " +
377
+ "With a slot name: that slot's size and a preview. Without: a listing of every slot that holds text.",
378
+ inputSchema: {
379
+ slot: z.string().min(1).max(64).optional().describe('Slot to inspect; omit to list all slots'),
380
+ },
200
381
  },
201
- async () =>
382
+ async ({ slot }) =>
202
383
  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)}`)
384
+ if (slot !== undefined) {
385
+ const clip = slots.get(slot)
386
+ if (clip === undefined || clip.length === 0) return ok(`Slot "${slot}" is empty.`)
387
+ return ok(`Slot "${slot}" holds ${describe(clip)}.\n\nPreview:\n${preview(clip)}`)
388
+ }
389
+ if (slots.size === 0) return ok('The clipboard is empty — no slots hold text.')
390
+ return ok(`Slots holding text: ${slotList()}. Pass a slot name to peek to see its contents.`)
205
391
  }),
206
392
  )
207
393