decorated-pi 0.5.2 → 0.5.4
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 +1 -3
- package/extensions/index.ts +0 -2
- package/extensions/io-tool-output.ts +33 -0
- package/extensions/io.ts +72 -201
- package/extensions/mcp/builtin.ts +34 -0
- package/extensions/mcp/index.ts +72 -16
- package/extensions/patch.ts +328 -104
- package/extensions/slash.ts +5 -2
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -14,12 +14,10 @@ pi install /path/to/decorated-pi
|
|
|
14
14
|
|
|
15
15
|
### 1. Patch Tool
|
|
16
16
|
|
|
17
|
-
Replaces Pi's built-in `edit`
|
|
17
|
+
Replaces Pi's built-in `edit` with a stronger `patch` tool that adds unique safety and usability improvements on top of the native tools.
|
|
18
18
|
|
|
19
19
|
| Capability | Pi native `edit` | `patch` |
|
|
20
20
|
| ------ | :---: | :---: |
|
|
21
|
-
| Exact string replacement | ✅ `oldText` | ✅ `old_str` |
|
|
22
|
-
| Atomic overwrite | ✅ `write` | ✅ `overwrite` |
|
|
23
21
|
| Syntax‑highlighted overwrite | ✅ streaming | ✅ incremental |
|
|
24
22
|
| **Anchor‑based search** | ❌ extending `oldText` for uniqueness | ✅ `anchor` bounds scope for precise matching |
|
|
25
23
|
| **Fuzzy whitespace match** | ❌ only reports "not found" | ✅ auto‑corrects tab↔space / trailing whitespace mismatches |
|
package/extensions/index.ts
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
// Extend prompt cache TTL for all providers: Anthropic 1h, OpenAI 24h
|
|
8
|
-
process.env.PI_CACHE_RETENTION ??= "long";
|
|
9
7
|
import { setupSafety } from "./safety/index.js";
|
|
10
8
|
import { setupModelIntegration } from "./model-integration";
|
|
11
9
|
import { setupSlash } from "./slash";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-output externalization helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by `setupIO` (for read / bash) and `maybeExternalizeMcpResult` (for MCP tools).
|
|
5
|
+
* Each writes to the same temp dir; MCP also adds a 2KB preview, while read / bash
|
|
6
|
+
* emit a single-line placeholder.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export const TOOL_OUTPUT_TEMP_DIR = path.join(os.tmpdir(), "decorated-pi-results");
|
|
14
|
+
|
|
15
|
+
/** Write content to a temp file under TOOL_OUTPUT_TEMP_DIR.
|
|
16
|
+
* Returns the file path, or undefined on failure (e.g., /tmp full). */
|
|
17
|
+
export function writeOutputToTemp(
|
|
18
|
+
toolName: string,
|
|
19
|
+
toolCallId: string,
|
|
20
|
+
content: string,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
try {
|
|
23
|
+
if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR)) {
|
|
24
|
+
fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
const id = toolCallId ? toolCallId.slice(0, 12) : randomBytes(8).toString("hex");
|
|
27
|
+
const filePath = path.join(TOOL_OUTPUT_TEMP_DIR, `${toolName}-${id}.txt`);
|
|
28
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
29
|
+
return filePath;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/extensions/io.ts
CHANGED
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
* 6. prepareArguments must handle literal newlines in JSON strings
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { defineTool, isReadToolResult, keyHint,
|
|
29
|
+
import { defineTool, isEditToolResult, isReadToolResult, isWriteToolResult, keyHint, type ExtensionAPI, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { renderDiff } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
32
|
+
import { writeOutputToTemp } from "./io-tool-output.js";
|
|
32
33
|
import { Type } from "typebox";
|
|
33
34
|
import {
|
|
34
35
|
applyPatch,
|
|
@@ -64,15 +65,9 @@ const PatchSchema = Type.Object({
|
|
|
64
65
|
path: Type.String({
|
|
65
66
|
description: "Path to the file to edit (relative or absolute).",
|
|
66
67
|
}),
|
|
67
|
-
edits: Type.
|
|
68
|
-
description: "Targeted replacements applied sequentially.",
|
|
69
|
-
})
|
|
70
|
-
overwrite: Type.Optional(Type.Boolean({
|
|
71
|
-
description: "If true, replace the entire file atomically (write temp → mv).",
|
|
72
|
-
})),
|
|
73
|
-
new_str: Type.Optional(Type.String({
|
|
74
|
-
description: "Entire new file content when overwrite is true.",
|
|
75
|
-
})),
|
|
68
|
+
edits: Type.Array(EditSchema, {
|
|
69
|
+
description: "Targeted replacements applied sequentially. Each edit does exact string replacement with optional anchor.",
|
|
70
|
+
}),
|
|
76
71
|
});
|
|
77
72
|
|
|
78
73
|
// ─── Argument repair ───────────────────────────────────────────────────────────────
|
|
@@ -105,26 +100,6 @@ export function preparePatchArguments(input: any): any {
|
|
|
105
100
|
|
|
106
101
|
const args = input as Record<string, any>;
|
|
107
102
|
|
|
108
|
-
// Legacy multi-file format: { patches: [{ path, edits }] } → extract first
|
|
109
|
-
if (Array.isArray(args.patches) && !args.path) {
|
|
110
|
-
const first = args.patches[0];
|
|
111
|
-
if (first && typeof first === "object" && first.path) {
|
|
112
|
-
Object.assign(args, first);
|
|
113
|
-
delete args.patches;
|
|
114
|
-
}
|
|
115
|
-
} else if (typeof args.patches === "string" && !args.path) {
|
|
116
|
-
try {
|
|
117
|
-
const parsed = jsonParseWithNewlineFix(args.patches);
|
|
118
|
-
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
|
|
119
|
-
Object.assign(args, parsed[0]);
|
|
120
|
-
delete args.patches;
|
|
121
|
-
} else if (parsed && typeof parsed === "object" && parsed.path) {
|
|
122
|
-
Object.assign(args, parsed);
|
|
123
|
-
delete args.patches;
|
|
124
|
-
}
|
|
125
|
-
} catch { /* keep original */ }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
103
|
// Edits serialized as JSON string
|
|
129
104
|
if (typeof args.edits === "string") {
|
|
130
105
|
try {
|
|
@@ -153,14 +128,6 @@ interface PatchCallComponent extends Box {
|
|
|
153
128
|
previewArgsKey?: string;
|
|
154
129
|
previewPending?: boolean;
|
|
155
130
|
settledError: boolean;
|
|
156
|
-
/** Overwrite streaming highlight cache (mirrors write tool design) */
|
|
157
|
-
overwriteHighlightCache?: {
|
|
158
|
-
rawPath: string;
|
|
159
|
-
lang: string | undefined;
|
|
160
|
-
rawContent: string;
|
|
161
|
-
normalizedLines: string[];
|
|
162
|
-
highlightedLines: string[];
|
|
163
|
-
};
|
|
164
131
|
}
|
|
165
132
|
|
|
166
133
|
export function createPatchCallComponent(): PatchCallComponent {
|
|
@@ -184,88 +151,10 @@ function getPatchCallComponent(state: any, lastComponent: any): PatchCallCompone
|
|
|
184
151
|
return comp;
|
|
185
152
|
}
|
|
186
153
|
|
|
187
|
-
// ─── Syntax highlighting for overwrite mode (mirrors write tool's incremental design) ──
|
|
188
|
-
|
|
189
|
-
const OVERWRITE_PARTIAL_HIGHLIGHT_LINES = 50;
|
|
190
|
-
|
|
191
|
-
function normalizeDisplayText(text: string): string {
|
|
192
|
-
return text.replace(/\r/g, "");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
154
|
function replaceTabs(text: string): string {
|
|
196
155
|
return text.replace(/\t/g, " ");
|
|
197
156
|
}
|
|
198
157
|
|
|
199
|
-
function highlightSingleLine(line: string, lang: string | undefined): string {
|
|
200
|
-
const highlighted = highlightCode(line, lang);
|
|
201
|
-
return highlighted[0] ?? "";
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function refreshOverwriteHighlightPrefix(cache: NonNullable<PatchCallComponent["overwriteHighlightCache"]>): void {
|
|
205
|
-
const prefixCount = Math.min(OVERWRITE_PARTIAL_HIGHLIGHT_LINES, cache.normalizedLines.length);
|
|
206
|
-
if (prefixCount === 0) return;
|
|
207
|
-
const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
|
|
208
|
-
const prefixHighlighted = highlightCode(prefixSource, cache.lang);
|
|
209
|
-
for (let i = 0; i < prefixCount; i++) {
|
|
210
|
-
cache.highlightedLines[i] =
|
|
211
|
-
prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function rebuildOverwriteHighlightCache(rawPath: string, fileContent: string): PatchCallComponent["overwriteHighlightCache"] | undefined {
|
|
216
|
-
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
|
217
|
-
if (!lang) return undefined;
|
|
218
|
-
const normalized = replaceTabs(normalizeDisplayText(fileContent));
|
|
219
|
-
return {
|
|
220
|
-
rawPath,
|
|
221
|
-
lang,
|
|
222
|
-
rawContent: fileContent,
|
|
223
|
-
normalizedLines: normalized.split("\n"),
|
|
224
|
-
highlightedLines: highlightCode(normalized, lang),
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function updateOverwriteHighlightCache(
|
|
229
|
-
cache: PatchCallComponent["overwriteHighlightCache"] | undefined,
|
|
230
|
-
rawPath: string,
|
|
231
|
-
fileContent: string,
|
|
232
|
-
): PatchCallComponent["overwriteHighlightCache"] | undefined {
|
|
233
|
-
if (cache) {
|
|
234
|
-
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
|
235
|
-
if (!lang || cache.lang !== lang || cache.rawPath !== rawPath) {
|
|
236
|
-
return rebuildOverwriteHighlightCache(rawPath, fileContent);
|
|
237
|
-
}
|
|
238
|
-
if (!fileContent.startsWith(cache.rawContent)) {
|
|
239
|
-
return rebuildOverwriteHighlightCache(rawPath, fileContent);
|
|
240
|
-
}
|
|
241
|
-
if (fileContent.length === cache.rawContent.length) return cache;
|
|
242
|
-
const deltaRaw = fileContent.slice(cache.rawContent.length);
|
|
243
|
-
const deltaNormalized = replaceTabs(normalizeDisplayText(deltaRaw));
|
|
244
|
-
cache.rawContent = fileContent;
|
|
245
|
-
if (cache.normalizedLines.length === 0) {
|
|
246
|
-
cache.normalizedLines.push("");
|
|
247
|
-
cache.highlightedLines.push("");
|
|
248
|
-
}
|
|
249
|
-
const segments = deltaNormalized.split("\n");
|
|
250
|
-
const lastIndex = cache.normalizedLines.length - 1;
|
|
251
|
-
cache.normalizedLines[lastIndex] += segments[0];
|
|
252
|
-
cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex]!, cache.lang);
|
|
253
|
-
for (let i = 1; i < segments.length; i++) {
|
|
254
|
-
cache.normalizedLines.push(segments[i]!);
|
|
255
|
-
cache.highlightedLines.push(highlightSingleLine(segments[i]!, cache.lang));
|
|
256
|
-
}
|
|
257
|
-
refreshOverwriteHighlightPrefix(cache);
|
|
258
|
-
return cache;
|
|
259
|
-
}
|
|
260
|
-
return rebuildOverwriteHighlightCache(rawPath, fileContent);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function addHighlightedContent(parent: Box, lines: string[]): void {
|
|
264
|
-
for (const line of lines) {
|
|
265
|
-
parent.addChild(new Text(line, 0, 0));
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
158
|
function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
|
|
270
159
|
if (component.settledError) {
|
|
271
160
|
return (text: string) => theme.bg("toolErrorBg", text);
|
|
@@ -335,9 +224,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
335
224
|
let label = "";
|
|
336
225
|
if (args?.path) {
|
|
337
226
|
label = theme.fg("accent", args.path);
|
|
338
|
-
if (args.
|
|
339
|
-
label += theme.fg("error", " [overwrite]");
|
|
340
|
-
} else if (args.edits?.length > 0) {
|
|
227
|
+
if (Array.isArray(args.edits) && args.edits.length > 0) {
|
|
341
228
|
label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
|
|
342
229
|
}
|
|
343
230
|
}
|
|
@@ -348,9 +235,7 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
348
235
|
|
|
349
236
|
const preview = component.preview;
|
|
350
237
|
let body = "";
|
|
351
|
-
if ("
|
|
352
|
-
body = preview.preview;
|
|
353
|
-
} else if ("diff" in preview && preview.diff) {
|
|
238
|
+
if ("diff" in preview && preview.diff) {
|
|
354
239
|
body = preview.diff;
|
|
355
240
|
} else if ("error" in preview && preview.error) {
|
|
356
241
|
component.addChild(new Spacer(1));
|
|
@@ -364,36 +249,18 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
364
249
|
const FOLD_THRESHOLD = 45;
|
|
365
250
|
|
|
366
251
|
component.addChild(new Spacer(1));
|
|
367
|
-
const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
|
|
368
252
|
|
|
369
253
|
if (lines.length > FOLD_THRESHOLD && !expanded) {
|
|
370
254
|
// 折叠态: 显示前几行 + 摘要
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const n = Math.min(10, component.overwriteHighlightCache.highlightedLines.length);
|
|
374
|
-
addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines.slice(0, n));
|
|
375
|
-
} else {
|
|
376
|
-
component.addChild(new Text(theme.fg("toolDiffAdded", lines.slice(0, 10).join("\n")), 0, 0));
|
|
377
|
-
}
|
|
378
|
-
} else {
|
|
379
|
-
const shown = lines.slice(0, 10).join("\n");
|
|
380
|
-
appendPatchDiffChildren(component, shown, theme);
|
|
381
|
-
}
|
|
255
|
+
const shown = lines.slice(0, 10).join("\n");
|
|
256
|
+
appendPatchDiffChildren(component, shown, theme);
|
|
382
257
|
component.addChild(new Text(
|
|
383
258
|
theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
|
|
384
259
|
0, 0,
|
|
385
260
|
));
|
|
386
261
|
} else {
|
|
387
262
|
// 展开态: 完整显示
|
|
388
|
-
|
|
389
|
-
if (component.overwriteHighlightCache) {
|
|
390
|
-
addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines);
|
|
391
|
-
} else {
|
|
392
|
-
component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
|
|
393
|
-
}
|
|
394
|
-
} else {
|
|
395
|
-
appendPatchDiffChildren(component, body, theme);
|
|
396
|
-
}
|
|
263
|
+
appendPatchDiffChildren(component, body, theme);
|
|
397
264
|
}
|
|
398
265
|
|
|
399
266
|
return component;
|
|
@@ -401,17 +268,51 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
401
268
|
|
|
402
269
|
// ─── Setup ──────────────────────────────────────────────────────────────────────────
|
|
403
270
|
|
|
271
|
+
export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000; // 30KB — match Claude Code's bash truncation threshold
|
|
272
|
+
|
|
273
|
+
/** If the tool result content is a single text string longer than the
|
|
274
|
+
* threshold, replace it with a one-line placeholder pointing at the
|
|
275
|
+
* full output on disk. Returns the modified result, or undefined to
|
|
276
|
+
* leave the original content untouched. */
|
|
277
|
+
export function maybeExternalizeToolResult(event: ToolResultEvent): ToolResultEventResult | undefined {
|
|
278
|
+
const content = event.content;
|
|
279
|
+
if (!Array.isArray(content) || content.length === 0) return undefined;
|
|
280
|
+
const first = content[0];
|
|
281
|
+
if (first?.type !== "text" || typeof first.text !== "string") return undefined;
|
|
282
|
+
const text = first.text;
|
|
283
|
+
if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
|
|
284
|
+
|
|
285
|
+
const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
|
|
286
|
+
if (!filePath) return undefined; // write failed — keep original content
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
content: [{
|
|
290
|
+
type: "text" as const,
|
|
291
|
+
text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
|
|
292
|
+
}],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
404
296
|
export function setupIO(pi: ExtensionAPI) {
|
|
405
297
|
pi.on("session_start", (_event, ctx) => {
|
|
406
298
|
restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
|
|
407
299
|
const active = pi.getActiveTools();
|
|
408
|
-
|
|
300
|
+
// Remove: edit (replaced by patch), grep/find/ls (replaced by bash)
|
|
301
|
+
// Keep: write (full-file write)
|
|
302
|
+
pi.setActiveTools(active.filter(t => !["edit", "grep", "find", "ls"].includes(t)));
|
|
409
303
|
});
|
|
410
304
|
|
|
411
305
|
pi.on("session_compact", () => {
|
|
412
306
|
clearReadMarkers();
|
|
413
307
|
});
|
|
414
308
|
|
|
309
|
+
// Externalize large tool results (read / bash) to a temp file.
|
|
310
|
+
// Keeps the messages segment small so prompt cache stays warm across turns.
|
|
311
|
+
pi.on("tool_result", (event) => {
|
|
312
|
+
if (event.toolName !== "read" && event.toolName !== "bash") return;
|
|
313
|
+
return maybeExternalizeToolResult(event);
|
|
314
|
+
});
|
|
315
|
+
|
|
415
316
|
// Track file read times
|
|
416
317
|
pi.on("tool_result", (event, ctx) => {
|
|
417
318
|
if (!isReadToolResult(event)) return;
|
|
@@ -424,18 +325,31 @@ export function setupIO(pi: ExtensionAPI) {
|
|
|
424
325
|
if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
|
|
425
326
|
});
|
|
426
327
|
|
|
328
|
+
// Track file write times (for write and edit, in case patch module is disabled)
|
|
329
|
+
pi.on("tool_result", (event, ctx) => {
|
|
330
|
+
if (!isWriteToolResult(event) && !isEditToolResult(event)) return;
|
|
331
|
+
const filePath = event.input?.path;
|
|
332
|
+
if (typeof filePath !== "string" || !filePath.trim()) return;
|
|
333
|
+
const cwd: string = ctx.cwd ?? process.cwd();
|
|
334
|
+
const absPath = resolveAbsolutePath(cwd, filePath);
|
|
335
|
+
// Only record if the write succeeded (not an error result)
|
|
336
|
+
if (event.isError) return;
|
|
337
|
+
recordReadTime(absPath);
|
|
338
|
+
const marker = createFileTimeMarkerData(cwd, absPath);
|
|
339
|
+
if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
|
|
340
|
+
});
|
|
341
|
+
|
|
427
342
|
pi.registerTool(defineTool({
|
|
428
343
|
name: "patch",
|
|
429
344
|
label: "Patch",
|
|
430
345
|
description: [
|
|
431
|
-
"Edits a file using exact string replacement, with anchor support
|
|
346
|
+
"Edits a file using exact string replacement, with anchor support.",
|
|
432
347
|
"When old_str is not unique, add more surrounding context or use anchor to narrow search.",
|
|
433
348
|
"",
|
|
434
349
|
"Examples:",
|
|
435
350
|
' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
|
|
436
351
|
' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
|
|
437
352
|
' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\"debug\");", new_str: "// debug disabled" }] }',
|
|
438
|
-
' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
|
|
439
353
|
"",
|
|
440
354
|
"Anchor (optional): narrows old_str search to lines after a unique marker.",
|
|
441
355
|
" Code: use the enclosing definition — function/class/struct/method signature.",
|
|
@@ -443,21 +357,20 @@ export function setupIO(pi: ExtensionAPI) {
|
|
|
443
357
|
" Non-code (markdown, config, etc.): use section headings, key names, or distinctive lines.",
|
|
444
358
|
' e.g. "## API Reference" in .md or "[dependencies]" in .toml files.',
|
|
445
359
|
].join("\n"),
|
|
446
|
-
promptSnippet: "Edits a file using exact string replacement, with anchor support
|
|
360
|
+
promptSnippet: "Edits a file using exact string replacement, with anchor support.",
|
|
447
361
|
promptGuidelines: [
|
|
448
|
-
"Always prefer modifying files with
|
|
449
|
-
"For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
|
|
362
|
+
"Always prefer modifying files with patch tool over bash commands or python scripts.",
|
|
450
363
|
"To prevent hallucinations: 1. Keep each edit batch ≤ 5 changes; 2. Process remaining revisions in sequential steps",
|
|
451
364
|
"On repeated failures: read the file first to confirm information accuracy.",
|
|
452
365
|
],
|
|
453
366
|
parameters: PatchSchema,
|
|
454
367
|
renderShell: "self",
|
|
455
368
|
prepareArguments: preparePatchArguments,
|
|
456
|
-
execute: async (_toolCallId: string, input: { path: string; edits
|
|
369
|
+
execute: async (_toolCallId: string, input: { path: string; edits: any[] }, _signal: any, _onUpdate: any, ctx: any) => {
|
|
457
370
|
const cwd: string = ctx.cwd ?? process.cwd();
|
|
458
371
|
|
|
459
|
-
// Stale-read protection
|
|
460
|
-
if (
|
|
372
|
+
// Stale-read protection
|
|
373
|
+
if (input.path?.trim()) {
|
|
461
374
|
const absPath = resolveAbsolutePath(cwd, input.path);
|
|
462
375
|
const staleError = checkStaleFile(absPath, input.path);
|
|
463
376
|
if (staleError) throw new Error(staleError);
|
|
@@ -491,28 +404,6 @@ export function setupIO(pi: ExtensionAPI) {
|
|
|
491
404
|
component.previewArgsKey = argsKey;
|
|
492
405
|
component.previewPending = false;
|
|
493
406
|
component.settledError = false;
|
|
494
|
-
component.overwriteHighlightCache = undefined;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Overwrite streaming: incrementally highlight as new_str streams in
|
|
498
|
-
if (args?.overwrite && typeof args?.path === "string" && typeof args?.new_str === "string") {
|
|
499
|
-
if (context.argsComplete) {
|
|
500
|
-
// Final: full rebuild for best quality
|
|
501
|
-
component.overwriteHighlightCache = rebuildOverwriteHighlightCache(args.path, args.new_str);
|
|
502
|
-
} else {
|
|
503
|
-
// Streaming: incremental update
|
|
504
|
-
component.overwriteHighlightCache = updateOverwriteHighlightCache(
|
|
505
|
-
component.overwriteHighlightCache, args.path, args.new_str,
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
// Inject highlighted content as synthetic preview for buildPatchCallComponent
|
|
509
|
-
const cache = component.overwriteHighlightCache;
|
|
510
|
-
if (cache) {
|
|
511
|
-
component.preview = { preview: cache.rawContent, isOverwrite: true };
|
|
512
|
-
} else {
|
|
513
|
-
// No language detected: fall back to plain text
|
|
514
|
-
component.preview = { preview: args.new_str, isOverwrite: true };
|
|
515
|
-
}
|
|
516
407
|
}
|
|
517
408
|
|
|
518
409
|
// Preview diff is computed during execute and delivered via result.details.diff.
|
|
@@ -527,37 +418,17 @@ export function setupIO(pi: ExtensionAPI) {
|
|
|
527
418
|
let changed = false;
|
|
528
419
|
|
|
529
420
|
if (callComponent) {
|
|
530
|
-
//
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
:
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const nextCache = rebuildOverwriteHighlightCache(context.args?.path, overwriteContent);
|
|
537
|
-
const prevContent = (callComponent.overwriteHighlightCache?.rawContent ?? undefined);
|
|
538
|
-
const prevPath = callComponent.overwriteHighlightCache?.rawPath;
|
|
539
|
-
callComponent.overwriteHighlightCache = nextCache;
|
|
540
|
-
if (
|
|
541
|
-
!(callComponent.preview && "isOverwrite" in callComponent.preview && callComponent.preview.isOverwrite && callComponent.preview.preview === overwriteContent) ||
|
|
542
|
-
prevContent !== overwriteContent ||
|
|
543
|
-
prevPath !== context.args?.path
|
|
544
|
-
) {
|
|
545
|
-
callComponent.preview = { preview: overwriteContent, isOverwrite: true };
|
|
421
|
+
// Use execute's returned diff (same design as Pi's native edit tool)
|
|
422
|
+
const resultDiff = !context.isError && result.details?.diff;
|
|
423
|
+
if (typeof resultDiff === "string") {
|
|
424
|
+
const newPreview = { diff: resultDiff };
|
|
425
|
+
if (callComponent.preview?.diff !== resultDiff) {
|
|
426
|
+
callComponent.preview = newPreview;
|
|
546
427
|
changed = true;
|
|
547
428
|
}
|
|
548
|
-
} else {
|
|
549
|
-
// 非 overwrite:优先使用 execute 返回的 diff(与 Pi 原生 edit 工具一致)
|
|
550
|
-
const resultDiff = !context.isError && result.details?.diff;
|
|
551
|
-
if (typeof resultDiff === "string") {
|
|
552
|
-
const newPreview = { diff: resultDiff };
|
|
553
|
-
if (callComponent.preview?.diff !== resultDiff) {
|
|
554
|
-
callComponent.preview = newPreview;
|
|
555
|
-
changed = true;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
429
|
}
|
|
559
430
|
|
|
560
|
-
//
|
|
431
|
+
// Update error state
|
|
561
432
|
if (callComponent.settledError !== context.isError) {
|
|
562
433
|
callComponent.settledError = context.isError;
|
|
563
434
|
changed = true;
|
|
@@ -340,3 +340,37 @@ export function cleanupStaleCache(configs: McpServerConfig[], cwd?: string): voi
|
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
|
+
|
|
344
|
+
// ── Large result externalization ────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
export const EXTERNALIZE_THRESHOLD = 50_000; // 50KB
|
|
347
|
+
export const EXTERNALIZE_PREVIEW_SIZE = 2_000; // 2KB preview
|
|
348
|
+
import { writeOutputToTemp } from "../io-tool-output.js";
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* If content exceeds threshold, save to temp file and return preview + path.
|
|
352
|
+
* Otherwise return original content unchanged.
|
|
353
|
+
* Falls back to returning full text if file write fails.
|
|
354
|
+
*/
|
|
355
|
+
export function maybeExternalizeMcpResult(
|
|
356
|
+
text: string,
|
|
357
|
+
toolName: string,
|
|
358
|
+
toolCallId: string,
|
|
359
|
+
): { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> } {
|
|
360
|
+
if (text.length <= EXTERNALIZE_THRESHOLD) {
|
|
361
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const filePath = writeOutputToTemp(toolName, toolCallId, text);
|
|
365
|
+
if (!filePath) {
|
|
366
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
367
|
+
}
|
|
368
|
+
const preview = text.slice(0, EXTERNALIZE_PREVIEW_SIZE);
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text", text: `${preview}\n\n[Truncated: ${text.length.toLocaleString()} chars total. Full output saved to: ${filePath}]` }],
|
|
371
|
+
details: {
|
|
372
|
+
fullOutputPath: filePath,
|
|
373
|
+
truncation: { truncated: true, outputChars: EXTERNALIZE_PREVIEW_SIZE, totalChars: text.length, maxBytes: EXTERNALIZE_THRESHOLD },
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { McpConnection } from "./client.js";
|
|
|
4
4
|
import {
|
|
5
5
|
resolveMcpConfigs,
|
|
6
6
|
loadMcpCache, updateServerCache, cleanupStaleCache,
|
|
7
|
+
maybeExternalizeMcpResult,
|
|
8
|
+
BUILTIN_MCP_SERVERS,
|
|
7
9
|
type McpServerConfig, type McpToolCache,
|
|
8
10
|
} from "./builtin.js";
|
|
9
11
|
|
|
@@ -51,7 +53,24 @@ function formatMcpResultText(text: string, expanded: boolean, theme: any): strin
|
|
|
51
53
|
text,
|
|
52
54
|
expanded ? Number.MAX_SAFE_INTEGER : MCP_RESULT_FOLD_LINES,
|
|
53
55
|
);
|
|
54
|
-
|
|
56
|
+
|
|
57
|
+
// Check for externalization message in the last line
|
|
58
|
+
const lastLine = displayLines[displayLines.length - 1] || "";
|
|
59
|
+
let outputLines = [...displayLines];
|
|
60
|
+
let truncationMsg = "";
|
|
61
|
+
|
|
62
|
+
if (lastLine.startsWith('[Truncated: ') && lastLine.endsWith(']')) {
|
|
63
|
+
truncationMsg = lastLine;
|
|
64
|
+
outputLines = outputLines.slice(0, -1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const outputText = outputLines.join("\n");
|
|
68
|
+
let rendered = outputText ? theme.fg("toolOutput", outputText) : "";
|
|
69
|
+
|
|
70
|
+
if (truncationMsg) {
|
|
71
|
+
rendered += (rendered ? "\n" : "") + theme.fg("warning", truncationMsg);
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
if (!expanded && remainingLines > 0) {
|
|
56
75
|
rendered += `${theme.fg("muted", `\n... (${remainingLines} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
|
57
76
|
}
|
|
@@ -100,10 +119,11 @@ function cacheScopeForSource(source: string): "global" | "project" {
|
|
|
100
119
|
|
|
101
120
|
// ── register cached tools ─────────────────────────────────────────────────
|
|
102
121
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
function registerCachedToolsFromCache(
|
|
123
|
+
pi: ExtensionAPI,
|
|
124
|
+
cache: McpCache,
|
|
125
|
+
configs: McpServerConfig[]
|
|
126
|
+
): void {
|
|
107
127
|
for (const config of configs) {
|
|
108
128
|
if (!config.enabled) continue;
|
|
109
129
|
const entry = cache.servers[config.name];
|
|
@@ -122,15 +142,19 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
|
|
|
122
142
|
execute: async (_id, params, _signal, _update, _ctx) => {
|
|
123
143
|
const conn = activeConnections.find(c => c.serverName === config.name);
|
|
124
144
|
if (!conn) {
|
|
145
|
+
// Tool is registered from cache before connectAll finishes. Tell the
|
|
146
|
+
// model to retry — connection is establishing in the background.
|
|
147
|
+
const status = allServers.get(config.name);
|
|
148
|
+
const state = status?.state ?? "connecting";
|
|
125
149
|
return {
|
|
126
|
-
content: [{ type: "text", text: `MCP server "${config.name}" is
|
|
127
|
-
isError:
|
|
150
|
+
content: [{ type: "text", text: `MCP server "${config.name}" is ${state}. Please retry shortly.` }],
|
|
151
|
+
isError: false,
|
|
128
152
|
details: {},
|
|
129
153
|
};
|
|
130
154
|
}
|
|
131
155
|
try {
|
|
132
156
|
const text = await conn.callTool(t.name, params as Record<string, unknown>);
|
|
133
|
-
return
|
|
157
|
+
return maybeExternalizeMcpResult(text, toolName, _id);
|
|
134
158
|
} catch (err) {
|
|
135
159
|
const msg = err instanceof Error ? err.message : String(err);
|
|
136
160
|
return {
|
|
@@ -145,9 +169,18 @@ function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void
|
|
|
145
169
|
}
|
|
146
170
|
}
|
|
147
171
|
|
|
172
|
+
function registerCachedTools(pi: ExtensionAPI, configs: McpServerConfig[]): void {
|
|
173
|
+
const cache = loadMcpCache(cachedCwd);
|
|
174
|
+
if (!cache) return;
|
|
175
|
+
registerCachedToolsFromCache(pi, cache, configs);
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
// ── connect ───────────────────────────────────────────────────────────────
|
|
149
179
|
|
|
150
|
-
async function connectAll(
|
|
180
|
+
async function connectAll(
|
|
181
|
+
configs: McpServerConfig[],
|
|
182
|
+
ui?: { notify: (msg: string, type: string) => void }
|
|
183
|
+
): Promise<{ schemaChanges: string[]; hasNewServer: boolean }> {
|
|
151
184
|
// Load current cache for comparison
|
|
152
185
|
const cache = loadMcpCache(cachedCwd);
|
|
153
186
|
const schemaChanges: string[] = [];
|
|
@@ -241,10 +274,17 @@ async function connectAll(configs: McpServerConfig[], ui?: { notify: (msg: strin
|
|
|
241
274
|
await connectPromise;
|
|
242
275
|
connectPromise = null;
|
|
243
276
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
277
|
+
// Determine whether any server had no cached tools (first-time connection).
|
|
278
|
+
let hasNewServer = false;
|
|
279
|
+
for (const server of configs) {
|
|
280
|
+
const cachedEntry = cache?.servers[server.name];
|
|
281
|
+
if (!cachedEntry || cachedEntry.tools.length === 0) {
|
|
282
|
+
hasNewServer = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
247
285
|
}
|
|
286
|
+
|
|
287
|
+
return { schemaChanges, hasNewServer };
|
|
248
288
|
}
|
|
249
289
|
|
|
250
290
|
// ── setup ─────────────────────────────────────────────────────────────────
|
|
@@ -263,11 +303,27 @@ export function setupMcp(pi: ExtensionAPI) {
|
|
|
263
303
|
|
|
264
304
|
const enabledConfigs = configs.filter(s => s.enabled);
|
|
265
305
|
|
|
266
|
-
//
|
|
267
|
-
|
|
306
|
+
// Load cache and register tools immediately (before connectAll)
|
|
307
|
+
const cache = loadMcpCache(cachedCwd);
|
|
308
|
+
if (cache) {
|
|
309
|
+
registerCachedToolsFromCache(pi, cache, enabledConfigs);
|
|
310
|
+
}
|
|
268
311
|
|
|
269
|
-
// Connect in background,
|
|
270
|
-
void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined)
|
|
312
|
+
// Connect in background (fire-and-forget, don't block session_start)
|
|
313
|
+
void connectAll(enabledConfigs, ctx.hasUI ? ctx.ui : undefined).then(({ schemaChanges, hasNewServer }) => {
|
|
314
|
+
// Re-register tools when:
|
|
315
|
+
// - A server was not in cache before (first connection)
|
|
316
|
+
// - Schema changed (tools added/removed/changed)
|
|
317
|
+
if (hasNewServer || schemaChanges.length > 0) {
|
|
318
|
+
const cache = loadMcpCache(cachedCwd);
|
|
319
|
+
if (cache) {
|
|
320
|
+
registerCachedToolsFromCache(pi, cache, enabledConfigs);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (schemaChanges.length > 0 && ctx.hasUI) {
|
|
324
|
+
ctx.ui.notify(`MCP schema updated: ${schemaChanges.join('; ')}.`, "info");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
271
327
|
});
|
|
272
328
|
|
|
273
329
|
pi.on("session_shutdown", () => {
|
package/extensions/patch.ts
CHANGED
|
@@ -176,6 +176,103 @@ function applyOverwrite(
|
|
|
176
176
|
// Edits (exact string replacement)
|
|
177
177
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
178
|
|
|
179
|
+
type LocateEditResult =
|
|
180
|
+
| {
|
|
181
|
+
found: true;
|
|
182
|
+
oldNorm: string;
|
|
183
|
+
newNorm: string;
|
|
184
|
+
matchIdx: number;
|
|
185
|
+
displayAnchor?: string;
|
|
186
|
+
anchorMissing: boolean;
|
|
187
|
+
}
|
|
188
|
+
| {
|
|
189
|
+
found: false;
|
|
190
|
+
oldNorm: string;
|
|
191
|
+
anchorState: "ok" | "missing" | "not_unique";
|
|
192
|
+
anchorMessage?: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Shared edit location logic used by both one-shot and sequential paths.
|
|
196
|
+
* Returns structured failure details when old_str is not found so callers can
|
|
197
|
+
* preserve precise diagnostics instead of guessing why matching failed.
|
|
198
|
+
* Throws ApplyError on duplicate global matches or non-unique old_str. */
|
|
199
|
+
function locateEdit(
|
|
200
|
+
edit: { old_str: string; new_str: string; anchor?: string },
|
|
201
|
+
content: string,
|
|
202
|
+
displayPath: string,
|
|
203
|
+
): LocateEditResult {
|
|
204
|
+
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
205
|
+
let newNorm = normalizeLineEndings(edit.new_str);
|
|
206
|
+
|
|
207
|
+
let searchFrom = 0;
|
|
208
|
+
let displayAnchor: string | undefined;
|
|
209
|
+
let anchorMissing = false;
|
|
210
|
+
let anchorState: "ok" | "missing" | "not_unique" = "ok";
|
|
211
|
+
let anchorMessage: string | undefined;
|
|
212
|
+
|
|
213
|
+
// ── Anchor parsing ──
|
|
214
|
+
if (edit.anchor) {
|
|
215
|
+
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
216
|
+
const anchorIdx = content.indexOf(anchorNorm);
|
|
217
|
+
if (anchorIdx === -1) {
|
|
218
|
+
anchorState = "missing";
|
|
219
|
+
anchorMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
220
|
+
} else {
|
|
221
|
+
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
222
|
+
if (secondAnchor !== -1) {
|
|
223
|
+
anchorState = "not_unique";
|
|
224
|
+
anchorMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
225
|
+
} else {
|
|
226
|
+
searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
|
|
227
|
+
displayAnchor = edit.anchor;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Exact match in search range ──
|
|
233
|
+
let matchIdx = anchorMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
234
|
+
|
|
235
|
+
// ── Global exact match fallback (when anchor was missing/unusable) ──
|
|
236
|
+
if (matchIdx === -1 && anchorMessage) {
|
|
237
|
+
displayAnchor = edit.anchor;
|
|
238
|
+
anchorMissing = true;
|
|
239
|
+
matchIdx = content.indexOf(oldNorm, 0);
|
|
240
|
+
if (matchIdx !== -1) {
|
|
241
|
+
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
242
|
+
if (secondGlobalMatch !== -1) {
|
|
243
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
244
|
+
throw new ApplyError(`${anchorMessage}\n${dupDiag}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Fuzzy match ──
|
|
250
|
+
if (matchIdx === -1) {
|
|
251
|
+
const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
|
|
252
|
+
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
253
|
+
if (fuzzy) {
|
|
254
|
+
oldNorm = fuzzy.matched;
|
|
255
|
+
matchIdx = fuzzy.idx;
|
|
256
|
+
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (matchIdx === -1) {
|
|
261
|
+
return { found: false, oldNorm, anchorState, anchorMessage };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Uniqueness check (skip when anchor was used as a fallback) ──
|
|
265
|
+
if (!anchorMessage) {
|
|
266
|
+
const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
267
|
+
if (secondMatch !== -1) {
|
|
268
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
269
|
+
throw new ApplyError(`${dupDiag}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing };
|
|
274
|
+
}
|
|
275
|
+
|
|
179
276
|
async function applyEdits(
|
|
180
277
|
absPath: string,
|
|
181
278
|
displayPath: string,
|
|
@@ -192,150 +289,205 @@ async function applyEdits(
|
|
|
192
289
|
|
|
193
290
|
const rawContent = fs.readFileSync(absPath, "utf8");
|
|
194
291
|
const lineEnding = detectLineEnding(rawContent);
|
|
195
|
-
|
|
292
|
+
const originalContent = normalizeLineEndings(rawContent);
|
|
196
293
|
|
|
197
|
-
// Precompute line offsets for
|
|
198
|
-
const lineOffsets = buildLineOffsets(
|
|
294
|
+
// Precompute line offsets for the original file (used throughout)
|
|
295
|
+
const lineOffsets = buildLineOffsets(originalContent);
|
|
199
296
|
const totalLines = lineOffsets.length - 1;
|
|
200
297
|
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
298
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
299
|
+
// Phase 1: try matching every old_str against the ORIGINAL snapshot.
|
|
300
|
+
// If any edit requires content from a prior edit (chained dependency),
|
|
301
|
+
// fall back to sequential mode.
|
|
302
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
const planned: Array<Extract<LocateEditResult, { found: true }>> = [];
|
|
305
|
+
let needsSequential = false;
|
|
205
306
|
|
|
206
307
|
for (const edit of edits) {
|
|
207
308
|
if (!edit.old_str) {
|
|
208
309
|
throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
|
|
209
310
|
}
|
|
210
311
|
|
|
211
|
-
|
|
212
|
-
|
|
312
|
+
const located = locateEdit(edit, originalContent, displayPath);
|
|
313
|
+
if (!located.found) {
|
|
314
|
+
// old_str not found in the original snapshot — likely chained edit.
|
|
315
|
+
// Fall back to sequential mode.
|
|
316
|
+
needsSequential = true;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
213
319
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
let displayAnchor: string | undefined;
|
|
217
|
-
let anchorMissing = false;
|
|
218
|
-
let anchorNotFoundMessage: string | undefined;
|
|
320
|
+
planned.push(located);
|
|
321
|
+
}
|
|
219
322
|
|
|
220
|
-
|
|
221
|
-
|
|
323
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
324
|
+
// Sequential fallback — old behaviour for chained edits
|
|
325
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
222
326
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Find old_str in range — must be unique
|
|
240
|
-
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
241
|
-
if (matchIdx === -1 && anchorNotFoundMessage) {
|
|
242
|
-
// Anchor was missing/unusable — try global exact match first
|
|
243
|
-
displayAnchor = edit.anchor;
|
|
244
|
-
anchorMissing = true;
|
|
245
|
-
matchIdx = content.indexOf(oldNorm, 0);
|
|
246
|
-
if (matchIdx !== -1) {
|
|
247
|
-
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
248
|
-
if (secondGlobalMatch !== -1) {
|
|
249
|
-
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
250
|
-
throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
|
|
327
|
+
if (needsSequential) {
|
|
328
|
+
let content = originalContent;
|
|
329
|
+
let cumulativeOffset = 0;
|
|
330
|
+
const rawReplacements: ReplacementInfo[] = [];
|
|
331
|
+
|
|
332
|
+
for (const edit of edits) {
|
|
333
|
+
const located = locateEdit(edit, content, displayPath);
|
|
334
|
+
if (!located.found) {
|
|
335
|
+
const diag = diagnoseOldStrMismatch(located.oldNorm, content);
|
|
336
|
+
if (located.anchorState === "missing" || located.anchorState === "not_unique") {
|
|
337
|
+
throw new ApplyError(
|
|
338
|
+
`${located.anchorMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
|
|
339
|
+
);
|
|
251
340
|
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (matchIdx === -1) {
|
|
256
|
-
// Fuzzy match fallback: normalize tab↔space + trailing whitespace
|
|
257
|
-
const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
|
|
258
|
-
const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
|
|
259
|
-
if (fuzzy) {
|
|
260
|
-
oldNorm = fuzzy.matched;
|
|
261
|
-
matchIdx = fuzzy.idx;
|
|
262
|
-
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
263
|
-
} else if (anchorNotFoundMessage) {
|
|
264
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
265
|
-
throw new ApplyError(
|
|
266
|
-
`${anchorNotFoundMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
|
|
267
|
-
);
|
|
268
|
-
} else {
|
|
269
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
270
341
|
throw new ApplyError(
|
|
271
342
|
`old_str not found in ${displayPath}` +
|
|
272
343
|
(edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
|
|
273
344
|
`: "${truncate(edit.old_str)}".\n${diag}`
|
|
274
345
|
);
|
|
275
346
|
}
|
|
347
|
+
|
|
348
|
+
const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing } = located;
|
|
349
|
+
|
|
350
|
+
const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
|
|
351
|
+
const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
|
|
352
|
+
|
|
353
|
+
content =
|
|
354
|
+
content.substring(0, matchIdx) +
|
|
355
|
+
newNorm +
|
|
356
|
+
content.substring(matchIdx + oldNorm.length);
|
|
357
|
+
|
|
358
|
+
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
359
|
+
|
|
360
|
+
rawReplacements.push({
|
|
361
|
+
oldStartLine,
|
|
362
|
+
oldEndLine,
|
|
363
|
+
newStartLine: 0, // placeholder — recalculated after collapse
|
|
364
|
+
newEndLine: 0,
|
|
365
|
+
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
366
|
+
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
367
|
+
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
368
|
+
anchorMissing,
|
|
369
|
+
});
|
|
276
370
|
}
|
|
277
371
|
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
372
|
+
// Collapse chained-edit replacements into net-change replacements,
|
|
373
|
+
// so the TUI diff shows only the net effect (original→final).
|
|
374
|
+
const cleanReplacements = collapseSequentialReplacements(rawReplacements);
|
|
375
|
+
|
|
376
|
+
const mergedRanges = mergeRanges(cleanReplacements.map(r => ({
|
|
377
|
+
startLine: Math.max(1, r.oldStartLine - CONTEXT_LINES),
|
|
378
|
+
endLine: Math.min(totalLines, r.oldEndLine + CONTEXT_LINES),
|
|
379
|
+
})));
|
|
380
|
+
const neededLines: Map<number, string> = new Map();
|
|
381
|
+
for (const range of mergedRanges) {
|
|
382
|
+
const lines = extractLineRange(originalContent, lineOffsets, range.startLine, range.endLine);
|
|
383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
384
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
286
385
|
}
|
|
287
386
|
}
|
|
288
387
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
388
|
+
const fileDiff = generateLocalDiff(displayPath, cleanReplacements, neededLines, totalLines);
|
|
389
|
+
if (result.diff) {
|
|
390
|
+
result.diff += "\n" + fileDiff;
|
|
391
|
+
} else {
|
|
392
|
+
result.diff = fileDiff;
|
|
393
|
+
}
|
|
294
394
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
395
|
+
const finalContent = restoreLineEndings(content, lineEnding);
|
|
396
|
+
if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
|
|
397
|
+
result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fs.writeFileSync(absPath, finalContent, "utf8");
|
|
401
|
+
result.modified.push(displayPath);
|
|
402
|
+
result.replacements.set(displayPath, cleanReplacements);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
407
|
+
// Phase 2: Conflict detection — sort by position, check for overlaps
|
|
408
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
409
|
+
|
|
410
|
+
const sorted = [...planned].sort((a, b) => a.matchIdx - b.matchIdx);
|
|
411
|
+
|
|
412
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
413
|
+
const cur = sorted[i]!;
|
|
414
|
+
const next = sorted[i + 1]!;
|
|
415
|
+
const curEnd = cur.matchIdx + cur.oldNorm.length;
|
|
416
|
+
if (curEnd > next.matchIdx) {
|
|
417
|
+
const curStartLine = lineAtOffset(lineOffsets, cur.matchIdx);
|
|
418
|
+
const curEndLine = lineAtOffset(lineOffsets, curEnd - 1);
|
|
419
|
+
const nextStartLine = lineAtOffset(lineOffsets, next.matchIdx);
|
|
420
|
+
const overlapEnd = Math.min(curEnd, next.matchIdx + next.oldNorm.length);
|
|
421
|
+
const overlapEndLine = lineAtOffset(lineOffsets, overlapEnd - 1);
|
|
422
|
+
throw new ApplyError(
|
|
423
|
+
`Edits target overlapping regions in ${displayPath}: ` +
|
|
424
|
+
`edit targeting lines ${curStartLine}-${curEndLine} overlaps with ` +
|
|
425
|
+
`edit targeting lines ${nextStartLine}-${overlapEndLine}. ` +
|
|
426
|
+
`Split overlapping edits into separate patch calls.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
300
430
|
|
|
301
|
-
|
|
302
|
-
|
|
431
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
432
|
+
// Phase 3: One-shot assembly — splice replacements into final content
|
|
433
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
303
434
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
435
|
+
let content = "";
|
|
436
|
+
let cursor = 0;
|
|
437
|
+
const replacements: ReplacementInfo[] = [];
|
|
438
|
+
const neededRanges: LineRange[] = [];
|
|
439
|
+
|
|
440
|
+
for (const p of sorted) {
|
|
441
|
+
// Copy original content up to this edit
|
|
442
|
+
content += originalContent.substring(cursor, p.matchIdx);
|
|
443
|
+
|
|
444
|
+
// Record where new_str lands in the assembled content
|
|
445
|
+
const newStartIdx = content.length;
|
|
446
|
+
content += p.newNorm;
|
|
447
|
+
const newEndIdx = content.length - 1;
|
|
448
|
+
|
|
449
|
+
// Compute line numbers (original file coordinates for old, result for new)
|
|
450
|
+
const oldStartLine = lineAtOffset(lineOffsets, p.matchIdx);
|
|
451
|
+
const oldEndLine = lineAtOffset(lineOffsets, p.matchIdx + p.oldNorm.length - 1);
|
|
452
|
+
const newStartLine = charOffsetToLine(content, newStartIdx);
|
|
453
|
+
const newEndLine = charOffsetToLine(content, newEndIdx);
|
|
307
454
|
|
|
308
|
-
// Record replacement info
|
|
309
455
|
replacements.push({
|
|
310
456
|
oldStartLine,
|
|
311
457
|
oldEndLine,
|
|
312
458
|
newStartLine,
|
|
313
459
|
newEndLine,
|
|
314
|
-
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
315
|
-
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
316
|
-
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
317
|
-
anchorMissing,
|
|
460
|
+
oldLines: p.oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
461
|
+
newLines: p.newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
462
|
+
anchor: p.displayAnchor ? p.displayAnchor.split("\n")[0] : undefined,
|
|
463
|
+
anchorMissing: p.anchorMissing,
|
|
318
464
|
});
|
|
319
465
|
|
|
320
|
-
// Collect context range for this edit
|
|
321
466
|
neededRanges.push({
|
|
322
467
|
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
323
468
|
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
324
469
|
});
|
|
470
|
+
|
|
471
|
+
cursor = p.matchIdx + p.oldNorm.length;
|
|
325
472
|
}
|
|
326
473
|
|
|
327
|
-
//
|
|
474
|
+
// Copy trailing original content
|
|
475
|
+
content += originalContent.substring(cursor);
|
|
476
|
+
|
|
477
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
478
|
+
// Diff generation
|
|
479
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
480
|
+
|
|
328
481
|
const mergedRanges = mergeRanges(neededRanges);
|
|
329
|
-
const
|
|
482
|
+
const originalLineOffsets = buildLineOffsets(originalContent);
|
|
330
483
|
const neededLines: Map<number, string> = new Map();
|
|
331
484
|
for (const range of mergedRanges) {
|
|
332
|
-
const lines = extractLineRange(
|
|
485
|
+
const lines = extractLineRange(originalContent, originalLineOffsets, range.startLine, range.endLine);
|
|
333
486
|
for (let i = 0; i < lines.length; i++) {
|
|
334
487
|
neededLines.set(range.startLine + i, lines[i]);
|
|
335
488
|
}
|
|
336
489
|
}
|
|
337
490
|
|
|
338
|
-
// Build diff for this file and append to result
|
|
339
491
|
const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
|
|
340
492
|
if (result.diff) {
|
|
341
493
|
result.diff += "\n" + fileDiff;
|
|
@@ -346,7 +498,6 @@ async function applyEdits(
|
|
|
346
498
|
// Restore line endings
|
|
347
499
|
const finalContent = restoreLineEndings(content, lineEnding);
|
|
348
500
|
|
|
349
|
-
// Warn if line endings were normalized (CRLF → LF)
|
|
350
501
|
if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
|
|
351
502
|
result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
|
|
352
503
|
}
|
|
@@ -560,14 +711,17 @@ interface ChunkAnchor {
|
|
|
560
711
|
function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
561
712
|
const byText = new Map<string, ChunkAnchor>();
|
|
562
713
|
for (const rep of chunk.reps) {
|
|
563
|
-
const
|
|
564
|
-
if (!
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
714
|
+
const raw = rep.anchor?.trim();
|
|
715
|
+
if (!raw) continue;
|
|
716
|
+
// Support \n-separated anchors from collapsed sequential replacements
|
|
717
|
+
const texts = raw.includes("\n") ? raw.split("\n").map(s => s.trim()).filter(Boolean) : [raw];
|
|
718
|
+
for (const text of texts) {
|
|
719
|
+
const existing = byText.get(text);
|
|
720
|
+
if (!existing) {
|
|
721
|
+
byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
|
|
722
|
+
} else if (!rep.anchorMissing) {
|
|
723
|
+
existing.missing = false;
|
|
724
|
+
}
|
|
571
725
|
}
|
|
572
726
|
}
|
|
573
727
|
return [...byText.values()];
|
|
@@ -795,6 +949,76 @@ function charOffsetToLine(content: string, offset: number): number {
|
|
|
795
949
|
/**
|
|
796
950
|
* Generate diff using only the needed lines (partial file context).
|
|
797
951
|
*/
|
|
952
|
+
/** Collapse chained-edit replacements (where out[i] === in[i+1]) into
|
|
953
|
+
* net-change replacements showing only the net effect (original→final). */
|
|
954
|
+
function collapseSequentialReplacements(
|
|
955
|
+
reps: ReplacementInfo[],
|
|
956
|
+
): ReplacementInfo[] {
|
|
957
|
+
const collapsed: ReplacementInfo[] = [];
|
|
958
|
+
let i = 0;
|
|
959
|
+
while (i < reps.length) {
|
|
960
|
+
const start = reps[i]!;
|
|
961
|
+
let merged: ReplacementInfo = {
|
|
962
|
+
...start,
|
|
963
|
+
newStartLine: start.oldStartLine,
|
|
964
|
+
newEndLine: start.oldStartLine + start.newLines.length - 1,
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
const anchors: string[] = [];
|
|
968
|
+
const seenAnchors = new Set<string>();
|
|
969
|
+
const addAnchor = (raw?: string) => {
|
|
970
|
+
if (!raw) return;
|
|
971
|
+
for (const text of raw.split("\n").map(s => s.trim()).filter(Boolean)) {
|
|
972
|
+
if (seenAnchors.has(text)) continue;
|
|
973
|
+
seenAnchors.add(text);
|
|
974
|
+
anchors.push(text);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
addAnchor(start.anchor);
|
|
978
|
+
|
|
979
|
+
let j = i + 1;
|
|
980
|
+
while (j < reps.length) {
|
|
981
|
+
const next = reps[j]!;
|
|
982
|
+
// Merge chained edits when next edit's input matches merged output.
|
|
983
|
+
// We allow slightly shifted line numbers because sequential edits can
|
|
984
|
+
// change string lengths before we compute displayed line ranges.
|
|
985
|
+
if (!(linesEqual(merged.newLines, next.oldLines) && next.oldStartLine <= merged.oldEndLine + 1)) {
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
addAnchor(next.anchor);
|
|
989
|
+
merged = {
|
|
990
|
+
// Keep the ORIGINAL region from the first replacement in the chain.
|
|
991
|
+
// Later chained replacements may have shifted line numbers, but the
|
|
992
|
+
// net diff should still point at the original file region.
|
|
993
|
+
oldStartLine: merged.oldStartLine,
|
|
994
|
+
oldEndLine: merged.oldEndLine,
|
|
995
|
+
newStartLine: merged.oldStartLine,
|
|
996
|
+
newEndLine: merged.oldStartLine + next.newLines.length - 1,
|
|
997
|
+
oldLines: merged.oldLines,
|
|
998
|
+
newLines: next.newLines,
|
|
999
|
+
anchor: undefined,
|
|
1000
|
+
anchorMissing: merged.anchorMissing || next.anchorMissing,
|
|
1001
|
+
};
|
|
1002
|
+
j++;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
collapsed.push({
|
|
1006
|
+
...merged,
|
|
1007
|
+
anchor: anchors.length > 0 ? anchors.join("\n") : undefined,
|
|
1008
|
+
});
|
|
1009
|
+
i = j;
|
|
1010
|
+
}
|
|
1011
|
+
return collapsed;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function linesEqual(a: string[], b: string[]): boolean {
|
|
1015
|
+
if (a.length !== b.length) return false;
|
|
1016
|
+
for (let i = 0; i < a.length; i++) {
|
|
1017
|
+
if (a[i] !== b[i]) return false;
|
|
1018
|
+
}
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
798
1022
|
function generateLocalDiff(
|
|
799
1023
|
filePath: string,
|
|
800
1024
|
reps: ReplacementInfo[],
|
package/extensions/slash.ts
CHANGED
|
@@ -255,8 +255,11 @@ class McpStatusComponent extends Container {
|
|
|
255
255
|
if (s.tools.length > 0) {
|
|
256
256
|
lines.push(` ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}:`);
|
|
257
257
|
for (const tool of s.tools.slice(0, 6)) {
|
|
258
|
-
const
|
|
259
|
-
|
|
258
|
+
const flat = (tool.description ?? "").replace(/\s+/g, " ").trim();
|
|
259
|
+
const td = flat
|
|
260
|
+
? flat.length > 55
|
|
261
|
+
? ` — ${flat.slice(0, 55)}…`
|
|
262
|
+
: ` — ${flat}`
|
|
260
263
|
: "";
|
|
261
264
|
lines.push(` ${tool.name}${td}`);
|
|
262
265
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorated-pi",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "decorated-pi is a practical enhancement pack for pi — smarter tools that are token efficient and cache friendly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"language-server",
|
|
14
14
|
"secret-detection"
|
|
15
15
|
],
|
|
16
|
-
"type": "commonjs",
|
|
17
16
|
"license": "MIT",
|
|
18
17
|
"repository": {
|
|
19
18
|
"type": "git",
|
|
@@ -46,6 +45,6 @@
|
|
|
46
45
|
"scripts": {
|
|
47
46
|
"prepack": "depcheck --fail-on-missing",
|
|
48
47
|
"test": "vitest run",
|
|
49
|
-
"prepublishOnly": "npm install --
|
|
48
|
+
"prepublishOnly": "npm install --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
|
|
50
49
|
}
|
|
51
50
|
}
|