@tyvm/knowhow 0.0.117 → 0.0.119

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.
Files changed (133) hide show
  1. package/package.json +1 -3
  2. package/src/agents/base/base.ts +72 -9
  3. package/src/agents/researcher/researcher.ts +9 -2
  4. package/src/agents/tools/list.ts +13 -2
  5. package/src/agents/tools/patch.ts +318 -32
  6. package/src/agents/tools/readFile.ts +48 -5
  7. package/src/chat/modules/AgentModule.ts +12 -0
  8. package/src/cli.ts +4 -0
  9. package/src/clients/anthropic.ts +12 -2
  10. package/src/clients/contextLimits.ts +77 -0
  11. package/src/commands/convert.ts +291 -0
  12. package/src/commands/mcp.ts +742 -0
  13. package/src/conversion.ts +15 -61
  14. package/src/index.ts +3 -0
  15. package/src/processors/TokenCompressor.ts +95 -9
  16. package/src/services/AgentSyncFs.ts +26 -4
  17. package/src/services/AgentSyncKnowhowWeb.ts +26 -4
  18. package/src/services/Mcp.ts +3 -1
  19. package/src/services/SyncedAgentWatcher.ts +8 -0
  20. package/src/services/conversion/ConversionService.ts +763 -0
  21. package/src/services/conversion/index.ts +2 -0
  22. package/src/services/conversion/types.ts +79 -0
  23. package/src/services/index.ts +8 -1
  24. package/src/services/modules/types.ts +2 -0
  25. package/src/services/watchers/FsSyncer.ts +6 -0
  26. package/src/services/watchers/RemoteSyncer.ts +5 -0
  27. package/src/types.ts +1 -0
  28. package/tests/agents/tools/readFile.test.ts +88 -0
  29. package/tests/clients/AIClient.test.ts +5 -0
  30. package/tests/clients/contextLimits.test.ts +71 -0
  31. package/tests/patching/patchFileOutput.test.ts +217 -0
  32. package/tests/patching/regression-2026.test.ts +278 -0
  33. package/tests/processors/CustomVariables.test.ts +4 -4
  34. package/tests/processors/TokenCompressor.test.ts +59 -1
  35. package/tests/processors/tools/grepToolResponse.test.ts +72 -0
  36. package/tests/services/ConversionService.test.ts +154 -0
  37. package/tests/test.spec.ts +1 -1
  38. package/tests/unit/clients/AIClient.test.ts +8 -0
  39. package/ts_build/package.json +1 -3
  40. package/ts_build/src/agents/base/base.d.ts +3 -0
  41. package/ts_build/src/agents/base/base.js +46 -3
  42. package/ts_build/src/agents/base/base.js.map +1 -1
  43. package/ts_build/src/agents/researcher/researcher.js +5 -2
  44. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  45. package/ts_build/src/agents/tools/list.js +10 -2
  46. package/ts_build/src/agents/tools/list.js.map +1 -1
  47. package/ts_build/src/agents/tools/patch.js +202 -24
  48. package/ts_build/src/agents/tools/patch.js.map +1 -1
  49. package/ts_build/src/agents/tools/readFile.d.ts +1 -1
  50. package/ts_build/src/agents/tools/readFile.js +17 -4
  51. package/ts_build/src/agents/tools/readFile.js.map +1 -1
  52. package/ts_build/src/chat/modules/AgentModule.js +12 -0
  53. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  54. package/ts_build/src/cli.js +4 -0
  55. package/ts_build/src/cli.js.map +1 -1
  56. package/ts_build/src/clients/anthropic.js +7 -2
  57. package/ts_build/src/clients/anthropic.js.map +1 -1
  58. package/ts_build/src/clients/contextLimits.js +70 -0
  59. package/ts_build/src/clients/contextLimits.js.map +1 -1
  60. package/ts_build/src/commands/convert.d.ts +2 -0
  61. package/ts_build/src/commands/convert.js +275 -0
  62. package/ts_build/src/commands/convert.js.map +1 -0
  63. package/ts_build/src/commands/mcp.d.ts +2 -0
  64. package/ts_build/src/commands/mcp.js +664 -0
  65. package/ts_build/src/commands/mcp.js.map +1 -0
  66. package/ts_build/src/conversion.js +6 -38
  67. package/ts_build/src/conversion.js.map +1 -1
  68. package/ts_build/src/index.d.ts +2 -0
  69. package/ts_build/src/index.js +4 -1
  70. package/ts_build/src/index.js.map +1 -1
  71. package/ts_build/src/processors/TokenCompressor.d.ts +2 -0
  72. package/ts_build/src/processors/TokenCompressor.js +57 -7
  73. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  74. package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
  75. package/ts_build/src/services/AgentSyncFs.js +21 -4
  76. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  77. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -0
  78. package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
  79. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  80. package/ts_build/src/services/Mcp.js +2 -1
  81. package/ts_build/src/services/Mcp.js.map +1 -1
  82. package/ts_build/src/services/SyncedAgentWatcher.d.ts +3 -0
  83. package/ts_build/src/services/SyncedAgentWatcher.js +4 -0
  84. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  85. package/ts_build/src/services/conversion/ConversionService.d.ts +18 -0
  86. package/ts_build/src/services/conversion/ConversionService.js +585 -0
  87. package/ts_build/src/services/conversion/ConversionService.js.map +1 -0
  88. package/ts_build/src/services/conversion/index.d.ts +2 -0
  89. package/ts_build/src/services/conversion/index.js +19 -0
  90. package/ts_build/src/services/conversion/index.js.map +1 -0
  91. package/ts_build/src/services/conversion/types.d.ts +49 -0
  92. package/ts_build/src/services/conversion/types.js +3 -0
  93. package/ts_build/src/services/conversion/types.js.map +1 -0
  94. package/ts_build/src/services/index.d.ts +3 -0
  95. package/ts_build/src/services/index.js +6 -1
  96. package/ts_build/src/services/index.js.map +1 -1
  97. package/ts_build/src/services/modules/index.d.ts +2 -0
  98. package/ts_build/src/services/modules/types.d.ts +2 -0
  99. package/ts_build/src/services/watchers/FsSyncer.d.ts +1 -0
  100. package/ts_build/src/services/watchers/FsSyncer.js +5 -0
  101. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  102. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +1 -0
  103. package/ts_build/src/services/watchers/RemoteSyncer.js +4 -0
  104. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  105. package/ts_build/src/types.d.ts +1 -0
  106. package/ts_build/src/types.js.map +1 -1
  107. package/ts_build/tests/agents/tools/readFile.test.d.ts +1 -0
  108. package/ts_build/tests/agents/tools/readFile.test.js +90 -0
  109. package/ts_build/tests/agents/tools/readFile.test.js.map +1 -0
  110. package/ts_build/tests/clients/AIClient.test.js +1 -0
  111. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  112. package/ts_build/tests/clients/contextLimits.test.d.ts +1 -0
  113. package/ts_build/tests/clients/contextLimits.test.js +57 -0
  114. package/ts_build/tests/clients/contextLimits.test.js.map +1 -0
  115. package/ts_build/tests/patching/patchFileOutput.test.d.ts +1 -0
  116. package/ts_build/tests/patching/patchFileOutput.test.js +187 -0
  117. package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -0
  118. package/ts_build/tests/patching/regression-2026.test.js +214 -0
  119. package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
  120. package/ts_build/tests/processors/CustomVariables.test.js +4 -4
  121. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
  122. package/ts_build/tests/processors/TokenCompressor.test.js +37 -1
  123. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  124. package/ts_build/tests/processors/tools/grepToolResponse.test.d.ts +1 -0
  125. package/ts_build/tests/processors/tools/grepToolResponse.test.js +40 -0
  126. package/ts_build/tests/processors/tools/grepToolResponse.test.js.map +1 -0
  127. package/ts_build/tests/services/ConversionService.test.d.ts +1 -0
  128. package/ts_build/tests/services/ConversionService.test.js +154 -0
  129. package/ts_build/tests/services/ConversionService.test.js.map +1 -0
  130. package/ts_build/tests/test.spec.js +1 -1
  131. package/ts_build/tests/test.spec.js.map +1 -1
  132. package/ts_build/tests/unit/clients/AIClient.test.js +3 -0
  133. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.117",
3
+ "version": "0.0.119",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -40,7 +40,6 @@
40
40
  "@types/jest": "^29.5.13",
41
41
  "@types/mocha": "^10.0.8",
42
42
  "@types/node": "^20.6.3",
43
- "@types/pdf-parse": "^1.1.4",
44
43
  "@types/ws": "^8.18.1",
45
44
  "jest": "^29.1.1",
46
45
  "prettier": "2.6.2",
@@ -67,7 +66,6 @@
67
66
  "minimatch": "^10.1.2",
68
67
  "node-jq": "^6.0.1",
69
68
  "openai": "4.89.1",
70
- "pdf-parse": "^1.1.1",
71
69
  "ws": "^8.18.1",
72
70
  "zod": "^3.25.0"
73
71
  },
@@ -68,6 +68,9 @@ export abstract class BaseAgent implements IAgent {
68
68
  protected compressThreshold = 30000;
69
69
  protected compressMinMessages = 30;
70
70
 
71
+ // Interrupt support: resolves the currently awaited tool call or completion
72
+ private _interruptResolve: ((value: any) => void) | null = null;
73
+
71
74
  protected threads = [] as Message[][];
72
75
 
73
76
  // Message from users
@@ -479,9 +482,18 @@ export abstract class BaseAgent implements IAgent {
479
482
  async processToolMessages(toolCall: ToolCall) {
480
483
  this.agentEvents.emit(this.eventTypes.toolCall, { toolCall });
481
484
 
482
- const { functionResp, toolMessages } = await this.tools.callTool(
483
- toolCall,
484
- this.getEnabledToolNames()
485
+ const interruptMsg = `User interrupted this tool call (${toolCall.function?.name})`;
486
+ const interruptResult: Awaited<ReturnType<typeof this.tools.callTool>> = {
487
+ functionResp: interruptMsg,
488
+ toolCallId: toolCall.id,
489
+ functionName: toolCall.function?.name,
490
+ functionArgs: {},
491
+ toolMessages: [{ role: "tool", tool_call_id: toolCall.id, name: toolCall.function?.name, content: interruptMsg }],
492
+ };
493
+
494
+ const { functionResp, toolMessages } = await this.makeInterruptible(
495
+ this.tools.callTool(toolCall, this.getEnabledToolNames()),
496
+ interruptResult
485
497
  );
486
498
 
487
499
  this.agentEvents.emit(this.eventTypes.toolUsed, {
@@ -647,6 +659,48 @@ export abstract class BaseAgent implements IAgent {
647
659
  } as Message);
648
660
  }
649
661
 
662
+ /**
663
+ * Wrap a promise so it can be interrupted via interrupt().
664
+ * If interrupt() is called while waiting, the promise resolves with the
665
+ * interrupt message instead of waiting for the original operation to complete.
666
+ */
667
+ protected makeInterruptible<T>(
668
+ promise: Promise<T>,
669
+ interruptValue: T
670
+ ): Promise<T> {
671
+ return new Promise<T>((resolve) => {
672
+ this._interruptResolve = (value: any) => {
673
+ this._interruptResolve = null;
674
+ resolve(value);
675
+ };
676
+ promise.then((result) => {
677
+ if (this._interruptResolve) {
678
+ this._interruptResolve = null;
679
+ resolve(result);
680
+ }
681
+ }).catch((err) => {
682
+ if (this._interruptResolve) {
683
+ this._interruptResolve = null;
684
+ resolve(interruptValue);
685
+ }
686
+ });
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Interrupt the currently awaited tool call or AI completion.
692
+ * The waiting promise will resolve immediately with an interrupt message,
693
+ * allowing the agent to continue its loop with the interruption as context.
694
+ */
695
+ interrupt(message = "User interrupted this action you were waiting on") {
696
+ this.log(`Interrupting current operation: ${message}`);
697
+ if (this._interruptResolve) {
698
+ this._interruptResolve(message);
699
+ } else {
700
+ this.log("No active interruptible operation to interrupt", "warn");
701
+ }
702
+ }
703
+
650
704
  async call(
651
705
  userInput: string | MessageContent[],
652
706
  _messages?: Message[],
@@ -717,13 +771,22 @@ export abstract class BaseAgent implements IAgent {
717
771
  "pre_call"
718
772
  );
719
773
 
720
- const response = await this.getClient().createChatCompletion({
774
+ const interruptResponse: CompletionResponse = {
775
+ choices: [{ message: { role: "assistant", content: "User interrupted this AI completion. Please continue." } }],
721
776
  model,
722
- messages,
723
- tools: this.getEnabledTools(),
724
- tool_choice: "auto",
725
- long_ttl_cache: this.runTime() > 300_000,
726
- });
777
+ usage: undefined,
778
+ usd_cost: 0,
779
+ };
780
+ const response = await this.makeInterruptible(
781
+ this.getClient().createChatCompletion({
782
+ model,
783
+ messages,
784
+ tools: this.getEnabledTools(),
785
+ tool_choice: "auto",
786
+ long_ttl_cache: this.runTime() > 300_000,
787
+ }),
788
+ interruptResponse
789
+ );
727
790
 
728
791
  // If the agent was paused while the completion was in-flight, wait here
729
792
  // before processing tool calls. This allows the user to send messages
@@ -8,8 +8,15 @@ export class ResearcherAgent extends BaseAgent {
8
8
 
9
9
  constructor(context: AgentContext) {
10
10
  super(context);
11
- this.setModel(Models.google.Gemini_3_Flash_Preview);
12
- this.setProvider("google");
11
+ // Prefer Gemini for research, but fall back to other registered providers
12
+ // so the agent still runs when GEMINI_API_KEY is not configured. Without
13
+ // fallbacks, an unregistered "google" provider causes the health-check loop
14
+ // to immediately throw "We have exhausted all model preferences."
15
+ this.setModelPreferences([
16
+ { model: Models.google.Gemini_3_Flash_Preview, provider: "google" },
17
+ { model: Models.anthropic.Sonnet4_6, provider: "anthropic" },
18
+ { model: Models.openai.GPT_53_Codex, provider: "openai" },
19
+ ]);
13
20
  this.disableTool("patchFile");
14
21
  this.disableTool("writeFile");
15
22
  this.disableTool("writeChunk");
@@ -174,7 +174,7 @@ export const includedTools = [
174
174
  function: {
175
175
  name: "readFile",
176
176
  description:
177
- "Read the contents of a file and return them as an array of blocks",
177
+ "Read the contents of a file as plain text. Optionally pass fromLine/toLine (1-based, inclusive) to read just a range of lines; ranged reads are prefixed with real source line numbers.",
178
178
  parameters: {
179
179
  type: "object",
180
180
  positional: true,
@@ -183,12 +183,23 @@ export const includedTools = [
183
183
  type: "string",
184
184
  description: "The path to the file to be read",
185
185
  },
186
+ fromLine: {
187
+ type: "number",
188
+ description:
189
+ "Optional 1-based start line (inclusive). When provided, only lines from this point are returned, prefixed with real source line numbers.",
190
+ },
191
+ toLine: {
192
+ type: "number",
193
+ description:
194
+ "Optional 1-based end line (inclusive). Defaults to the end of the file when omitted.",
195
+ },
186
196
  },
187
197
  required: ["filePath"],
188
198
  },
189
199
  returns: {
190
200
  type: "string",
191
- description: "The file contents in diff format",
201
+ description:
202
+ "The file contents as plain text. When a line range is requested, each line is prefixed with its real source line number.",
192
203
  },
193
204
  },
194
205
  },
@@ -109,6 +109,31 @@ function findSequenceIndex(haystack: string[], needle: string[]): number {
109
109
  return -1;
110
110
  } // --- Hunk Parsing and Formatting (Keep as is) ---
111
111
 
112
+ /**
113
+ * Like findSequenceIndex but compares lines by trimmed content, so it tolerates
114
+ * leading/trailing whitespace drift between a hand-written patch and the file.
115
+ * Returns the 0-based start index in haystack, or -1.
116
+ */
117
+ function findSequenceIndexTrimmed(
118
+ haystack: string[],
119
+ needle: string[]
120
+ ): number {
121
+ if (!needle || needle.length === 0) return -1;
122
+ if (!haystack || needle.length > haystack.length) return -1;
123
+ const needleTrim = needle.map((l) => l.trim());
124
+ for (let start = 0; start <= haystack.length - needle.length; start++) {
125
+ let matches = true;
126
+ for (let i = 0; i < needle.length; i++) {
127
+ if (haystack[start + i].trim() !== needleTrim[i]) {
128
+ matches = false;
129
+ break;
130
+ }
131
+ }
132
+ if (matches) return start;
133
+ }
134
+ return -1;
135
+ }
136
+
112
137
  export interface Hunk {
113
138
  header: string;
114
139
  originalStartLine: number; // Parsed from -s,l
@@ -294,16 +319,34 @@ function fixSingleHunk(hunk: Hunk, originalContent: string): Hunk | null {
294
319
  deletionLinesContent.length === 0 &&
295
320
  additionLinesContent.length > 0
296
321
  ) {
297
- // Find the context line just before the first addition in the *original* patch hunk
298
- let precedingContextLine = "";
322
+ // Collect the FULL leading context block (all context lines that appear before the
323
+ // first addition) so we can anchor on the whole sequence. This is far more reliable
324
+ // than a single preceding line when that line (e.g. a closing `}`) is ambiguous and
325
+ // appears multiple times in the file.
326
+ const leadingContextBlock: string[] = [];
299
327
  for (const line of hunk.lines) {
300
328
  if (line.startsWith("+")) break; // Stop when we hit the first addition
301
329
  if (line.startsWith(" ")) {
302
- precedingContextLine = line.slice(1); // Keep track of the last context line seen
330
+ leadingContextBlock.push(line.slice(1));
303
331
  }
304
332
  }
305
333
 
306
- if (precedingContextLine) {
334
+ // 3a. Try to anchor on the full leading context sequence first (unambiguous).
335
+ if (leadingContextBlock.length > 1) {
336
+ const seqIdx = findSequenceIndex(originalLines, leadingContextBlock);
337
+ if (seqIdx !== -1) {
338
+ // Additions go AFTER the full leading context block.
339
+ actualOriginalStartLine = seqIdx + leadingContextBlock.length + 1;
340
+ console.log(
341
+ `Anchor found via leading context sequence (${leadingContextBlock.length} lines), targeting line ${actualOriginalStartLine}`
342
+ );
343
+ }
344
+ }
345
+
346
+ // 3b. Fall back to the single preceding context line (closest to the header hint).
347
+ if (actualOriginalStartLine === -1 && leadingContextBlock.length > 0) {
348
+ const precedingContextLine =
349
+ leadingContextBlock[leadingContextBlock.length - 1];
307
350
  const potentialLines = findAllLineNumbers(
308
351
  originalContent,
309
352
  precedingContextLine
@@ -394,6 +437,104 @@ function fixSingleHunk(hunk: Hunk, originalContent: string): Hunk | null {
394
437
  }
395
438
 
396
439
  if (hasInterleavedChanges) {
440
+ // Preferred strategy: in-order reconstruction.
441
+ //
442
+ // The block-grouping fallback below is lossy — it skips addition-only
443
+ // blocks (dropping inserted lines) and re-orders additions relative to
444
+ // surrounding context, which corrupts nested scopes (e.g. moving a
445
+ // replaced object property outside of its `{ ... }`). Before falling back
446
+ // to it, try to anchor the hunk's "original side" (context + deletions, in
447
+ // order) as a contiguous region of the file and rebuild that region by
448
+ // walking the hunk body in order. This preserves the exact author intent.
449
+ {
450
+ // The sequence of lines the hunk expects to exist in the original file,
451
+ // in order (context lines and deletions; additions are new).
452
+ const originalSideLines: string[] = [];
453
+ for (const line of hunk.lines) {
454
+ if (line.startsWith("+")) continue;
455
+ originalSideLines.push(line.startsWith(" ") || line.startsWith("-") ? line.slice(1) : line);
456
+ }
457
+ // Drop trailing blank context lines that patches sometimes include after
458
+ // the last real line — they aren't part of the file region and break the
459
+ // contiguous anchor match.
460
+ while (
461
+ originalSideLines.length > 0 &&
462
+ originalSideLines[originalSideLines.length - 1].trim() === ""
463
+ ) {
464
+ originalSideLines.pop();
465
+ }
466
+
467
+ if (originalSideLines.length > 0) {
468
+ // Prefer an exact anchor; fall back to a whitespace-tolerant match so
469
+ // hand-written patches with slightly off indentation still apply.
470
+ let anchorIdx = findSequenceIndex(originalLines, originalSideLines);
471
+ let usedTrimmed = false;
472
+ if (anchorIdx === -1) {
473
+ anchorIdx = findSequenceIndexTrimmed(originalLines, originalSideLines);
474
+ usedTrimmed = anchorIdx !== -1;
475
+ }
476
+ if (anchorIdx !== -1) {
477
+ // The file's actual lines for this region (authoritative indentation).
478
+ const fileRegion = originalLines.slice(
479
+ anchorIdx,
480
+ anchorIdx + originalSideLines.length
481
+ );
482
+ // Compute indentation correction: difference between the file's first
483
+ // context line indentation and the patch's first original-side line.
484
+ const fileIndent = (fileRegion[0].match(/^\s*/) || [""])[0];
485
+ const patchIndent = (originalSideLines[0].match(/^\s*/) || [""])[0];
486
+ const indentDelta =
487
+ usedTrimmed && fileIndent.length >= patchIndent.length
488
+ ? fileIndent.slice(patchIndent.length)
489
+ : "";
490
+ // Rebuild the anchored region by replaying the hunk body in order.
491
+ // For context lines we use the file's authoritative version; for
492
+ // additions we re-indent by the computed delta.
493
+ const rebuilt: string[] = [];
494
+ let origCursor = 0; // index into fileRegion for context/deletion lines
495
+ for (const line of hunk.lines) {
496
+ if (line.startsWith("+")) {
497
+ rebuilt.push(indentDelta + line.slice(1));
498
+ } else if (line.startsWith("-")) {
499
+ origCursor++; // consume a file line, drop it
500
+ } else {
501
+ // context line. If it is a trailing line beyond the anchored
502
+ // region (e.g. a formatting-artifact blank line), ignore it.
503
+ if (origCursor >= fileRegion.length) continue;
504
+ // Otherwise keep the file's authoritative version.
505
+ rebuilt.push(fileRegion[origCursor]);
506
+ origCursor++;
507
+ }
508
+ }
509
+
510
+ // Emit a pure replacement hunk: delete the file's authoritative
511
+ // region and insert the rebuilt content. The deletion content is an
512
+ // exact, contiguous slice of the file, so the diff library anchors it
513
+ // unambiguously without needing surrounding context lines.
514
+ const newBody: string[] = [
515
+ ...fileRegion.map((l) => `-${l}`),
516
+ ...rebuilt.map((l) => `+${l}`),
517
+ ];
518
+
519
+ const origCountR = fileRegion.length;
520
+ const newCountR = rebuilt.length;
521
+ const headerR = `@@ -${anchorIdx + 1},${origCountR} +${anchorIdx + 1},${newCountR} @@`;
522
+
523
+ return {
524
+ header: headerR,
525
+ originalStartLine: anchorIdx + 1,
526
+ originalLineCount: origCountR,
527
+ newStartLine: anchorIdx + 1,
528
+ newLineCount: newCountR,
529
+ lines: newBody,
530
+ additions: newBody.filter((l) => l.startsWith("+")),
531
+ subtractions: newBody.filter((l) => l.startsWith("-")),
532
+ contextLines: newBody.filter((l) => l.startsWith(" ")),
533
+ };
534
+ }
535
+ }
536
+ }
537
+
397
538
  // Group hunk lines into change-blocks separated by context
398
539
  // Each block: { deletions, additions }
399
540
  type ChangeBlock = { deletions: string[]; additions: string[]; };
@@ -513,19 +654,56 @@ function fixSingleHunk(hunk: Hunk, originalContent: string): Hunk | null {
513
654
  };
514
655
  }
515
656
 
516
- // Pure insertion: output minimal -N,0 format required by unified diff spec
657
+ // Pure insertion: wrap the additions in real surrounding context from the file so the
658
+ // insertion position is unambiguous. A context-less `@@ -N,0 +N,M @@` hunk is applied
659
+ // inconsistently by the diff library (it can land the additions AFTER the anchor line
660
+ // instead of BEFORE it), which corrupts scopes (e.g. injecting a new function inside an
661
+ // existing function body). `actualOriginalStartLine` is the 1-based line that the new
662
+ // lines should be inserted *before*.
517
663
  if (deletionLinesContent.length === 0 && additionLinesContent.length > 0) {
518
- const pureHeader = `@@ -${actualOriginalStartLine},0 +${actualOriginalStartLine},${hunk.additions.length} @@`;
664
+ // Insertion index (0-based) within originalLines where additions are placed.
665
+ const insertIdx = actualOriginalStartLine - 1;
666
+
667
+ // Take up to CONTEXT_LINES real lines before and after the insertion point.
668
+ const beforeStart = Math.max(0, insertIdx - CONTEXT_LINES);
669
+ const contextBeforeLines: string[] = [];
670
+ for (let i = beforeStart; i < insertIdx && i < originalLines.length; i++) {
671
+ contextBeforeLines.push(` ${originalLines[i]}`);
672
+ }
673
+
674
+ const contextAfterLines: string[] = [];
675
+ for (
676
+ let i = insertIdx;
677
+ i < insertIdx + CONTEXT_LINES && i < originalLines.length;
678
+ i++
679
+ ) {
680
+ contextAfterLines.push(` ${originalLines[i]}`);
681
+ }
682
+
683
+ const pureLines = [
684
+ ...contextBeforeLines,
685
+ ...hunk.additions,
686
+ ...contextAfterLines,
687
+ ];
688
+
689
+ const pureOrigStart = insertIdx - contextBeforeLines.length + 1; // 1-based
690
+ const pureOrigCount = contextBeforeLines.length + contextAfterLines.length;
691
+ const pureNewCount =
692
+ contextBeforeLines.length +
693
+ hunk.additions.length +
694
+ contextAfterLines.length;
695
+
696
+ const pureHeader = `@@ -${pureOrigStart},${pureOrigCount} +${pureOrigStart},${pureNewCount} @@`;
519
697
  return {
520
698
  header: pureHeader,
521
- originalStartLine: actualOriginalStartLine,
522
- originalLineCount: 0,
523
- newStartLine: actualOriginalStartLine,
524
- newLineCount: hunk.additions.length,
525
- lines: hunk.additions,
699
+ originalStartLine: pureOrigStart,
700
+ originalLineCount: pureOrigCount,
701
+ newStartLine: pureOrigStart,
702
+ newLineCount: pureNewCount,
703
+ lines: pureLines,
526
704
  additions: hunk.additions,
527
705
  subtractions: [],
528
- contextLines: [],
706
+ contextLines: [...contextBeforeLines, ...contextAfterLines],
529
707
  };
530
708
  }
531
709
 
@@ -557,14 +735,27 @@ function fixSingleHunk(hunk: Hunk, originalContent: string): Hunk | null {
557
735
  })
558
736
  .filter((l): l is string => l !== null);
559
737
 
560
- // Supplement: add 1 extra line before the valid context for better anchoring
561
- const supplementBeforeIdx = actualOriginalStartLine - 1 - validContextBefore.length - 1; // 0-based
562
- const supplementBefore: string[] =
563
- supplementBeforeIdx >= 0
564
- ? [` ${originalLines[supplementBeforeIdx]}`]
565
- : [];
566
-
567
- const contextBefore = [...supplementBefore, ...validContextBefore];
738
+ // For deletion-anchored hunks, derive the leading context directly from the file just
739
+ // above the deletion start. This is far more reliable than mapping the hunk's context
740
+ // lines via `.find()` (which returns the FIRST match and mis-aligns when lines like a
741
+ // blank or a closing `}` repeat earlier in the file, producing duplicated context).
742
+ const fileContextBefore: string[] = [];
743
+ if (deletionLinesContent.length > 0) {
744
+ // Match the amount of leading context the original hunk carried (plus one extra
745
+ // anchor line, as the legacy implementation did), capped at CONTEXT_LINES. This keeps
746
+ // the header line counts stable while still deriving the *content* from the correct
747
+ // file position (avoiding the `.find()` duplication bug).
748
+ const desiredBeforeCount = Math.min(
749
+ CONTEXT_LINES,
750
+ validContextBefore.length + 1
751
+ );
752
+ const ctxStartIdx = Math.max(0, actualOriginalStartLine - 1 - desiredBeforeCount); // 0-based
753
+ for (let i = ctxStartIdx; i < actualOriginalStartLine - 1; i++) {
754
+ fileContextBefore.push(` ${originalLines[i]}`);
755
+ }
756
+ }
757
+ const contextBefore =
758
+ deletionLinesContent.length > 0 ? fileContextBefore : validContextBefore;
568
759
 
569
760
  // For context after: use valid context from hunk; if none, take 1 line from file
570
761
  const originalContentEndLine = actualOriginalStartLine + deletionLinesContent.length;
@@ -574,12 +765,22 @@ function fixSingleHunk(hunk: Hunk, originalContent: string): Hunk | null {
574
765
  const afterIdx = actualOriginalStartLine - 1; // 0-based index of line at insertion point
575
766
  contextAfter = afterIdx < originalLines.length ? [` ${originalLines[afterIdx]}`] : [];
576
767
  } else {
577
- contextAfter = validContextAfter;
578
- if (contextAfter.length === 0) {
579
- const afterIdx = originalContentEndLine - 1; // 0-based index after deletions
580
- if (afterIdx < originalLines.length) {
581
- contextAfter = [` ${originalLines[afterIdx]}`];
582
- }
768
+ // Derive trailing context directly from the file just below the deleted block, for the
769
+ // same reliability reasons as the leading context above. Match the amount of trailing
770
+ // context the original hunk carried (at least one line), capped at CONTEXT_LINES, so the
771
+ // header line counts stay stable.
772
+ contextAfter = [];
773
+ const desiredAfterCount = Math.max(
774
+ 1,
775
+ Math.min(CONTEXT_LINES, validContextAfter.length)
776
+ );
777
+ const afterStartIdx = originalContentEndLine - 1; // 0-based index after deletions
778
+ for (
779
+ let i = afterStartIdx;
780
+ i < afterStartIdx + desiredAfterCount && i < originalLines.length;
781
+ i++
782
+ ) {
783
+ contextAfter.push(` ${originalLines[i]}`);
583
784
  }
584
785
  }
585
786
 
@@ -777,8 +978,57 @@ export async function patchFile(
777
978
 
778
979
  let updatedContent = applyPatch(originalContent, patch);
779
980
  let appliedPatch = patch; // Keep track of which patch succeeded
981
+ const patchHunks = parseHunks(patch);
982
+ const patchHasAdditions = patchHunks.some((h) => h.additions.length > 0);
983
+ const patchHasDeletions = patchHunks.some((h) => h.subtractions.length > 0);
984
+
985
+ /**
986
+ * Build a human-readable summary of what changed between two content strings.
987
+ */
988
+ function buildChangeSummary(original: string, updated: string): string {
989
+ const origLines = original === "" ? [] : original.split("\n");
990
+ const updLines = updated === "" ? [] : updated.split("\n");
991
+ const netChange = updLines.length - origLines.length;
992
+
993
+ // Find lines unique to each side (approximate diff)
994
+ const origSet = new Set(origLines);
995
+ const updSet = new Set(updLines);
996
+ const addedLines = updLines.filter((l) => !origSet.has(l));
997
+ const removedLines = origLines.filter((l) => !updSet.has(l));
998
+
999
+ const linesAdded = netChange > 0 ? netChange : addedLines.length;
1000
+ const linesRemoved = netChange < 0 ? -netChange : removedLines.length;
1001
+
1002
+ const parts: string[] = [];
1003
+ parts.push(`+${linesAdded} lines, -${linesRemoved} lines (net: ${netChange > 0 ? "+" : ""}${netChange})`);
1004
+
1005
+ // Preview: show first 3 added lines
1006
+ const previewAdded = addedLines.slice(0, 3);
1007
+ if (previewAdded.length > 0) {
1008
+ parts.push("Preview of additions:\n" + previewAdded.map((l) => ` + ${l.trim().slice(0, 100)}`).join("\n"));
1009
+ }
1010
+ const previewRemoved = removedLines.slice(0, 3);
1011
+ if (previewRemoved.length > 0) {
1012
+ parts.push("Preview of removals:\n" + previewRemoved.map((l) => ` - ${l.trim().slice(0, 100)}`).join("\n"));
1013
+ }
1014
+ return parts.join("\n");
1015
+ }
780
1016
 
781
- // If the patch doesn't apply, try to fix it
1017
+ // Detect silent no-op: patch "succeeded" but content unchanged despite having additions/deletions
1018
+ let wasNoOp = false;
1019
+ if (
1020
+ updatedContent !== false &&
1021
+ updatedContent === originalContent &&
1022
+ (patchHasAdditions || patchHasDeletions)
1023
+ ) {
1024
+ console.warn(
1025
+ "Patch applied but resulted in NO CHANGES despite having additions/deletions. Treating as failure and attempting fix..."
1026
+ );
1027
+ wasNoOp = true;
1028
+ updatedContent = false; // Force fix path
1029
+ }
1030
+
1031
+ // If the patch doesn't apply (or was a silent no-op), try to fix it
782
1032
  if (updatedContent === false) {
783
1033
  // diff library often returns false on failure
784
1034
  console.warn("Initial patch apply failed. Attempting to fix patch...");
@@ -812,7 +1062,16 @@ export async function patchFile(
812
1062
  );
813
1063
  // It might be valid that the patch had no real changes, but applyPatch failed anyway?
814
1064
  // Let's return an error indicating failure.
815
- return `Patch failed to apply and could not be fixed or resulted in no changes. Make sure you are making small patches`;
1065
+ const hunkSummary = patchHunks.map((h, i) =>
1066
+ ` Hunk ${i + 1}: ${h.header} — ${h.additions.length} additions, ${h.subtractions.length} deletions`
1067
+ ).join("\n");
1068
+ return [
1069
+ `❌ Patch failed: could not apply or fix the patch (${patchHunks.length} hunk(s) attempted, 0 applied).`,
1070
+ wasNoOp ? `⚠️ Note: The patch initially appeared to succeed but produced NO CHANGES (silent no-op). This often means context lines matched a different location than intended.` : null,
1071
+ `File: ${filePath} (${originalContent.split("\n").length} lines)`,
1072
+ `Hunks attempted:\n${hunkSummary}`,
1073
+ `Tip: Break your patch into smaller hunks. Ensure context lines exactly match the file. Use readFile to verify the current content before patching.`,
1074
+ ].filter(Boolean).join("\n");
816
1075
  }
817
1076
 
818
1077
  updatedContent = applyPatch(originalContent, fixedPatch);
@@ -837,14 +1096,23 @@ export async function patchFile(
837
1096
  "Fixed patch also failed to apply."
838
1097
  );
839
1098
  // Try to provide more specific feedback from applyPatch if possible (library might not offer it)
840
- return "Patch failed to apply even after attempting to fix it. Make sure you are making small patches.";
1099
+ return [
1100
+ `❌ Patch failed: could not apply even after auto-correction (${patchHunks.length} hunk(s) attempted, 0 applied).`,
1101
+ wasNoOp ? `⚠️ Note: The patch initially appeared to succeed but produced NO CHANGES (silent no-op). Context lines may have matched the wrong location.` : null,
1102
+ `File: ${filePath} (${originalContent.split("\n").length} lines)`,
1103
+ `The auto-fix attempted to re-anchor your hunks to the file content but the resulting patch still failed.`,
1104
+ `Tip: Use readFile to get the current exact content, then rewrite your patch with precise context lines matching the file. Make smaller, more targeted hunks.`,
1105
+ ].filter(Boolean).join("\n");
841
1106
  } else {
842
1107
  console.log("Successfully applied the *fixed* patch.");
1108
+ appliedPatch = fixedPatch;
843
1109
  }
844
1110
  } else {
845
1111
  console.log("Successfully applied the original patch.");
846
1112
  }
847
1113
 
1114
+ const wasFixed = appliedPatch !== patch;
1115
+
848
1116
  const eventResults: any[] = [];
849
1117
  // Emit pre-edit blocking event
850
1118
  if (context.Events) {
@@ -885,9 +1153,27 @@ export async function patchFile(
885
1153
  }
886
1154
  }
887
1155
 
888
- return `Patch applied successfully.${
889
- filePath ? ` Use readFile on ${filePath} to verify changes.` : ""
890
- }${eventResultsText}`.trim();
1156
+ // Build rich summary of what actually changed
1157
+ const changeSummary = buildChangeSummary(originalContent, updatedContent as string);
1158
+ const appliedHunks = parseHunks(appliedPatch);
1159
+ const totalHunks = patchHunks.length;
1160
+ const appliedHunkCount = appliedHunks.length;
1161
+ const patchStatus = wasFixed
1162
+ ? `⚠️ Original patch required auto-correction before applying.`
1163
+ : `✅ Original patch applied cleanly.`;
1164
+ const hunkStatus = appliedHunkCount < totalHunks
1165
+ ? `⚠️ Only ${appliedHunkCount}/${totalHunks} hunks were applied.`
1166
+ : `${appliedHunkCount}/${totalHunks} hunks applied.`;
1167
+
1168
+ const summaryLines = [
1169
+ `Patch applied to ${filePath}.`,
1170
+ patchStatus,
1171
+ hunkStatus,
1172
+ changeSummary,
1173
+ ];
1174
+ if (eventResultsText) summaryLines.push(eventResultsText);
1175
+
1176
+ return summaryLines.join("\n");
891
1177
  } catch (e: any) {
892
1178
  console.error(`Error in patchFile function for ${filePath}:`, e);
893
1179
  // Save error only if it's not a controlled failure path that already saved