@llmist/cli 16.0.1 → 16.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -34,18 +34,22 @@ import { createGadget } from "llmist";
34
34
  import { z } from "zod";
35
35
 
36
36
  // src/builtins/filesystem/editfile/matcher.ts
37
+ import DiffMatchPatch from "diff-match-patch";
38
+ var dmp = new DiffMatchPatch();
37
39
  var DEFAULT_OPTIONS = {
38
40
  fuzzyThreshold: 0.8,
39
41
  maxSuggestions: 3,
40
42
  contextLines: 5
41
43
  };
42
44
  function findMatch(content, search, options = {}) {
45
+ if (!search) return null;
43
46
  const opts = { ...DEFAULT_OPTIONS, ...options };
44
47
  const strategies = [
45
48
  { name: "exact", fn: exactMatch },
46
49
  { name: "whitespace", fn: whitespaceMatch },
47
50
  { name: "indentation", fn: indentationMatch },
48
- { name: "fuzzy", fn: (c, s) => fuzzyMatch(c, s, opts.fuzzyThreshold) }
51
+ { name: "fuzzy", fn: (c, s) => fuzzyMatch(c, s, opts.fuzzyThreshold) },
52
+ { name: "dmp", fn: (c, s) => dmpMatch(c, s, opts.fuzzyThreshold) }
49
53
  ];
50
54
  for (const { name, fn } of strategies) {
51
55
  const result = fn(content, search);
@@ -111,7 +115,8 @@ function indentationMatch(content, search) {
111
115
  const stripIndent = (s) => s.split("\n").map((line) => line.trimStart()).join("\n");
112
116
  const strippedSearch = stripIndent(search);
113
117
  const contentLines = content.split("\n");
114
- const searchLineCount = search.split("\n").length;
118
+ const searchLines = search.split("\n");
119
+ const searchLineCount = searchLines.length;
115
120
  for (let i = 0; i <= contentLines.length - searchLineCount; i++) {
116
121
  const windowLines = contentLines.slice(i, i + searchLineCount);
117
122
  const strippedWindow = stripIndent(windowLines.join("\n"));
@@ -120,6 +125,7 @@ function indentationMatch(content, search) {
120
125
  const matchedContent = windowLines.join("\n");
121
126
  const endIndex = startIndex + matchedContent.length;
122
127
  const { startLine, endLine } = getLineNumbers(content, startIndex, endIndex);
128
+ const indentationDelta = computeIndentationDelta(searchLines, windowLines);
123
129
  return {
124
130
  found: true,
125
131
  strategy: "indentation",
@@ -128,7 +134,8 @@ function indentationMatch(content, search) {
128
134
  startIndex,
129
135
  endIndex,
130
136
  startLine,
131
- endLine
137
+ endLine,
138
+ indentationDelta
132
139
  };
133
140
  }
134
141
  }
@@ -166,6 +173,94 @@ function fuzzyMatch(content, search, threshold) {
166
173
  endLine
167
174
  };
168
175
  }
176
+ function dmpMatch(content, search, threshold) {
177
+ if (!search || !content) return null;
178
+ if (search.length > 1e3) return null;
179
+ if (search.split("\n").length > 20) return null;
180
+ const matchIndex = search.length <= 32 ? dmpMatchShortPattern(content, search, threshold) : dmpMatchLongPattern(content, search, threshold);
181
+ if (matchIndex === -1) return null;
182
+ const matchedContent = findBestMatchExtent(content, matchIndex, search, threshold);
183
+ if (!matchedContent) return null;
184
+ const startIndex = matchIndex;
185
+ const endIndex = matchIndex + matchedContent.length;
186
+ const { startLine, endLine } = getLineNumbers(content, startIndex, endIndex);
187
+ const similarity = stringSimilarity(search, matchedContent);
188
+ return {
189
+ found: true,
190
+ strategy: "dmp",
191
+ confidence: similarity,
192
+ matchedContent,
193
+ startIndex,
194
+ endIndex,
195
+ startLine,
196
+ endLine
197
+ };
198
+ }
199
+ function dmpMatchShortPattern(content, search, threshold) {
200
+ dmp.Match_Threshold = 1 - threshold;
201
+ dmp.Match_Distance = 1e3;
202
+ const index = dmp.match_main(content, search, 0);
203
+ return index;
204
+ }
205
+ function dmpMatchLongPattern(content, search, threshold) {
206
+ const prefix = search.slice(0, 32);
207
+ dmp.Match_Threshold = 1 - threshold;
208
+ dmp.Match_Distance = 1e3;
209
+ const prefixIndex = dmp.match_main(content, prefix, 0);
210
+ if (prefixIndex === -1) return -1;
211
+ const windowPadding = Math.max(50, Math.floor(search.length / 2));
212
+ const windowStart = Math.max(0, prefixIndex - windowPadding);
213
+ const windowEnd = Math.min(content.length, prefixIndex + search.length + windowPadding);
214
+ const window = content.slice(windowStart, windowEnd);
215
+ let bestIndex = -1;
216
+ let bestSimilarity = 0;
217
+ for (let i = 0; i <= window.length - search.length; i++) {
218
+ const candidate = window.slice(i, i + search.length);
219
+ const similarity = stringSimilarity(search, candidate);
220
+ if (similarity >= threshold && similarity > bestSimilarity) {
221
+ bestSimilarity = similarity;
222
+ bestIndex = windowStart + i;
223
+ }
224
+ }
225
+ return bestIndex;
226
+ }
227
+ function findBestMatchExtent(content, matchIndex, search, threshold) {
228
+ const exactLength = content.slice(matchIndex, matchIndex + search.length);
229
+ if (stringSimilarity(search, exactLength) >= threshold) {
230
+ return exactLength;
231
+ }
232
+ const searchLines = search.split("\n").length;
233
+ const contentFromMatch = content.slice(matchIndex);
234
+ const contentLines = contentFromMatch.split("\n");
235
+ if (contentLines.length >= searchLines) {
236
+ const lineBasedMatch = contentLines.slice(0, searchLines).join("\n");
237
+ if (stringSimilarity(search, lineBasedMatch) >= threshold) {
238
+ return lineBasedMatch;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+ function findAllMatches(content, search, options = {}) {
244
+ const results = [];
245
+ let searchStart = 0;
246
+ while (searchStart < content.length) {
247
+ const remainingContent = content.slice(searchStart);
248
+ const match = findMatch(remainingContent, search, options);
249
+ if (!match) break;
250
+ results.push({
251
+ ...match,
252
+ startIndex: searchStart + match.startIndex,
253
+ endIndex: searchStart + match.endIndex,
254
+ // Recalculate line numbers for original content
255
+ ...getLineNumbers(content, searchStart + match.startIndex, searchStart + match.endIndex)
256
+ });
257
+ searchStart = searchStart + match.endIndex;
258
+ if (match.endIndex === 0) {
259
+ searchStart++;
260
+ }
261
+ }
262
+ return results;
263
+ }
169
264
  function findSuggestions(content, search, maxSuggestions, minSimilarity) {
170
265
  const searchLines = search.split("\n");
171
266
  const contentLines = content.split("\n");
@@ -282,6 +377,78 @@ function getContext(content, lineNumber, contextLines) {
282
377
  });
283
378
  return contextWithNumbers.join("\n");
284
379
  }
380
+ function getLeadingWhitespace(line) {
381
+ const match = line.match(/^[ \t]*/);
382
+ return match ? match[0] : "";
383
+ }
384
+ function computeIndentationDelta(searchLines, matchedLines) {
385
+ for (let i = 0; i < Math.min(searchLines.length, matchedLines.length); i++) {
386
+ const searchLine = searchLines[i];
387
+ const matchedLine = matchedLines[i];
388
+ if (searchLine.trim() === "" && matchedLine.trim() === "") continue;
389
+ const searchIndent = getLeadingWhitespace(searchLine);
390
+ const matchedIndent = getLeadingWhitespace(matchedLine);
391
+ if (matchedIndent.length > searchIndent.length) {
392
+ return matchedIndent.slice(searchIndent.length);
393
+ }
394
+ return "";
395
+ }
396
+ return "";
397
+ }
398
+ function adjustIndentation(replacement, delta) {
399
+ if (!delta) return replacement;
400
+ return replacement.split("\n").map((line, index) => {
401
+ if (line.trim() === "") return line;
402
+ return delta + line;
403
+ }).join("\n");
404
+ }
405
+ function formatEditContext(originalContent, match, replacement, contextLines = 5) {
406
+ const lines = originalContent.split("\n");
407
+ const startLine = match.startLine - 1;
408
+ const endLine = match.endLine;
409
+ const contextStart = Math.max(0, startLine - contextLines);
410
+ const contextEnd = Math.min(lines.length, endLine + contextLines);
411
+ const output = [];
412
+ output.push(`=== Edit (lines ${match.startLine}-${match.endLine}) ===`);
413
+ output.push("");
414
+ for (let i = contextStart; i < startLine; i++) {
415
+ output.push(` ${String(i + 1).padStart(4)} | ${lines[i]}`);
416
+ }
417
+ for (let i = startLine; i < endLine; i++) {
418
+ output.push(`< ${String(i + 1).padStart(4)} | ${lines[i]}`);
419
+ }
420
+ const replacementLines = replacement.split("\n");
421
+ for (let i = 0; i < replacementLines.length; i++) {
422
+ const lineNum = startLine + i + 1;
423
+ output.push(`> ${String(lineNum).padStart(4)} | ${replacementLines[i]}`);
424
+ }
425
+ for (let i = endLine; i < contextEnd; i++) {
426
+ output.push(` ${String(i + 1).padStart(4)} | ${lines[i]}`);
427
+ }
428
+ return output.join("\n");
429
+ }
430
+ function formatMultipleMatches(content, matches, maxMatches = 5) {
431
+ const lines = content.split("\n");
432
+ const output = [];
433
+ output.push(`Found ${matches.length} matches:`);
434
+ output.push("");
435
+ const displayMatches = matches.slice(0, maxMatches);
436
+ for (let i = 0; i < displayMatches.length; i++) {
437
+ const match = displayMatches[i];
438
+ output.push(`Match ${i + 1} (lines ${match.startLine}-${match.endLine}):`);
439
+ const contextStart = Math.max(0, match.startLine - 2);
440
+ const contextEnd = Math.min(lines.length, match.endLine + 1);
441
+ for (let j = contextStart; j < contextEnd; j++) {
442
+ const marker = j >= match.startLine - 1 && j < match.endLine ? ">" : " ";
443
+ output.push(`${marker}${String(j + 1).padStart(4)} | ${lines[j]}`);
444
+ }
445
+ output.push("");
446
+ }
447
+ if (matches.length > maxMatches) {
448
+ output.push(`... and ${matches.length - maxMatches} more matches`);
449
+ }
450
+ return output.join("\n");
451
+ }
285
452
 
286
453
  // src/builtins/filesystem/edit-file.ts
287
454
  function formatFailure(filePath, search, failure, fileContent) {
@@ -323,22 +490,30 @@ Uses layered matching strategies (in order):
323
490
  2. Whitespace-insensitive - ignores differences in spaces/tabs
324
491
  3. Indentation-preserving - matches structure ignoring leading whitespace
325
492
  4. Fuzzy match - similarity-based matching (80% threshold)
493
+ 5. DMP (diff-match-patch) - handles heavily refactored code
326
494
 
327
495
  For multiple edits to the same file, call this gadget multiple times.
328
- Each call provides immediate feedback, allowing you to adjust subsequent edits.`,
496
+ Each call provides immediate feedback, allowing you to adjust subsequent edits.
497
+
498
+ Options:
499
+ - replaceAll: Replace all occurrences instead of just the first
500
+ - expectedCount: Validate exact number of matches before applying`,
329
501
  maxConcurrent: 1,
330
502
  // Sequential execution to prevent race conditions
331
503
  schema: z.object({
332
504
  filePath: z.string().describe("Path to the file to edit (relative or absolute)"),
333
505
  search: z.string().describe("The content to search for in the file"),
334
- replace: z.string().describe("The content to replace it with (empty string to delete)")
506
+ replace: z.string().describe("The content to replace it with (empty string to delete)"),
507
+ replaceAll: z.boolean().optional().default(false).describe("Replace all occurrences instead of just the first match"),
508
+ expectedCount: z.number().int().positive().optional().describe("Expected number of matches. Edit fails if actual count differs")
335
509
  }),
336
510
  examples: [
337
511
  {
338
512
  params: {
339
513
  filePath: "src/config.ts",
340
514
  search: "const DEBUG = false;",
341
- replace: "const DEBUG = true;"
515
+ replace: "const DEBUG = true;",
516
+ replaceAll: false
342
517
  },
343
518
  output: "path=src/config.ts status=success strategy=exact lines=5-5\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// config.ts\nconst DEBUG = true;\nexport default { DEBUG };\n```",
344
519
  comment: "Simple single-line edit"
@@ -351,7 +526,8 @@ Each call provides immediate feedback, allowing you to adjust subsequent edits.`
351
526
  }`,
352
527
  replace: `function newHelper() {
353
528
  return 2;
354
- }`
529
+ }`,
530
+ replaceAll: false
355
531
  },
356
532
  output: "path=src/utils.ts status=success strategy=exact lines=10-12\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// utils.ts\nfunction newHelper() {\n return 2;\n}\n```",
357
533
  comment: "Multi-line replacement"
@@ -360,14 +536,25 @@ Each call provides immediate feedback, allowing you to adjust subsequent edits.`
360
536
  params: {
361
537
  filePath: "src/app.ts",
362
538
  search: "unusedImport",
363
- replace: ""
539
+ replace: "",
540
+ replaceAll: false
364
541
  },
365
542
  output: 'path=src/app.ts status=success strategy=exact lines=3-3\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// app.ts\nimport { usedImport } from "./lib";\n```',
366
543
  comment: "Delete content by replacing with empty string"
544
+ },
545
+ {
546
+ params: {
547
+ filePath: "src/constants.ts",
548
+ search: "OLD_VALUE",
549
+ replace: "NEW_VALUE",
550
+ replaceAll: true
551
+ },
552
+ output: "path=src/constants.ts status=success matches=3 lines=[2-2, 5-5, 8-8]\n\nReplaced 3 occurrences\n\nUPDATED FILE CONTENT:\n```\n// constants.ts\nexport const A = NEW_VALUE;\nexport const B = NEW_VALUE;\nexport const C = NEW_VALUE;\n```",
553
+ comment: "Replace all occurrences with replaceAll=true"
367
554
  }
368
555
  ],
369
556
  timeoutMs: 3e4,
370
- execute: ({ filePath, search, replace }) => {
557
+ execute: ({ filePath, search, replace, replaceAll, expectedCount }) => {
371
558
  if (search.trim() === "") {
372
559
  return `path=${filePath} status=error
373
560
 
@@ -397,12 +584,43 @@ Error: File not found: ${filePath}`;
397
584
 
398
585
  Error reading file: ${message}`;
399
586
  }
400
- const match = findMatch(content, search);
401
- if (!match) {
587
+ const allMatches = findAllMatches(content, search);
588
+ if (allMatches.length === 0) {
402
589
  const failure = getMatchFailure(content, search);
403
590
  return formatFailure(filePath, search, failure, content);
404
591
  }
405
- const newContent = applyReplacement(content, match, replace);
592
+ if (expectedCount !== void 0 && allMatches.length !== expectedCount) {
593
+ return `path=${filePath} status=error
594
+
595
+ Error: Expected ${expectedCount} match(es) but found ${allMatches.length}.
596
+
597
+ ${formatMultipleMatches(content, allMatches)}`;
598
+ }
599
+ if (allMatches.length > 1 && !replaceAll) {
600
+ const matchSummary = formatMultipleMatches(content, allMatches);
601
+ return `path=${filePath} status=error
602
+
603
+ Error: Found ${allMatches.length} matches. Please either:
604
+ 1. Add more context to your search to make it unique
605
+ 2. Use replaceAll=true to replace all occurrences
606
+
607
+ ${matchSummary}`;
608
+ }
609
+ let newContent;
610
+ let editSummary;
611
+ if (replaceAll && allMatches.length > 1) {
612
+ newContent = executeReplaceAll(content, allMatches, replace);
613
+ const MAX_DISPLAYED_RANGES = 5;
614
+ const displayMatches = allMatches.slice(0, MAX_DISPLAYED_RANGES);
615
+ const lineRanges = displayMatches.map((m) => `${m.startLine}-${m.endLine}`).join(", ");
616
+ const suffix = allMatches.length > MAX_DISPLAYED_RANGES ? `, +${allMatches.length - MAX_DISPLAYED_RANGES} more` : "";
617
+ editSummary = `matches=${allMatches.length} lines=[${lineRanges}${suffix}]`;
618
+ } else {
619
+ const match = allMatches[0];
620
+ const finalReplace = prepareReplacement(match, replace);
621
+ newContent = applyReplacement(content, match, finalReplace);
622
+ editSummary = `strategy=${match.strategy} lines=${match.startLine}-${match.endLine}`;
623
+ }
406
624
  try {
407
625
  writeFileSync(validatedPath, newContent, "utf-8");
408
626
  } catch (error) {
@@ -411,9 +629,10 @@ Error reading file: ${message}`;
411
629
 
412
630
  Error writing file: ${message}`;
413
631
  }
414
- return `path=${filePath} status=success strategy=${match.strategy} lines=${match.startLine}-${match.endLine}
632
+ const diffContext = allMatches.length === 1 ? formatEditContext(content, allMatches[0], prepareReplacement(allMatches[0], replace)) : `Replaced ${allMatches.length} occurrences`;
633
+ return `path=${filePath} status=success ${editSummary}
415
634
 
416
- Replaced content successfully.
635
+ ${diffContext}
417
636
 
418
637
  UPDATED FILE CONTENT:
419
638
  \`\`\`
@@ -421,6 +640,21 @@ ${newContent}
421
640
  \`\`\``;
422
641
  }
423
642
  });
643
+ function prepareReplacement(match, replace) {
644
+ if (match.strategy === "indentation" && match.indentationDelta) {
645
+ return adjustIndentation(replace, match.indentationDelta);
646
+ }
647
+ return replace;
648
+ }
649
+ function executeReplaceAll(content, matches, replace) {
650
+ const sortedMatches = [...matches].sort((a, b) => b.startIndex - a.startIndex);
651
+ let result = content;
652
+ for (const match of sortedMatches) {
653
+ const finalReplace = prepareReplacement(match, replace);
654
+ result = applyReplacement(result, match, finalReplace);
655
+ }
656
+ return result;
657
+ }
424
658
 
425
659
  // src/builtins/filesystem/list-directory.ts
426
660
  import fs2 from "fs";