@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.
- package/README.md +22 -8
- package/package.json +1 -1
- package/server.js +283 -67
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Tephra
|
|
2
2
|
|
|
3
|
-
**A clipboard for AI agents.**
|
|
3
|
+
**A clipboard for AI agents.** When a model moves code by regenerating it, it retypes 200 lines from memory — and sometimes what lands isn't quite what left. That transcription drift is a correctness bug, not just a token cost. Tephra is an MCP server for byte-exact copy and paste: name whole lines (or exact `(line, char)` ranges), copy them byte-for-byte, paste them at an exact position anywhere else. **Your agent will never subtly mangle code it's moving.**
|
|
4
4
|
|
|
5
5
|
The clipboard is a **private in-process buffer, deliberately not the OS clipboard**. An agent can never read what you last copied (passwords, tokens, anything) and can never overwrite what you're about to paste. Nothing touches disk; the buffer dies with the session.
|
|
6
6
|
|
|
@@ -18,16 +18,30 @@ 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, 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
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
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.
|
|
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:
|
|
6
|
-
//
|
|
7
|
-
// exact range —
|
|
8
|
-
// inclusive — copy it byte-for-byte, and paste
|
|
9
|
-
// another file.
|
|
5
|
+
// Models are bad at retyping: regenerating 200 lines to move them invites
|
|
6
|
+
// transcription drift — a correctness bug, not just a token cost. Tephra
|
|
7
|
+
// lets an agent name an exact range — whole lines, or (line, char) to
|
|
8
|
+
// (line, char), 1-indexed and inclusive — copy it byte-for-byte, and paste
|
|
9
|
+
// it at an exact position in another file.
|
|
10
|
+
//
|
|
11
|
+
// Models are also bad at counting characters within a line, so character
|
|
12
|
+
// positions are optional everywhere: whole_lines ranges take line numbers
|
|
13
|
+
// only (linewise, including the trailing newline, so cuts leave no empty
|
|
14
|
+
// husk), an omitted end_char means end-of-line, an omitted start_char or
|
|
15
|
+
// paste char means column 1.
|
|
10
16
|
//
|
|
11
17
|
// The clipboard is a private in-process buffer, deliberately NOT the OS
|
|
12
18
|
// clipboard: an agent must not be able to read whatever the human last
|
|
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
|
|
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
|
|
61
|
-
const shown =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
149
|
+
// @tephra-shift — cut/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
|
|
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.
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
268
|
+
title: 'Copy ranges to clipboard',
|
|
132
269
|
description:
|
|
133
|
-
"Copy
|
|
134
|
-
'
|
|
135
|
-
'
|
|
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:
|
|
275
|
+
inputSchema: rangesSchema,
|
|
138
276
|
},
|
|
139
|
-
async ({ file,
|
|
277
|
+
async ({ file, ranges }) =>
|
|
140
278
|
guarded(async () => {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
295
|
+
title: 'Cut ranges to clipboard',
|
|
152
296
|
description:
|
|
153
|
-
'Same as copy, but also removes the
|
|
154
|
-
'
|
|
155
|
-
|
|
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,
|
|
303
|
+
async ({ file, ranges }) =>
|
|
158
304
|
guarded(async () => {
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
173
|
-
'
|
|
174
|
-
'
|
|
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
|
-
|
|
178
|
-
|
|
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,
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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:
|
|
199
|
-
|
|
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 (
|
|
204
|
-
|
|
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
|
|