@tyvm/knowhow 0.0.118 → 0.0.120
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/package.json +1 -3
- package/src/agents/base/base.ts +72 -9
- package/src/agents/researcher/researcher.ts +9 -2
- package/src/agents/tools/list.ts +13 -2
- package/src/agents/tools/patch.ts +318 -32
- package/src/agents/tools/readFile.ts +48 -5
- package/src/chat/modules/AgentModule.ts +12 -0
- package/src/cli.ts +2 -0
- package/src/clients/anthropic.ts +12 -2
- package/src/clients/contextLimits.ts +77 -0
- package/src/commands/convert.ts +291 -0
- package/src/conversion.ts +15 -61
- package/src/index.ts +3 -0
- package/src/processors/CustomVariables.ts +45 -20
- package/src/processors/TokenCompressor.ts +95 -9
- package/src/services/AgentSyncFs.ts +26 -4
- package/src/services/AgentSyncKnowhowWeb.ts +26 -4
- package/src/services/SyncedAgentWatcher.ts +8 -0
- package/src/services/conversion/ConversionService.ts +763 -0
- package/src/services/conversion/index.ts +2 -0
- package/src/services/conversion/types.ts +79 -0
- package/src/services/index.ts +8 -1
- package/src/services/modules/types.ts +2 -0
- package/src/services/watchers/FsSyncer.ts +6 -0
- package/src/services/watchers/RemoteSyncer.ts +5 -0
- package/tests/agents/tools/readFile.test.ts +88 -0
- package/tests/clients/AIClient.test.ts +5 -0
- package/tests/clients/contextLimits.test.ts +71 -0
- package/tests/patching/patchFileOutput.test.ts +217 -0
- package/tests/patching/regression-2026.test.ts +278 -0
- package/tests/processors/CustomVariables.test.ts +4 -4
- package/tests/processors/TokenCompressor.test.ts +59 -1
- package/tests/processors/tools/grepToolResponse.test.ts +72 -0
- package/tests/services/ConversionService.test.ts +154 -0
- package/tests/test.spec.ts +1 -1
- package/tests/unit/clients/AIClient.test.ts +8 -0
- package/ts_build/package.json +1 -3
- package/ts_build/src/agents/base/base.d.ts +3 -0
- package/ts_build/src/agents/base/base.js +46 -3
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/researcher/researcher.js +5 -2
- package/ts_build/src/agents/researcher/researcher.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +10 -2
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/patch.js +202 -24
- package/ts_build/src/agents/tools/patch.js.map +1 -1
- package/ts_build/src/agents/tools/readFile.d.ts +1 -1
- package/ts_build/src/agents/tools/readFile.js +17 -4
- package/ts_build/src/agents/tools/readFile.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +12 -0
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/cli.js +2 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/anthropic.js +7 -2
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/contextLimits.js +70 -0
- package/ts_build/src/clients/contextLimits.js.map +1 -1
- package/ts_build/src/commands/convert.d.ts +2 -0
- package/ts_build/src/commands/convert.js +275 -0
- package/ts_build/src/commands/convert.js.map +1 -0
- package/ts_build/src/conversion.js +6 -38
- package/ts_build/src/conversion.js.map +1 -1
- package/ts_build/src/index.d.ts +2 -0
- package/ts_build/src/index.js +4 -1
- package/ts_build/src/index.js.map +1 -1
- package/ts_build/src/processors/CustomVariables.js +14 -12
- package/ts_build/src/processors/CustomVariables.js.map +1 -1
- package/ts_build/src/processors/TokenCompressor.d.ts +2 -0
- package/ts_build/src/processors/TokenCompressor.js +57 -7
- package/ts_build/src/processors/TokenCompressor.js.map +1 -1
- package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
- package/ts_build/src/services/AgentSyncFs.js +21 -4
- package/ts_build/src/services/AgentSyncFs.js.map +1 -1
- package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -0
- package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
- package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
- package/ts_build/src/services/SyncedAgentWatcher.d.ts +3 -0
- package/ts_build/src/services/SyncedAgentWatcher.js +4 -0
- package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
- package/ts_build/src/services/conversion/ConversionService.d.ts +18 -0
- package/ts_build/src/services/conversion/ConversionService.js +585 -0
- package/ts_build/src/services/conversion/ConversionService.js.map +1 -0
- package/ts_build/src/services/conversion/index.d.ts +2 -0
- package/ts_build/src/services/conversion/index.js +19 -0
- package/ts_build/src/services/conversion/index.js.map +1 -0
- package/ts_build/src/services/conversion/types.d.ts +49 -0
- package/ts_build/src/services/conversion/types.js +3 -0
- package/ts_build/src/services/conversion/types.js.map +1 -0
- package/ts_build/src/services/index.d.ts +3 -0
- package/ts_build/src/services/index.js +6 -1
- package/ts_build/src/services/index.js.map +1 -1
- package/ts_build/src/services/modules/index.d.ts +2 -0
- package/ts_build/src/services/modules/types.d.ts +2 -0
- package/ts_build/src/services/watchers/FsSyncer.d.ts +1 -0
- package/ts_build/src/services/watchers/FsSyncer.js +5 -0
- package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
- package/ts_build/src/services/watchers/RemoteSyncer.d.ts +1 -0
- package/ts_build/src/services/watchers/RemoteSyncer.js +4 -0
- package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
- package/ts_build/tests/agents/tools/readFile.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/readFile.test.js +90 -0
- package/ts_build/tests/agents/tools/readFile.test.js.map +1 -0
- package/ts_build/tests/clients/AIClient.test.js +1 -0
- package/ts_build/tests/clients/AIClient.test.js.map +1 -1
- package/ts_build/tests/clients/contextLimits.test.d.ts +1 -0
- package/ts_build/tests/clients/contextLimits.test.js +57 -0
- package/ts_build/tests/clients/contextLimits.test.js.map +1 -0
- package/ts_build/tests/patching/patchFileOutput.test.d.ts +1 -0
- package/ts_build/tests/patching/patchFileOutput.test.js +187 -0
- package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -0
- package/ts_build/tests/patching/regression-2026.test.js +214 -0
- package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
- package/ts_build/tests/processors/CustomVariables.test.js +4 -4
- package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
- package/ts_build/tests/processors/TokenCompressor.test.js +37 -1
- package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
- package/ts_build/tests/processors/tools/grepToolResponse.test.d.ts +1 -0
- package/ts_build/tests/processors/tools/grepToolResponse.test.js +40 -0
- package/ts_build/tests/processors/tools/grepToolResponse.test.js.map +1 -0
- package/ts_build/tests/services/ConversionService.test.d.ts +1 -0
- package/ts_build/tests/services/ConversionService.test.js +154 -0
- package/ts_build/tests/services/ConversionService.test.js.map +1 -0
- package/ts_build/tests/test.spec.js +1 -1
- package/ts_build/tests/test.spec.js.map +1 -1
- package/ts_build/tests/unit/clients/AIClient.test.js +3 -0
- 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.
|
|
3
|
+
"version": "0.0.120",
|
|
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
|
},
|
package/src/agents/base/base.ts
CHANGED
|
@@ -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
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
774
|
+
const interruptResponse: CompletionResponse = {
|
|
775
|
+
choices: [{ message: { role: "assistant", content: "User interrupted this AI completion. Please continue." } }],
|
|
721
776
|
model,
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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");
|
package/src/agents/tools/list.ts
CHANGED
|
@@ -174,7 +174,7 @@ export const includedTools = [
|
|
|
174
174
|
function: {
|
|
175
175
|
name: "readFile",
|
|
176
176
|
description:
|
|
177
|
-
"Read the contents of a file
|
|
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:
|
|
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
|
-
//
|
|
298
|
-
|
|
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
|
-
|
|
330
|
+
leadingContextBlock.push(line.slice(1));
|
|
303
331
|
}
|
|
304
332
|
}
|
|
305
333
|
|
|
306
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
522
|
-
originalLineCount:
|
|
523
|
-
newStartLine:
|
|
524
|
-
newLineCount:
|
|
525
|
-
lines:
|
|
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
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|