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.
@@ -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 os from "node:os";
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 rawLineOffsets = buildLineOffsets(rawContent);
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
- throw new ApplyError(
222
- `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`
223
- );
224
- }
225
- // Check uniqueness
226
- const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
227
- if (secondAnchor !== -1) {
228
- throw new ApplyError(
229
- `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
230
- `found at multiple locations. Choose a more specific anchor.`
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
- const matchIdx = content.indexOf(oldNorm, searchFrom);
244
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
241
245
  if (matchIdx === -1) {
242
- throw new ApplyError(
243
- `old_str not found in ${displayPath}` +
244
- (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
245
- `: "${truncate(edit.old_str)}". ` +
246
- `The file may have changed — re-read it and try again.`
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
- const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
252
- if (secondMatch !== -1) {
253
- throw new ApplyError(
254
- `old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
255
- `Add more context to old_str or use an anchor to narrow the search.`
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(rawLineOffsets, origMatchIdx);
263
- const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
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: edit.anchor ? edit.anchor.split("\n")[0] : undefined,
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 = fs.readFileSync(absPath, "utf8");
338
- const origLines = rawContent.split("\n");
339
- if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
340
- let content = normalizeLineEndings(rawContent);
341
- const rawLineOffsets = buildLineOffsets(rawContent);
342
- const allReplacements: ReplacementInfo[] = [];
343
- let cumulativeOffset = 0;
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
- return { error: `Anchor not found: "${truncate(edit.anchor)}"` };
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
- const matchIdx = content.indexOf(oldNorm, searchFrom);
421
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
361
422
  if (matchIdx === -1) {
362
- return { error: `old_str not found: "${truncate(edit.old_str)}"` };
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(rawLineOffsets, origMatchIdx);
367
- const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
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
- allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: edit.anchor ? edit.anchor.split("\n")[0] : undefined });
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
- const diff = generateReplacementDiff(patch.path, allReplacements, origLines);
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
- function getChunkAnchors(chunk: ReplacementChunk): string[] {
442
- return [
443
- ...new Set(
444
- chunk.reps
445
- .map((rep) => rep.anchor?.trim())
446
- .filter((anchor): anchor is string => Boolean(anchor)),
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
- /** Precompute character offsets for each line start. offsets[i] = char offset of line i+1 (1-based). */
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
- /** Binary search: find 1-based line number containing the given char offset.
594
- * Uses precomputed line offsets instead of scanning from the beginning. */
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; // 1-based
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
- /** Convert a character offset to a 1-based line number. O(n) — prefer lineAtOffset for repeated calls on same content. */
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
- function randomId(): string {
615
- return Math.random().toString(36).slice(2, 10);
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 },