decorated-pi 0.3.0 → 0.4.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 +58 -34
- package/extensions/file-times.ts +60 -2
- package/extensions/guidance.ts +5 -3
- package/extensions/index.ts +2 -0
- package/extensions/io.ts +210 -29
- package/extensions/lsp/client.ts +181 -428
- package/extensions/lsp/env.ts +45 -12
- package/extensions/lsp/format.ts +102 -237
- package/extensions/lsp/index.ts +8 -11
- package/extensions/lsp/manager.ts +249 -0
- package/extensions/lsp/prompt.ts +3 -42
- package/extensions/lsp/protocol.ts +219 -0
- package/extensions/lsp/servers.ts +80 -160
- package/extensions/lsp/tools.ts +160 -553
- package/extensions/lsp/types.ts +42 -0
- package/extensions/mcp/builtin.ts +126 -0
- package/extensions/mcp/client.ts +106 -0
- package/extensions/mcp/index.ts +123 -0
- package/extensions/patch.ts +291 -73
- package/extensions/providers/ark-coding.ts +2 -0
- package/extensions/safety/detect.ts +20 -744
- package/extensions/safety/entropy.ts +226 -0
- package/extensions/safety/index.ts +1 -93
- package/extensions/safety/patterns.ts +155 -0
- package/extensions/safety/types.ts +50 -0
- package/extensions/settings.ts +8 -0
- package/extensions/slash.ts +161 -7
- package/extensions/smart-at.ts +5 -5
- package/extensions/subdir-agents.ts +43 -13
- package/package.json +2 -3
- package/tsconfig.json +16 -0
- package/extensions/lsp/server-manager.ts +0 -309
- package/extensions/lsp/trust.ts +0 -45
package/extensions/patch.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import * as
|
|
14
|
+
import * as fsPromises from "node:fs/promises";
|
|
15
15
|
|
|
16
16
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
17
|
// Types
|
|
@@ -45,6 +45,8 @@ export interface PatchResult {
|
|
|
45
45
|
replacements: Map<string, ReplacementInfo[]>;
|
|
46
46
|
/** Original file lines per file, for diff context generation */
|
|
47
47
|
originalLines: Map<string, string[]>;
|
|
48
|
+
/** Pre-generated diff string (set by applyEdits to avoid re-reading files) */
|
|
49
|
+
diff: string;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Records a single old_str→new_str replacement within a file */
|
|
@@ -63,6 +65,8 @@ export interface ReplacementInfo {
|
|
|
63
65
|
newLines: string[];
|
|
64
66
|
/** Optional anchor text (first line only, for hunk display) */
|
|
65
67
|
anchor?: string;
|
|
68
|
+
/** Anchor was provided but not found, and patch fell back to global old_str search */
|
|
69
|
+
anchorMissing?: boolean;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -90,6 +94,7 @@ export async function applyPatch(patch: FilePatch, cwd: string): Promise<PatchRe
|
|
|
90
94
|
warnings: [],
|
|
91
95
|
replacements: new Map(),
|
|
92
96
|
originalLines: new Map(),
|
|
97
|
+
diff: "",
|
|
93
98
|
};
|
|
94
99
|
|
|
95
100
|
const absPath = resolveAbsPath(cwd, patch.path);
|
|
@@ -119,6 +124,7 @@ export async function applyPatches(patches: FilePatch[], cwd: string): Promise<P
|
|
|
119
124
|
warnings: [],
|
|
120
125
|
replacements: new Map(),
|
|
121
126
|
originalLines: new Map(),
|
|
127
|
+
diff: "",
|
|
122
128
|
};
|
|
123
129
|
|
|
124
130
|
for (const p of patches) {
|
|
@@ -185,21 +191,17 @@ async function applyEdits(
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
const rawContent = fs.readFileSync(absPath, "utf8");
|
|
188
|
-
// Store original lines for diff context generation later
|
|
189
|
-
const origLines = rawContent.split("\n");
|
|
190
|
-
// Remove trailing empty line from split (trailing newline), but keep lines for content-less files
|
|
191
|
-
if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
|
|
192
|
-
result.originalLines.set(displayPath, origLines);
|
|
193
|
-
|
|
194
194
|
const lineEnding = detectLineEnding(rawContent);
|
|
195
195
|
let content = normalizeLineEndings(rawContent);
|
|
196
196
|
|
|
197
197
|
// Precompute line offsets for O(log n) line number lookups
|
|
198
|
-
const
|
|
198
|
+
const lineOffsets = buildLineOffsets(rawContent);
|
|
199
|
+
const totalLines = lineOffsets.length - 1;
|
|
199
200
|
|
|
200
201
|
// Track cumulative offset for mapping current positions back to original
|
|
201
202
|
let cumulativeOffset = 0;
|
|
202
203
|
const replacements: ReplacementInfo[] = [];
|
|
204
|
+
const neededRanges: LineRange[] = [];
|
|
203
205
|
|
|
204
206
|
for (const edit of edits) {
|
|
205
207
|
if (!edit.old_str) {
|
|
@@ -211,56 +213,80 @@ async function applyEdits(
|
|
|
211
213
|
|
|
212
214
|
// Determine search range
|
|
213
215
|
let searchFrom = 0;
|
|
216
|
+
let displayAnchor: string | undefined;
|
|
217
|
+
let anchorMissing = false;
|
|
218
|
+
let anchorNotFoundMessage: string | undefined;
|
|
214
219
|
|
|
215
220
|
if (edit.anchor) {
|
|
216
221
|
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
217
222
|
|
|
218
|
-
// Find anchor — must be unique
|
|
223
|
+
// Find anchor — must be unique when present
|
|
219
224
|
const anchorIdx = content.indexOf(anchorNorm);
|
|
220
225
|
if (anchorIdx === -1) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
226
|
+
anchorNotFoundMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
|
|
227
|
+
} else {
|
|
228
|
+
const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
|
|
229
|
+
if (secondAnchor !== -1) {
|
|
230
|
+
throw new ApplyError(
|
|
231
|
+
`Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
|
|
232
|
+
`found at multiple locations. Choose a more specific anchor.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
// Search from anchor start position onward (anchor narrows search range)
|
|
236
|
+
// old_str may start at anchor position (anchor is a prefix of old_str)
|
|
237
|
+
searchFrom = anchorIdx;
|
|
238
|
+
displayAnchor = edit.anchor;
|
|
239
|
+
anchorMissing = false;
|
|
232
240
|
}
|
|
233
|
-
|
|
234
|
-
// Search from anchor start position onward (anchor narrows search range)
|
|
235
|
-
// old_str may start at anchor position (anchor is a prefix of old_str)
|
|
236
|
-
searchFrom = anchorIdx;
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
// Find old_str in range — must be unique
|
|
240
|
-
|
|
244
|
+
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
241
245
|
if (matchIdx === -1) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
if (anchorNotFoundMessage) {
|
|
247
|
+
displayAnchor = edit.anchor;
|
|
248
|
+
anchorMissing = true;
|
|
249
|
+
const globalMatchIdx = content.indexOf(oldNorm, 0);
|
|
250
|
+
if (globalMatchIdx === -1) {
|
|
251
|
+
throw new ApplyError(
|
|
252
|
+
`${anchorNotFoundMessage} old_str not found in ${displayPath}: "${truncate(edit.old_str)}". ` +
|
|
253
|
+
`The file may have changed — re-read it and try again.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
|
|
257
|
+
if (secondGlobalMatch !== -1) {
|
|
258
|
+
throw new ApplyError(
|
|
259
|
+
`${anchorNotFoundMessage} old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
|
|
260
|
+
`Add more context to old_str or use a more specific anchor.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
matchIdx = globalMatchIdx;
|
|
264
|
+
} else {
|
|
265
|
+
throw new ApplyError(
|
|
266
|
+
`old_str not found in ${displayPath}` +
|
|
267
|
+
(edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
|
|
268
|
+
`: "${truncate(edit.old_str)}". ` +
|
|
269
|
+
`The file may have changed — re-read it and try again.`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
// Check uniqueness
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
274
|
+
// Check uniqueness in anchor-narrowed / plain search path only when anchor was used normally
|
|
275
|
+
if (!anchorNotFoundMessage) {
|
|
276
|
+
const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
277
|
+
if (secondMatch !== -1) {
|
|
278
|
+
throw new ApplyError(
|
|
279
|
+
`old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
|
|
280
|
+
`Add more context to old_str or use an anchor to narrow the search.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
257
283
|
}
|
|
258
284
|
|
|
259
285
|
// Compute line numbers in the original file for diff generation (O(log n) via binary search)
|
|
260
286
|
// matchIdx is in the modified content; subtract cumulative offset to map back to original
|
|
261
287
|
const origMatchIdx = matchIdx - cumulativeOffset;
|
|
262
|
-
const oldStartLine = lineAtOffset(
|
|
263
|
-
const oldEndLine = lineAtOffset(
|
|
288
|
+
const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
|
|
289
|
+
const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
|
|
264
290
|
|
|
265
291
|
// Apply replacement
|
|
266
292
|
content =
|
|
@@ -283,10 +309,35 @@ async function applyEdits(
|
|
|
283
309
|
newEndLine,
|
|
284
310
|
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
285
311
|
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
286
|
-
anchor:
|
|
312
|
+
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
313
|
+
anchorMissing,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Collect context range for this edit
|
|
317
|
+
neededRanges.push({
|
|
318
|
+
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
319
|
+
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
287
320
|
});
|
|
288
321
|
}
|
|
289
322
|
|
|
323
|
+
// Generate diff using only needed context lines (no full-file split)
|
|
324
|
+
const mergedRanges = mergeRanges(neededRanges);
|
|
325
|
+
const neededLines: Map<number, string> = new Map();
|
|
326
|
+
for (const range of mergedRanges) {
|
|
327
|
+
const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
|
|
328
|
+
for (let i = 0; i < lines.length; i++) {
|
|
329
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Build diff for this file and append to result
|
|
334
|
+
const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
|
|
335
|
+
if (result.diff) {
|
|
336
|
+
result.diff += "\n" + fileDiff;
|
|
337
|
+
} else {
|
|
338
|
+
result.diff = fileDiff;
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
// Restore line endings
|
|
291
342
|
const finalContent = restoreLineEndings(content, lineEnding);
|
|
292
343
|
|
|
@@ -334,13 +385,13 @@ export async function computePatchPreview(
|
|
|
334
385
|
return { error: "File not found" };
|
|
335
386
|
}
|
|
336
387
|
|
|
337
|
-
const rawContent =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
388
|
+
const rawContent = await fsPromises.readFile(absPath, "utf8");
|
|
389
|
+
const lineOffsets = buildLineOffsets(rawContent);
|
|
390
|
+
const totalLines = lineOffsets.length - 1;
|
|
391
|
+
let content = normalizeLineEndings(rawContent);
|
|
392
|
+
const allReplacements: ReplacementInfo[] = [];
|
|
393
|
+
const neededRanges: LineRange[] = [];
|
|
394
|
+
let cumulativeOffset = 0;
|
|
344
395
|
|
|
345
396
|
for (const edit of patch.edits) {
|
|
346
397
|
if (!edit.old_str) continue;
|
|
@@ -348,33 +399,72 @@ export async function computePatchPreview(
|
|
|
348
399
|
const newNorm = normalizeLineEndings(edit.new_str);
|
|
349
400
|
|
|
350
401
|
let searchFrom = 0;
|
|
402
|
+
let displayAnchor: string | undefined;
|
|
403
|
+
let anchorMissing = false;
|
|
404
|
+
let anchorNotFoundMessage: string | undefined;
|
|
351
405
|
if (edit.anchor) {
|
|
352
406
|
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
353
407
|
const idx = content.indexOf(anchorNorm);
|
|
354
408
|
if (idx === -1) {
|
|
355
|
-
|
|
409
|
+
anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
|
|
410
|
+
} else {
|
|
411
|
+
const secondAnchor = content.indexOf(anchorNorm, idx + 1);
|
|
412
|
+
if (secondAnchor !== -1) {
|
|
413
|
+
return { error: `Anchor is not unique: "${truncate(edit.anchor)}"` };
|
|
414
|
+
}
|
|
415
|
+
searchFrom = idx;
|
|
416
|
+
displayAnchor = edit.anchor;
|
|
417
|
+
anchorMissing = false;
|
|
356
418
|
}
|
|
357
|
-
searchFrom = idx;
|
|
358
419
|
}
|
|
359
420
|
|
|
360
|
-
|
|
421
|
+
let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
|
|
361
422
|
if (matchIdx === -1) {
|
|
362
|
-
|
|
423
|
+
if (anchorNotFoundMessage) {
|
|
424
|
+
displayAnchor = edit.anchor;
|
|
425
|
+
anchorMissing = true;
|
|
426
|
+
const globalMatchIdx = content.indexOf(oldNorm, 0);
|
|
427
|
+
if (globalMatchIdx === -1) {
|
|
428
|
+
return { error: `${anchorNotFoundMessage}; old_str not found: "${truncate(edit.old_str)}"` };
|
|
429
|
+
}
|
|
430
|
+
const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
|
|
431
|
+
if (secondGlobalMatch !== -1) {
|
|
432
|
+
return { error: `${anchorNotFoundMessage}; old_str is not unique: "${truncate(edit.old_str)}"` };
|
|
433
|
+
}
|
|
434
|
+
matchIdx = globalMatchIdx;
|
|
435
|
+
} else {
|
|
436
|
+
return { error: `old_str not found: "${truncate(edit.old_str)}"` };
|
|
437
|
+
}
|
|
363
438
|
}
|
|
364
439
|
|
|
365
440
|
const origMatchIdx = matchIdx - cumulativeOffset;
|
|
366
|
-
const oldStartLine = lineAtOffset(
|
|
367
|
-
const oldEndLine = lineAtOffset(
|
|
441
|
+
const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
|
|
442
|
+
const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
|
|
368
443
|
const oldLines = oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
|
|
369
444
|
const newLines = newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
|
|
370
445
|
content = content.substring(0, matchIdx) + newNorm + content.substring(matchIdx + oldNorm.length);
|
|
371
446
|
const newStartLine = charOffsetToLine(content, matchIdx);
|
|
372
447
|
const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
|
|
373
|
-
|
|
448
|
+
// Record needed context range around this edit
|
|
449
|
+
neededRanges.push({
|
|
450
|
+
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
451
|
+
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
452
|
+
});
|
|
453
|
+
allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
|
|
374
454
|
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
375
455
|
}
|
|
376
456
|
|
|
377
|
-
|
|
457
|
+
// Merge needed ranges and extract only those lines
|
|
458
|
+
const mergedRanges = mergeRanges(neededRanges);
|
|
459
|
+
const neededLines: Map<number, string> = new Map();
|
|
460
|
+
for (const range of mergedRanges) {
|
|
461
|
+
const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
|
|
462
|
+
for (let i = 0; i < lines.length; i++) {
|
|
463
|
+
neededLines.set(range.startLine + i, lines[i]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const diff = generateLocalDiff(patch.path, allReplacements, neededLines, totalLines);
|
|
378
468
|
return { diff };
|
|
379
469
|
} else {
|
|
380
470
|
return { error: "Must provide edits[] or overwrite:true" };
|
|
@@ -400,6 +490,12 @@ export async function computePatchPreviewMulti(
|
|
|
400
490
|
|
|
401
491
|
|
|
402
492
|
export function generatePatchDiff(result: PatchResult): string {
|
|
493
|
+
// If applyEdits pre-generated the diff, use it directly (avoids re-reading files)
|
|
494
|
+
if (result.diff) {
|
|
495
|
+
return result.diff;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Fallback: reconstruct diff from stored originalLines (legacy path)
|
|
403
499
|
const parts: string[] = [];
|
|
404
500
|
for (const [filePath, reps] of result.replacements) {
|
|
405
501
|
const origLines = result.originalLines.get(filePath) ?? [];
|
|
@@ -438,14 +534,29 @@ function buildReplacementChunks(
|
|
|
438
534
|
return chunks;
|
|
439
535
|
}
|
|
440
536
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
537
|
+
interface ChunkAnchor {
|
|
538
|
+
text: string;
|
|
539
|
+
missing: boolean;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
543
|
+
const byText = new Map<string, ChunkAnchor>();
|
|
544
|
+
for (const rep of chunk.reps) {
|
|
545
|
+
const text = rep.anchor?.trim();
|
|
546
|
+
if (!text) continue;
|
|
547
|
+
const existing = byText.get(text);
|
|
548
|
+
if (!existing) {
|
|
549
|
+
byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
|
|
550
|
+
} else if (!rep.anchorMissing) {
|
|
551
|
+
// If any replacement successfully used this anchor, do not mark it missing.
|
|
552
|
+
existing.missing = false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return [...byText.values()];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function formatAnchorLabel(anchor: ChunkAnchor): string {
|
|
559
|
+
return anchor.text + (anchor.missing ? " (missing)" : "");
|
|
449
560
|
}
|
|
450
561
|
|
|
451
562
|
function formatChunkHeader(chunk: ReplacementChunk): string {
|
|
@@ -459,7 +570,7 @@ function formatChunkHeader(chunk: ReplacementChunk): string {
|
|
|
459
570
|
}
|
|
460
571
|
|
|
461
572
|
if (anchors.length === 1) {
|
|
462
|
-
return `@@ lines ${range} @@ anchor: ${anchors[0]}`;
|
|
573
|
+
return `@@ lines ${range} @@ anchor: ${formatAnchorLabel(anchors[0]!)}`;
|
|
463
574
|
}
|
|
464
575
|
|
|
465
576
|
return `@@ lines ${range} @@`;
|
|
@@ -471,7 +582,7 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
471
582
|
|
|
472
583
|
const shown = anchors.slice(0, 2);
|
|
473
584
|
const remaining = anchors.length - shown.length;
|
|
474
|
-
const lines = ["anchors:", ...shown.map((anchor) => ` - ${anchor}`)];
|
|
585
|
+
const lines = ["anchors:", ...shown.map((anchor) => ` - ${formatAnchorLabel(anchor)}`)];
|
|
475
586
|
if (remaining > 0) {
|
|
476
587
|
lines.push(` - +${remaining} more`);
|
|
477
588
|
}
|
|
@@ -581,7 +692,18 @@ function restoreLineEndings(text: string, ending: string): string {
|
|
|
581
692
|
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
582
693
|
}
|
|
583
694
|
|
|
584
|
-
|
|
695
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
696
|
+
// Line range utilities (for partial file reading)
|
|
697
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
698
|
+
|
|
699
|
+
const CONTEXT_LINES = 3;
|
|
700
|
+
|
|
701
|
+
interface LineRange {
|
|
702
|
+
startLine: number;
|
|
703
|
+
endLine: number;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
|
|
585
707
|
function buildLineOffsets(content: string): number[] {
|
|
586
708
|
const offsets = [0];
|
|
587
709
|
for (let i = 0; i < content.length; i++) {
|
|
@@ -590,8 +712,8 @@ function buildLineOffsets(content: string): number[] {
|
|
|
590
712
|
return offsets;
|
|
591
713
|
}
|
|
592
714
|
|
|
593
|
-
|
|
594
|
-
|
|
715
|
+
|
|
716
|
+
/** Binary search: find 1-based line number containing charOffset */
|
|
595
717
|
function lineAtOffset(lineOffsets: number[], charOffset: number): number {
|
|
596
718
|
let lo = 0, hi = lineOffsets.length - 1;
|
|
597
719
|
while (lo < hi) {
|
|
@@ -599,10 +721,51 @@ function lineAtOffset(lineOffsets: number[], charOffset: number): number {
|
|
|
599
721
|
if (lineOffsets[mid] <= charOffset) lo = mid;
|
|
600
722
|
else hi = mid - 1;
|
|
601
723
|
}
|
|
602
|
-
return lo + 1;
|
|
724
|
+
return lo + 1;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Binary search: find line start offset given 1-based line number */
|
|
728
|
+
function offsetAtLine(lineOffsets: number[], lineNum: number): number {
|
|
729
|
+
if (lineNum <= 1) return 0;
|
|
730
|
+
if (lineNum > lineOffsets.length) return lineOffsets[lineOffsets.length - 1];
|
|
731
|
+
return lineOffsets[lineNum - 1];
|
|
603
732
|
}
|
|
604
733
|
|
|
605
|
-
/**
|
|
734
|
+
/** Extract a range of lines from content (1-based, inclusive) */
|
|
735
|
+
function extractLineRange(content: string, lineOffsets: number[], startLine: number, endLine: number): string[] {
|
|
736
|
+
const lines: string[] = [];
|
|
737
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
738
|
+
const start = offsetAtLine(lineOffsets, i);
|
|
739
|
+
const end = offsetAtLine(lineOffsets, i + 1);
|
|
740
|
+
// Remove trailing \n from last line if present
|
|
741
|
+
const lineText = content.slice(start, end).replace(/\n$/, "");
|
|
742
|
+
lines.push(lineText);
|
|
743
|
+
}
|
|
744
|
+
return lines;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
/** Merge overlapping/adjacent line ranges */
|
|
749
|
+
function mergeRanges(ranges: LineRange[]): LineRange[] {
|
|
750
|
+
if (ranges.length === 0) return [];
|
|
751
|
+
const sorted = [...ranges].sort((a, b) => a.startLine - b.startLine);
|
|
752
|
+
const merged: LineRange[] = [];
|
|
753
|
+
for (const r of sorted) {
|
|
754
|
+
const last = merged[merged.length - 1];
|
|
755
|
+
if (last && r.startLine <= last.endLine + 1) {
|
|
756
|
+
last.endLine = Math.max(last.endLine, r.endLine);
|
|
757
|
+
} else {
|
|
758
|
+
merged.push({ ...r });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return merged;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function randomId(): string {
|
|
765
|
+
return Math.random().toString(36).slice(2, 10);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Convert a character offset to a 1-based line number. */
|
|
606
769
|
function charOffsetToLine(content: string, offset: number): number {
|
|
607
770
|
let line = 1;
|
|
608
771
|
for (let i = 0; i < offset && i < content.length; i++) {
|
|
@@ -611,8 +774,63 @@ function charOffsetToLine(content: string, offset: number): number {
|
|
|
611
774
|
return line;
|
|
612
775
|
}
|
|
613
776
|
|
|
614
|
-
|
|
615
|
-
|
|
777
|
+
/**
|
|
778
|
+
* Generate diff using only the needed lines (partial file context).
|
|
779
|
+
*/
|
|
780
|
+
function generateLocalDiff(
|
|
781
|
+
filePath: string,
|
|
782
|
+
reps: ReplacementInfo[],
|
|
783
|
+
neededLines: Map<number, string>,
|
|
784
|
+
totalLines: number,
|
|
785
|
+
): string {
|
|
786
|
+
if (reps.length === 0) return "";
|
|
787
|
+
|
|
788
|
+
const parts: string[] = [];
|
|
789
|
+
parts.push(`--- ${filePath}`);
|
|
790
|
+
parts.push(`+++ ${filePath}`);
|
|
791
|
+
|
|
792
|
+
// Calculate dynamic width based on max line number
|
|
793
|
+
const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
|
|
794
|
+
const numWidth = String(maxLineNum).length;
|
|
795
|
+
|
|
796
|
+
// Merge replacement chunks
|
|
797
|
+
const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
|
|
798
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
799
|
+
if (c > 0) parts.push("");
|
|
800
|
+
const chunk = chunks[c]!;
|
|
801
|
+
parts.push(formatChunkHeader(chunk));
|
|
802
|
+
parts.push(...formatChunkMetadataLines(chunk));
|
|
803
|
+
|
|
804
|
+
// Output context + removed + added
|
|
805
|
+
let cursor = chunk.startLine;
|
|
806
|
+
for (const rep of chunk.reps) {
|
|
807
|
+
// Context before this replacement
|
|
808
|
+
for (let i = cursor; i < rep.oldStartLine; i++) {
|
|
809
|
+
const lineText = neededLines.get(i);
|
|
810
|
+
if (lineText !== undefined) {
|
|
811
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Removed lines
|
|
815
|
+
for (let i = 0; i < rep.oldLines.length; i++) {
|
|
816
|
+
parts.push(`-${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.oldLines[i]}`);
|
|
817
|
+
}
|
|
818
|
+
// Added lines
|
|
819
|
+
for (let i = 0; i < rep.newLines.length; i++) {
|
|
820
|
+
parts.push(`+${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.newLines[i]}`);
|
|
821
|
+
}
|
|
822
|
+
cursor = rep.oldEndLine + 1;
|
|
823
|
+
}
|
|
824
|
+
// Trailing context
|
|
825
|
+
for (let i = cursor; i <= chunk.endLine; i++) {
|
|
826
|
+
const lineText = neededLines.get(i);
|
|
827
|
+
if (lineText !== undefined) {
|
|
828
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return parts.join("\n");
|
|
616
834
|
}
|
|
617
835
|
|
|
618
836
|
function truncate(s: string, maxLen = 60): string {
|
|
@@ -22,6 +22,8 @@ const BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3";
|
|
|
22
22
|
|
|
23
23
|
const MODELS: ProviderModelConfig[] = [
|
|
24
24
|
{ id: "deepseek-v3.2", name: "DeepSeek V3.2", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 163_840, maxTokens: 65_536, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
25
|
+
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_048_576, maxTokens: 1_048_576, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
26
|
+
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_048_576, maxTokens: 1_048_576, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
25
27
|
{ id: "glm-4.7", name: "GLM 4.7", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
26
28
|
{ id: "glm-5.1", name: "GLM 5.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|
|
27
29
|
{ id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262_144, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
|