opencodekit 0.17.7 → 0.17.8
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/dist/index.js +1 -1
- package/dist/template/.opencode/AGENTS.md +2 -1
- package/dist/template/.opencode/agent/general.md +1 -1
- package/dist/template/.opencode/agent/painter.md +1 -1
- package/dist/template/.opencode/agent/vision.md +1 -1
- package/dist/template/.opencode/opencode.json +2 -5
- package/dist/template/.opencode/package.json +1 -1
- package/dist/template/.opencode/plugin/sessions.ts +71 -266
- package/package.json +1 -1
- package/dist/template/.opencode/agent/looker.md +0 -102
- package/dist/template/.opencode/plugin/hashline.ts.bak +0 -757
- package/dist/template/.opencode/plugin/swarm-enforcer.ts +0 -371
- package/dist/template/.opencode/tool/swarm.ts +0 -605
|
@@ -1,757 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
-
import { tool } from "@opencode-ai/plugin/tool";
|
|
5
|
-
|
|
6
|
-
const z = tool.schema;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Max line length — must match OpenCode's read tool truncation.
|
|
10
|
-
* Lines longer than this are displayed as `line.substring(0, 2000) + "..."`.
|
|
11
|
-
* We must hash the truncated form so computeFileHashes matches the read hook.
|
|
12
|
-
*/
|
|
13
|
-
const MAX_LINE_LENGTH = 2000;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* djb2 hash of trimmed line content, truncated to 3 hex chars.
|
|
17
|
-
* 3 hex chars = 4096 values. Collisions are rare and disambiguated by line number.
|
|
18
|
-
*/
|
|
19
|
-
function hashLine(content: string): string {
|
|
20
|
-
const trimmed = content.trimEnd();
|
|
21
|
-
let h = 5381;
|
|
22
|
-
for (let i = 0; i < trimmed.length; i++) {
|
|
23
|
-
h = ((h << 5) + h + trimmed.charCodeAt(i)) | 0;
|
|
24
|
-
}
|
|
25
|
-
return (h >>> 0).toString(16).slice(-3).padStart(3, "0");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Normalize a hash reference from model output.
|
|
30
|
-
* Handles: extra whitespace, trailing "|", spaces around ":"
|
|
31
|
-
* e.g. " 141:d81 " → "141:d81", "141:d81|" → "141:d81", "141: d81" → "141:d81"
|
|
32
|
-
*/
|
|
33
|
-
function normalizeHashRef(ref: string): string {
|
|
34
|
-
ref = ref.trim();
|
|
35
|
-
if (ref.endsWith("|")) ref = ref.slice(0, -1).trimEnd();
|
|
36
|
-
const colonIdx = ref.indexOf(":");
|
|
37
|
-
if (colonIdx > 0) {
|
|
38
|
-
const lineNum = ref.slice(0, colonIdx).trim();
|
|
39
|
-
const hash = ref.slice(colonIdx + 1).trim();
|
|
40
|
-
return `${lineNum}:${hash}`;
|
|
41
|
-
}
|
|
42
|
-
return ref;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
|
|
46
|
-
const fileHashes = new Map<string, Map<string, string>>();
|
|
47
|
-
|
|
48
|
-
/** Track file paths for apply_patch hash invalidation across before/after hooks */
|
|
49
|
-
let pendingPatchFilePaths: string[] = [];
|
|
50
|
-
|
|
51
|
-
interface HashlineEdit {
|
|
52
|
-
filePath: string;
|
|
53
|
-
startHash?: string;
|
|
54
|
-
endHash?: string;
|
|
55
|
-
afterHash?: string;
|
|
56
|
-
content: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Normalize all hash refs in an edit object */
|
|
60
|
-
function normalizeEdit(edit: HashlineEdit): HashlineEdit {
|
|
61
|
-
return {
|
|
62
|
-
...edit,
|
|
63
|
-
startHash: edit.startHash ? normalizeHashRef(edit.startHash) : undefined,
|
|
64
|
-
endHash: edit.endHash ? normalizeHashRef(edit.endHash) : undefined,
|
|
65
|
-
afterHash: edit.afterHash ? normalizeHashRef(edit.afterHash) : undefined,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
70
|
-
function resolvePath(filePath: string): string {
|
|
71
|
-
if (path.isAbsolute(filePath)) return path.normalize(filePath);
|
|
72
|
-
return path.resolve(directory, filePath);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Read file from disk and compute fresh hashes.
|
|
77
|
-
* Lines are truncated before hashing to match OpenCode's read tool output.
|
|
78
|
-
* Full (untruncated) content is stored for building oldString/patchText.
|
|
79
|
-
*/
|
|
80
|
-
function computeFileHashes(filePath: string): Map<string, string> {
|
|
81
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
82
|
-
const lines = content.split("\n");
|
|
83
|
-
const hashes = new Map<string, string>();
|
|
84
|
-
for (let i = 0; i < lines.length; i++) {
|
|
85
|
-
// Truncate to match OpenCode's read tool (read.ts MAX_LINE_LENGTH)
|
|
86
|
-
const displayed =
|
|
87
|
-
lines[i].length > MAX_LINE_LENGTH
|
|
88
|
-
? lines[i].substring(0, MAX_LINE_LENGTH) + "..."
|
|
89
|
-
: lines[i];
|
|
90
|
-
const hash = hashLine(displayed);
|
|
91
|
-
hashes.set(`${i + 1}:${hash}`, lines[i]);
|
|
92
|
-
}
|
|
93
|
-
fileHashes.set(filePath, hashes);
|
|
94
|
-
return hashes;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Get line content by line number from hash map */
|
|
98
|
-
function getLineByNumber(
|
|
99
|
-
hashes: Map<string, string>,
|
|
100
|
-
lineNum: number,
|
|
101
|
-
): string | undefined {
|
|
102
|
-
for (const [ref, content] of hashes) {
|
|
103
|
-
if (ref.startsWith(`${lineNum}:`)) return content;
|
|
104
|
-
}
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Find the actual hash ref for a line number (if it exists) */
|
|
109
|
-
function getHashRefByLineNumber(
|
|
110
|
-
hashes: Map<string, string>,
|
|
111
|
-
lineNum: number,
|
|
112
|
-
): string | undefined {
|
|
113
|
-
for (const ref of hashes.keys()) {
|
|
114
|
-
if (ref.startsWith(`${lineNum}:`)) return ref;
|
|
115
|
-
}
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Validate a hash reference exists, re-reading file once if stale */
|
|
120
|
-
function validateHash(
|
|
121
|
-
filePath: string,
|
|
122
|
-
hashRef: string,
|
|
123
|
-
hashes: Map<string, string>,
|
|
124
|
-
): Map<string, string> {
|
|
125
|
-
if (hashes.has(hashRef)) return hashes;
|
|
126
|
-
|
|
127
|
-
// Re-read file and recompute hashes
|
|
128
|
-
try {
|
|
129
|
-
hashes = computeFileHashes(filePath);
|
|
130
|
-
} catch {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Cannot read file "${filePath}" to verify hash references.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
if (hashes.has(hashRef)) return hashes;
|
|
136
|
-
|
|
137
|
-
// Hash not found — provide a diagnostic error message
|
|
138
|
-
const lineNum = Number.parseInt(hashRef.split(":")[0], 10);
|
|
139
|
-
const actualRef = getHashRefByLineNumber(hashes, lineNum);
|
|
140
|
-
|
|
141
|
-
if (actualRef) {
|
|
142
|
-
throw new Error(
|
|
143
|
-
[
|
|
144
|
-
`Hash reference "${hashRef}" not found.`,
|
|
145
|
-
`Line ${lineNum} now has hash "${actualRef}".`,
|
|
146
|
-
`The file has changed since last read. Please re-read the file.`,
|
|
147
|
-
].join(" "),
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
[
|
|
152
|
-
`Hash reference "${hashRef}" not found.`,
|
|
153
|
-
`Line ${lineNum} does not exist in the file (${hashes.size} lines total).`,
|
|
154
|
-
`Please re-read the file.`,
|
|
155
|
-
].join(" "),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Ensure hashes exist for a file, computing them if needed */
|
|
160
|
-
function ensureHashes(filePath: string): Map<string, string> | undefined {
|
|
161
|
-
let hashes = fileHashes.get(filePath);
|
|
162
|
-
if (!hashes) {
|
|
163
|
-
try {
|
|
164
|
-
hashes = computeFileHashes(filePath);
|
|
165
|
-
} catch {
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return hashes;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Collect old lines from a line range, throwing if any are missing */
|
|
173
|
-
function collectRange(
|
|
174
|
-
filePath: string,
|
|
175
|
-
hashes: Map<string, string>,
|
|
176
|
-
startLine: number,
|
|
177
|
-
endLine: number,
|
|
178
|
-
): string[] {
|
|
179
|
-
const lines: string[] = [];
|
|
180
|
-
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
|
181
|
-
const content = getLineByNumber(hashes, lineNum);
|
|
182
|
-
if (content === undefined) {
|
|
183
|
-
fileHashes.delete(filePath);
|
|
184
|
-
throw new Error(
|
|
185
|
-
`No hash found for line ${lineNum} in range ${startLine}-${endLine}. The file may have changed. Please re-read the file.`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
lines.push(content);
|
|
189
|
-
}
|
|
190
|
-
return lines;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Generate patch @@ chunk lines for a single hashline edit */
|
|
194
|
-
function generatePatchChunk(
|
|
195
|
-
filePath: string,
|
|
196
|
-
edit: HashlineEdit,
|
|
197
|
-
hashes: Map<string, string>,
|
|
198
|
-
): string[] {
|
|
199
|
-
const chunk: string[] = [];
|
|
200
|
-
|
|
201
|
-
if (edit.afterHash) {
|
|
202
|
-
hashes = validateHash(filePath, edit.afterHash, hashes);
|
|
203
|
-
const anchorContent = hashes.get(edit.afterHash)!;
|
|
204
|
-
const anchorLine = Number.parseInt(edit.afterHash.split(":")[0], 10);
|
|
205
|
-
const ctx =
|
|
206
|
-
anchorLine > 1 ? (getLineByNumber(hashes, anchorLine - 1) ?? "") : "";
|
|
207
|
-
chunk.push(`@@ ${ctx}`);
|
|
208
|
-
chunk.push(` ${anchorContent}`);
|
|
209
|
-
for (const line of edit.content.split("\n")) {
|
|
210
|
-
chunk.push(`+${line}`);
|
|
211
|
-
}
|
|
212
|
-
} else if (edit.startHash) {
|
|
213
|
-
hashes = validateHash(filePath, edit.startHash, hashes);
|
|
214
|
-
if (edit.endHash) {
|
|
215
|
-
hashes = validateHash(filePath, edit.endHash, hashes);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const startLine = Number.parseInt(edit.startHash.split(":")[0], 10);
|
|
219
|
-
const endLine = edit.endHash
|
|
220
|
-
? Number.parseInt(edit.endHash.split(":")[0], 10)
|
|
221
|
-
: startLine;
|
|
222
|
-
|
|
223
|
-
if (endLine < startLine) {
|
|
224
|
-
throw new Error(
|
|
225
|
-
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const ctx =
|
|
230
|
-
startLine > 1 ? (getLineByNumber(hashes, startLine - 1) ?? "") : "";
|
|
231
|
-
chunk.push(`@@ ${ctx}`);
|
|
232
|
-
|
|
233
|
-
const oldLines = collectRange(filePath, hashes, startLine, endLine);
|
|
234
|
-
for (const line of oldLines) {
|
|
235
|
-
chunk.push(`-${line}`);
|
|
236
|
-
}
|
|
237
|
-
for (const line of edit.content.split("\n")) {
|
|
238
|
-
chunk.push(`+${line}`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return chunk;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Apply multiple hashline edits to a file's lines array (in-place).
|
|
247
|
-
* Edits are sorted descending by line number and applied bottom-to-top
|
|
248
|
-
* so earlier indices aren't affected by insertions/deletions.
|
|
249
|
-
*/
|
|
250
|
-
function applyEditsToLines(
|
|
251
|
-
filePath: string,
|
|
252
|
-
edits: HashlineEdit[],
|
|
253
|
-
lines: string[],
|
|
254
|
-
hashes: Map<string, string>,
|
|
255
|
-
): void {
|
|
256
|
-
// Sort descending by line number (apply from bottom to top)
|
|
257
|
-
const sorted = [...edits].sort((a, b) => {
|
|
258
|
-
const lineA = Number.parseInt(
|
|
259
|
-
(a.startHash || a.afterHash || "0").split(":")[0],
|
|
260
|
-
10,
|
|
261
|
-
);
|
|
262
|
-
const lineB = Number.parseInt(
|
|
263
|
-
(b.startHash || b.afterHash || "0").split(":")[0],
|
|
264
|
-
10,
|
|
265
|
-
);
|
|
266
|
-
return lineB - lineA;
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
for (const edit of sorted) {
|
|
270
|
-
if (edit.afterHash) {
|
|
271
|
-
hashes = validateHash(filePath, edit.afterHash, hashes);
|
|
272
|
-
const anchorLine = Number.parseInt(edit.afterHash.split(":")[0], 10);
|
|
273
|
-
const newLines = edit.content.split("\n");
|
|
274
|
-
// Insert after anchorLine (1-indexed → splice at anchorLine)
|
|
275
|
-
lines.splice(anchorLine, 0, ...newLines);
|
|
276
|
-
} else if (edit.startHash) {
|
|
277
|
-
hashes = validateHash(filePath, edit.startHash, hashes);
|
|
278
|
-
if (edit.endHash) {
|
|
279
|
-
hashes = validateHash(filePath, edit.endHash, hashes);
|
|
280
|
-
}
|
|
281
|
-
const startLine = Number.parseInt(edit.startHash.split(":")[0], 10);
|
|
282
|
-
const endLine = edit.endHash
|
|
283
|
-
? Number.parseInt(edit.endHash.split(":")[0], 10)
|
|
284
|
-
: startLine;
|
|
285
|
-
|
|
286
|
-
if (endLine < startLine) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const newLines = edit.content.split("\n");
|
|
293
|
-
// Replace lines startLine..endLine (1-indexed)
|
|
294
|
-
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Hashline edit schema — shared shape for individual edits */
|
|
300
|
-
const editShape = {
|
|
301
|
-
filePath: z.string().describe("The absolute path to the file to modify"),
|
|
302
|
-
startHash: z
|
|
303
|
-
.string()
|
|
304
|
-
.optional()
|
|
305
|
-
.describe('Hash reference for the start line to replace (e.g. "42:a3f")'),
|
|
306
|
-
endHash: z
|
|
307
|
-
.string()
|
|
308
|
-
.optional()
|
|
309
|
-
.describe(
|
|
310
|
-
"Hash reference for the end line (for multi-line range replacement)",
|
|
311
|
-
),
|
|
312
|
-
afterHash: z
|
|
313
|
-
.string()
|
|
314
|
-
.optional()
|
|
315
|
-
.describe("Hash reference for the line to insert after (no replacement)"),
|
|
316
|
-
content: z.string().describe("The new content to insert or replace with"),
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
/** Schema for edit tool — edits array, single file per call */
|
|
320
|
-
const editParams = z.object({
|
|
321
|
-
edits: z
|
|
322
|
-
.array(z.object(editShape))
|
|
323
|
-
.describe(
|
|
324
|
-
"Array of edits to apply. All edits must target the same file.",
|
|
325
|
-
),
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
/** Schema for apply_patch tool — edits array, multi-file supported */
|
|
329
|
-
const patchParams = z.object({
|
|
330
|
-
edits: z
|
|
331
|
-
.array(z.object(editShape))
|
|
332
|
-
.describe(
|
|
333
|
-
"Array of edits to apply. Multiple files and multiple edits per file are supported.",
|
|
334
|
-
),
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const editDescription = [
|
|
338
|
-
"Edit a file using hashline references from the most recent read output.",
|
|
339
|
-
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
340
|
-
"Pass an `edits` array with one or more edits (all must target the same file).",
|
|
341
|
-
"",
|
|
342
|
-
"Three operations per edit:",
|
|
343
|
-
"1. Replace line: startHash only → replaces that single line",
|
|
344
|
-
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
345
|
-
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
346
|
-
].join("\n");
|
|
347
|
-
|
|
348
|
-
const patchDescription = [
|
|
349
|
-
"Edit one or more files using hashline references from read output.",
|
|
350
|
-
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
351
|
-
"Pass an `edits` array — multiple files and multiple edits per file are supported.",
|
|
352
|
-
"",
|
|
353
|
-
"Three operations per edit:",
|
|
354
|
-
"1. Replace line: startHash only → replaces that single line",
|
|
355
|
-
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
356
|
-
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
357
|
-
].join("\n");
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
// ── Read: tag each line with its content hash ──────────────────────
|
|
361
|
-
"tool.execute.after": async (input, output) => {
|
|
362
|
-
if (input.tool === "edit") {
|
|
363
|
-
// Recompute hashes from the edited file so subsequent edits
|
|
364
|
-
// get diagnostic "line N now has hash X" errors instead of
|
|
365
|
-
// a generic "not found" when using stale refs.
|
|
366
|
-
const filePath = resolvePath(input.args.filePath);
|
|
367
|
-
try {
|
|
368
|
-
computeFileHashes(filePath);
|
|
369
|
-
} catch {
|
|
370
|
-
fileHashes.delete(filePath);
|
|
371
|
-
}
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (input.tool === "apply_patch") {
|
|
376
|
-
for (const fp of pendingPatchFilePaths) {
|
|
377
|
-
try {
|
|
378
|
-
computeFileHashes(fp);
|
|
379
|
-
} catch {
|
|
380
|
-
fileHashes.delete(fp);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
pendingPatchFilePaths = [];
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (input.tool !== "read") return;
|
|
388
|
-
|
|
389
|
-
// Skip directory reads
|
|
390
|
-
if (output.output.includes("<type>directory</type>")) return;
|
|
391
|
-
|
|
392
|
-
// Extract absolute file path from output and normalize it
|
|
393
|
-
const pathMatch = output.output.match(/<path>(.+?)<\/path>/);
|
|
394
|
-
if (!pathMatch) return;
|
|
395
|
-
const filePath = path.normalize(pathMatch[1]);
|
|
396
|
-
|
|
397
|
-
// Transform content lines: "N: content" → "N:hash| content"
|
|
398
|
-
// The first line is concatenated with <content> (no newline), so we
|
|
399
|
-
// match an optional <content> prefix and preserve it in the output.
|
|
400
|
-
const hashes = new Map<string, string>();
|
|
401
|
-
output.output = output.output.replace(
|
|
402
|
-
/^(<content>)?(\d+): (.*)$/gm,
|
|
403
|
-
(
|
|
404
|
-
_match,
|
|
405
|
-
prefix: string | undefined,
|
|
406
|
-
lineNum: string,
|
|
407
|
-
content: string,
|
|
408
|
-
) => {
|
|
409
|
-
const hash = hashLine(content);
|
|
410
|
-
const ref = `${lineNum}:${hash}`;
|
|
411
|
-
hashes.set(ref, content);
|
|
412
|
-
return `${prefix ?? ""}${lineNum}:${hash}| ${content}`;
|
|
413
|
-
},
|
|
414
|
-
);
|
|
415
|
-
|
|
416
|
-
if (hashes.size > 0) {
|
|
417
|
-
// Merge with existing hashes (supports partial reads / offset reads)
|
|
418
|
-
const existing = fileHashes.get(filePath);
|
|
419
|
-
if (existing) {
|
|
420
|
-
for (const [ref, content] of hashes) {
|
|
421
|
-
existing.set(ref, content);
|
|
422
|
-
}
|
|
423
|
-
} else {
|
|
424
|
-
fileHashes.set(filePath, hashes);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
},
|
|
428
|
-
|
|
429
|
-
// ── Tool schema: replace params with hash references ─────────────
|
|
430
|
-
// Requires PR #4956 (tool.definition hook) to take effect.
|
|
431
|
-
// OpenCode shows `edit` for Anthropic models, `apply_patch` for Codex.
|
|
432
|
-
"tool.definition": async (input: any, output: any) => {
|
|
433
|
-
if (input.toolID === "edit") {
|
|
434
|
-
output.description = editDescription;
|
|
435
|
-
output.parameters = editParams;
|
|
436
|
-
} else if (input.toolID === "apply_patch") {
|
|
437
|
-
output.description = patchDescription;
|
|
438
|
-
output.parameters = patchParams;
|
|
439
|
-
}
|
|
440
|
-
},
|
|
441
|
-
|
|
442
|
-
// ── System prompt: instruct the model to use hashline edits ────────
|
|
443
|
-
"experimental.chat.system.transform": async (_input: any, output: any) => {
|
|
444
|
-
output.system.push(
|
|
445
|
-
[
|
|
446
|
-
"## Hashline Edit Mode (MANDATORY)",
|
|
447
|
-
"",
|
|
448
|
-
"When you read a file, each line is tagged with a hash: `<lineNumber>:<hash>| <content>`.",
|
|
449
|
-
"You MUST use these hash references when editing files. Do NOT use oldString/newString or patchText.",
|
|
450
|
-
"",
|
|
451
|
-
"Pass an `edits` array with one or more edits. Each edit has: filePath, and one of:",
|
|
452
|
-
"",
|
|
453
|
-
"1. **Replace line** — `startHash` + `content`:",
|
|
454
|
-
' `{ filePath: "...", startHash: "3:cc7", content: "new line" }`',
|
|
455
|
-
"",
|
|
456
|
-
"2. **Replace range** — `startHash` + `endHash` + `content`:",
|
|
457
|
-
' `{ filePath: "...", startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5" }`',
|
|
458
|
-
"",
|
|
459
|
-
"3. **Insert after** — `afterHash` + `content`:",
|
|
460
|
-
' `{ filePath: "...", afterHash: "3:cc7", content: " inserted line" }`',
|
|
461
|
-
"",
|
|
462
|
-
"Multiple edits can be batched in a single call:",
|
|
463
|
-
' `{ edits: [{ filePath: "...", startHash: "3:cc7", content: "..." }, { filePath: "...", afterHash: "7:e2c", content: "..." }] }`',
|
|
464
|
-
"",
|
|
465
|
-
"IMPORTANT: The hash value (e.g. `cc7`) is the EXACT 3-character code shown after the line number and colon.",
|
|
466
|
-
"Copy it exactly as shown — do NOT include the `|` separator or any surrounding spaces.",
|
|
467
|
-
"Example: for line `42:a3f| function hello()`, the hash reference is `42:a3f` (not `42:a3f|`).",
|
|
468
|
-
"",
|
|
469
|
-
"NEVER pass oldString, newString, or patchText. ALWAYS use the edits array with hash references.",
|
|
470
|
-
].join("\n"),
|
|
471
|
-
);
|
|
472
|
-
},
|
|
473
|
-
|
|
474
|
-
// ── Edit/Patch: resolve hash references before built-in tool runs ─
|
|
475
|
-
"tool.execute.before": async (input, output) => {
|
|
476
|
-
// ── apply_patch: resolve hashes → generate patchText ──
|
|
477
|
-
if (input.tool === "apply_patch") {
|
|
478
|
-
const args = output.args;
|
|
479
|
-
|
|
480
|
-
// Raw patchText with no hashline args → let normal patch through
|
|
481
|
-
if (args.patchText && !args.edits && !args.startHash && !args.afterHash)
|
|
482
|
-
return;
|
|
483
|
-
|
|
484
|
-
// ── Multi-file edits array ──
|
|
485
|
-
if (args.edits && Array.isArray(args.edits)) {
|
|
486
|
-
// Normalize all hash refs up front
|
|
487
|
-
const edits = (args.edits as HashlineEdit[]).map(normalizeEdit);
|
|
488
|
-
|
|
489
|
-
// Group edits by file path (preserving order within each file)
|
|
490
|
-
const editsByFile = new Map<
|
|
491
|
-
string,
|
|
492
|
-
{ absPath: string; relPath: string; edits: HashlineEdit[] }
|
|
493
|
-
>();
|
|
494
|
-
|
|
495
|
-
for (const edit of edits) {
|
|
496
|
-
const absPath = resolvePath(edit.filePath);
|
|
497
|
-
let entry = editsByFile.get(absPath);
|
|
498
|
-
if (!entry) {
|
|
499
|
-
const relPath = path
|
|
500
|
-
.relative(directory, absPath)
|
|
501
|
-
.split(path.sep)
|
|
502
|
-
.join("/");
|
|
503
|
-
entry = { absPath, relPath, edits: [] };
|
|
504
|
-
editsByFile.set(absPath, entry);
|
|
505
|
-
}
|
|
506
|
-
entry.edits.push(edit);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const patchLines: string[] = ["*** Begin Patch"];
|
|
510
|
-
const editedPaths: string[] = [];
|
|
511
|
-
|
|
512
|
-
for (const [absPath, { relPath, edits }] of editsByFile) {
|
|
513
|
-
editedPaths.push(absPath);
|
|
514
|
-
const hashes = ensureHashes(absPath);
|
|
515
|
-
if (!hashes) continue;
|
|
516
|
-
|
|
517
|
-
// Sort edits by line number so chunks apply top-to-bottom
|
|
518
|
-
edits.sort((a, b) => {
|
|
519
|
-
const lineA = Number.parseInt(
|
|
520
|
-
(a.startHash || a.afterHash || "0").split(":")[0],
|
|
521
|
-
10,
|
|
522
|
-
);
|
|
523
|
-
const lineB = Number.parseInt(
|
|
524
|
-
(b.startHash || b.afterHash || "0").split(":")[0],
|
|
525
|
-
10,
|
|
526
|
-
);
|
|
527
|
-
return lineA - lineB;
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
// One *** Update File section with multiple @@ chunks
|
|
531
|
-
patchLines.push(`*** Update File: ${relPath}`);
|
|
532
|
-
|
|
533
|
-
for (const edit of edits) {
|
|
534
|
-
const chunkLines = generatePatchChunk(absPath, edit, hashes);
|
|
535
|
-
patchLines.push(...chunkLines);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
patchLines.push("*** End Patch");
|
|
540
|
-
|
|
541
|
-
pendingPatchFilePaths = editedPaths;
|
|
542
|
-
args.patchText = patchLines.join("\n");
|
|
543
|
-
delete args.edits;
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// ── Single-file fallback (backwards compat) ──
|
|
548
|
-
if (!args.startHash && !args.afterHash) return;
|
|
549
|
-
|
|
550
|
-
const filePath = resolvePath(args.filePath);
|
|
551
|
-
pendingPatchFilePaths = [filePath];
|
|
552
|
-
const relativePath = path
|
|
553
|
-
.relative(directory, filePath)
|
|
554
|
-
.split(path.sep)
|
|
555
|
-
.join("/");
|
|
556
|
-
|
|
557
|
-
const hashes = ensureHashes(filePath);
|
|
558
|
-
if (!hashes) return;
|
|
559
|
-
|
|
560
|
-
const edit: HashlineEdit = normalizeEdit({
|
|
561
|
-
filePath: args.filePath,
|
|
562
|
-
startHash: args.startHash,
|
|
563
|
-
endHash: args.endHash,
|
|
564
|
-
afterHash: args.afterHash,
|
|
565
|
-
content: args.content,
|
|
566
|
-
});
|
|
567
|
-
const patchLines: string[] = [
|
|
568
|
-
"*** Begin Patch",
|
|
569
|
-
`*** Update File: ${relativePath}`,
|
|
570
|
-
];
|
|
571
|
-
patchLines.push(...generatePatchChunk(filePath, edit, hashes));
|
|
572
|
-
patchLines.push("*** End Patch");
|
|
573
|
-
|
|
574
|
-
args.patchText = patchLines.join("\n");
|
|
575
|
-
|
|
576
|
-
delete args.filePath;
|
|
577
|
-
delete args.startHash;
|
|
578
|
-
delete args.endHash;
|
|
579
|
-
delete args.afterHash;
|
|
580
|
-
delete args.content;
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// ── edit: resolve hashes → oldString/newString ──
|
|
585
|
-
if (input.tool !== "edit") return;
|
|
586
|
-
|
|
587
|
-
const args = output.args;
|
|
588
|
-
|
|
589
|
-
// ── Multi-edit via edits array ──
|
|
590
|
-
if (args.edits && Array.isArray(args.edits)) {
|
|
591
|
-
// Normalize all hash refs up front
|
|
592
|
-
const edits = (args.edits as HashlineEdit[]).map(normalizeEdit);
|
|
593
|
-
if (edits.length === 0) return;
|
|
594
|
-
|
|
595
|
-
// All edits must target the same file (edit tool is single-file)
|
|
596
|
-
const filePath = resolvePath(edits[0].filePath);
|
|
597
|
-
const uniqueFiles = new Set(edits.map((e) => resolvePath(e.filePath)));
|
|
598
|
-
if (uniqueFiles.size > 1) {
|
|
599
|
-
throw new Error(
|
|
600
|
-
"The edit tool supports one file per call. Make separate calls for each file.",
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
let hashes = ensureHashes(filePath);
|
|
605
|
-
if (!hashes) return;
|
|
606
|
-
|
|
607
|
-
// Validate all hashes up front
|
|
608
|
-
for (const edit of edits) {
|
|
609
|
-
if (edit.afterHash) {
|
|
610
|
-
hashes = validateHash(filePath, edit.afterHash, hashes);
|
|
611
|
-
} else if (edit.startHash) {
|
|
612
|
-
hashes = validateHash(filePath, edit.startHash, hashes);
|
|
613
|
-
if (edit.endHash)
|
|
614
|
-
hashes = validateHash(filePath, edit.endHash, hashes);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Read original file, apply all edits, produce oldString/newString
|
|
619
|
-
const originalContent = fs.readFileSync(filePath, "utf-8");
|
|
620
|
-
const lines = originalContent.split("\n");
|
|
621
|
-
applyEditsToLines(filePath, edits, lines, hashes);
|
|
622
|
-
const newContent = lines.join("\n");
|
|
623
|
-
|
|
624
|
-
args.filePath = filePath;
|
|
625
|
-
args.oldString = originalContent;
|
|
626
|
-
args.newString = newContent;
|
|
627
|
-
delete args.edits;
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// ── Single-edit fallback (backwards compat) ──
|
|
632
|
-
|
|
633
|
-
// Auto-convert oldString to hashline - reads file if needed
|
|
634
|
-
if (args.oldString && !args.startHash && !args.afterHash) {
|
|
635
|
-
const filePath = resolvePath(args.filePath);
|
|
636
|
-
|
|
637
|
-
// Read file and compute hashes if not cached
|
|
638
|
-
let hashes = fileHashes.get(filePath);
|
|
639
|
-
if (!hashes) {
|
|
640
|
-
hashes = computeFileHashes(filePath);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (hashes && args.oldString && args.newString !== undefined) {
|
|
644
|
-
const oldContent = args.oldString;
|
|
645
|
-
const newContent = args.newString;
|
|
646
|
-
|
|
647
|
-
// Try exact match first
|
|
648
|
-
let found = false;
|
|
649
|
-
let matchedRef = "";
|
|
650
|
-
for (const [ref, lineContent] of hashes) {
|
|
651
|
-
if (lineContent.trim() === oldContent.trim()) {
|
|
652
|
-
// Validate hash is still valid
|
|
653
|
-
hashes = validateHash(filePath, ref, hashes);
|
|
654
|
-
matchedRef = ref;
|
|
655
|
-
found = true;
|
|
656
|
-
break;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Try first line match if exact match failed
|
|
661
|
-
if (!found) {
|
|
662
|
-
const firstLine = oldContent.split("\n")[0].trim();
|
|
663
|
-
for (const [ref, lineContent] of hashes) {
|
|
664
|
-
if (lineContent.trim() === firstLine) {
|
|
665
|
-
// Validate hash is still valid
|
|
666
|
-
hashes = validateHash(filePath, ref, hashes);
|
|
667
|
-
matchedRef = ref;
|
|
668
|
-
found = true;
|
|
669
|
-
break;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (found && matchedRef) {
|
|
675
|
-
args.startHash = matchedRef;
|
|
676
|
-
args.content = newContent;
|
|
677
|
-
delete args.oldString;
|
|
678
|
-
delete args.newString;
|
|
679
|
-
} else {
|
|
680
|
-
// Provide helpful error with suggestions
|
|
681
|
-
const searchKey = oldContent.split("\n")[0].substring(0, 30);
|
|
682
|
-
let suggestion = "";
|
|
683
|
-
for (const [ref, lineContent] of hashes) {
|
|
684
|
-
if (
|
|
685
|
-
lineContent.includes(searchKey) ||
|
|
686
|
-
searchKey.includes(lineContent.substring(0, 20))
|
|
687
|
-
) {
|
|
688
|
-
suggestion = ` Did you mean hash ${ref} for: ${lineContent.substring(0, 40)}...?`;
|
|
689
|
-
break;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
throw new Error(
|
|
693
|
-
`Hashline Plugin: oldString does not match any line in the file.${suggestion}\n` +
|
|
694
|
-
"Please re-read the file and use hashline format (startHash/endHash/afterHash) instead of oldString/newString.",
|
|
695
|
-
);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Only intercept hashline edits; fall through for normal edits
|
|
701
|
-
if (!args.startHash && !args.afterHash) return;
|
|
702
|
-
|
|
703
|
-
// Normalize hash refs
|
|
704
|
-
if (args.startHash) args.startHash = normalizeHashRef(args.startHash);
|
|
705
|
-
if (args.endHash) args.endHash = normalizeHashRef(args.endHash);
|
|
706
|
-
if (args.afterHash) args.afterHash = normalizeHashRef(args.afterHash);
|
|
707
|
-
|
|
708
|
-
// Insert after
|
|
709
|
-
if (args.afterHash) {
|
|
710
|
-
const filePath = resolvePath(args.filePath);
|
|
711
|
-
let hashes = ensureHashes(filePath);
|
|
712
|
-
if (!hashes) return;
|
|
713
|
-
hashes = validateHash(filePath, args.afterHash, hashes);
|
|
714
|
-
|
|
715
|
-
const anchorContent = hashes.get(args.afterHash)!;
|
|
716
|
-
args.oldString = anchorContent;
|
|
717
|
-
args.newString = anchorContent + "\n" + args.content;
|
|
718
|
-
|
|
719
|
-
delete args.afterHash;
|
|
720
|
-
delete args.content;
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// Replace (single line or range)
|
|
725
|
-
const filePath = resolvePath(args.filePath);
|
|
726
|
-
let hashes = ensureHashes(filePath);
|
|
727
|
-
if (!hashes) return;
|
|
728
|
-
|
|
729
|
-
hashes = validateHash(filePath, args.startHash, hashes);
|
|
730
|
-
|
|
731
|
-
const startLine = Number.parseInt(args.startHash.split(":")[0], 10);
|
|
732
|
-
const endLine = args.endHash
|
|
733
|
-
? Number.parseInt(args.endHash.split(":")[0], 10)
|
|
734
|
-
: startLine;
|
|
735
|
-
|
|
736
|
-
if (args.endHash) {
|
|
737
|
-
hashes = validateHash(filePath, args.endHash, hashes);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
if (endLine < startLine) {
|
|
741
|
-
throw new Error(
|
|
742
|
-
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const rangeLines = collectRange(filePath, hashes, startLine, endLine);
|
|
747
|
-
args.oldString = rangeLines.join("\n");
|
|
748
|
-
args.newString = args.content;
|
|
749
|
-
|
|
750
|
-
delete args.startHash;
|
|
751
|
-
delete args.endHash;
|
|
752
|
-
delete args.content;
|
|
753
|
-
},
|
|
754
|
-
} as any;
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
export default HashlinePlugin;
|