@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.
- package/README.md +16 -4
- package/package.json +1 -1
- 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 `
|
|
22
|
-
| `cut` | Same as `copy`, but removes the
|
|
23
|
-
| `paste` | Insert
|
|
24
|
-
| `peek` |
|
|
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.
|
|
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
|
|
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
|
|
61
|
-
const shown =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
// @tephra-shift — cut/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
|
|
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.
|
|
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
|
|
121
|
-
file: z.string().describe('Absolute path of the file to
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
239
|
+
title: 'Copy ranges to clipboard',
|
|
132
240
|
description:
|
|
133
|
-
"Copy
|
|
134
|
-
'
|
|
135
|
-
'
|
|
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:
|
|
246
|
+
inputSchema: rangesSchema,
|
|
138
247
|
},
|
|
139
|
-
async ({ file,
|
|
248
|
+
async ({ file, ranges }) =>
|
|
140
249
|
guarded(async () => {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
266
|
+
title: 'Cut ranges to clipboard',
|
|
152
267
|
description:
|
|
153
|
-
'Same as copy, but also removes the
|
|
154
|
-
'
|
|
155
|
-
|
|
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,
|
|
273
|
+
async ({ file, ranges }) =>
|
|
158
274
|
guarded(async () => {
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
173
|
-
'
|
|
174
|
-
'
|
|
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
|
-
|
|
178
|
-
|
|
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,
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
199
|
-
|
|
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 (
|
|
204
|
-
|
|
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
|
|