@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
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export * from "./ConversionService";
@@ -0,0 +1,79 @@
1
+ import { AIClient } from "../../clients";
2
+
3
+ export type Modality = "text" | "html" | "image" | "audio" | "video";
4
+
5
+ export interface ConvertInput {
6
+ filePath: string; // source file on disk
7
+ inputExt: string; // e.g. "pdf"
8
+ /**
9
+ * When a previous chain step produced multiple files (e.g. one image per
10
+ * PDF page), they are forwarded here so the next converter can process all
11
+ * of them. filePath is set to files[0] for single-file converters.
12
+ */
13
+ files?: string[];
14
+ startPage?: number;
15
+ endPage?: number;
16
+ startLine?: number;
17
+ endLine?: number;
18
+ startTime?: number;
19
+ endTime?: number;
20
+ }
21
+
22
+ export interface ConvertResult {
23
+ outputType: Modality;
24
+ text?: string; // for text/html
25
+ files?: string[]; // for image/audio/video
26
+ usd_cost?: number;
27
+ }
28
+
29
+ export interface ConverterContext {
30
+ clients: AIClient;
31
+ cacheDir: string;
32
+ /**
33
+ * Per-converter options forwarded from ConvertOptions.converterOptions[name].
34
+ * Lets individual converters accept their own settings (e.g. which model to
35
+ * use for image->text description, DPI for pdf->image, etc).
36
+ */
37
+ options?: Record<string, any>;
38
+ }
39
+
40
+ export interface Converter {
41
+ name: string; // unique
42
+ inputExts?: string[]; // e.g. ["pdf"]
43
+ inputModality?: Modality; // OR an input modality (e.g. "image")
44
+ outputType: Modality;
45
+ /**
46
+ * If true, the ConversionService will cache the result of this converter to
47
+ * disk so repeated calls with the same input are fast. Defaults to false —
48
+ * caching is opt-in. Only enable for expensive converters (e.g. pdf->image,
49
+ * image->text via LLM) where re-running would be slow or costly.
50
+ */
51
+ cache?: boolean;
52
+ convert: (input: ConvertInput, ctx: ConverterContext) => Promise<ConvertResult>;
53
+ }
54
+
55
+ export interface ConvertOptions {
56
+ preferredConverters?: string[];
57
+ isGoodEnough?: (args: { filePath: string; result: ConvertResult }) => boolean;
58
+ force?: boolean;
59
+ startPage?: number;
60
+ endPage?: number;
61
+ startLine?: number;
62
+ endLine?: number;
63
+ startTime?: number;
64
+ endTime?: number;
65
+ onProgress?: (stage: string, fraction: number) => void;
66
+ /**
67
+ * Options scoped to a specific converter, keyed by converter name.
68
+ * e.g. { "image-to-text": { model: "gpt-4o" }, "pdf-to-img": { scale: 2 } }
69
+ */
70
+ converterOptions?: Record<string, Record<string, any>>;
71
+ /**
72
+ * Explicit intermediate modalities to route through before reaching the
73
+ * final targetType. e.g. ["image"] means the chain must go through "image"
74
+ * first. When provided, the ConversionService will stitch BFS sub-paths
75
+ * between each waypoint rather than picking the overall shortest path.
76
+ * CLI: --to image,text → via=["image"], target="text"
77
+ */
78
+ via?: Modality[];
79
+ }
@@ -15,6 +15,8 @@ import { SessionManager } from "./SessionManager";
15
15
  import { TaskRegistry } from "./TaskRegistry";
16
16
  import { MediaProcessorService } from "./MediaProcessorService";
17
17
 
18
+ import { ConversionService } from "./conversion/ConversionService";
19
+
18
20
  export * from "./AgentService";
19
21
  export * from "./EventService";
20
22
  export * from "./flags";
@@ -34,6 +36,7 @@ export * from "./SyncedAgentWatcher";
34
36
  export * from "./SyncerService";
35
37
  export * from "./watchers";
36
38
  export { Clients } from "../clients";
39
+ export * from "./conversion";
37
40
 
38
41
  let Singletons = {} as {
39
42
  Tools: ToolsService;
@@ -48,6 +51,7 @@ let Singletons = {} as {
48
51
  Plugins: PluginService;
49
52
  Clients: AIClient;
50
53
  MediaProcessor: MediaProcessorService;
54
+ Conversion: ConversionService;
51
55
  };
52
56
 
53
57
  export const services = (): typeof Singletons => {
@@ -55,6 +59,8 @@ export const services = (): typeof Singletons => {
55
59
  const Tools = new ToolsService();
56
60
  const Events = new EventService();
57
61
  const Agents = new AgentService(Tools, Events);
62
+ const MediaProcessor = new MediaProcessorService(Clients);
63
+ const Conversion = new ConversionService(Clients, MediaProcessor);
58
64
  const Plugins = new PluginService({
59
65
  Agents,
60
66
  Events,
@@ -71,7 +77,8 @@ export const services = (): typeof Singletons => {
71
77
  Embeddings: new EmbeddingsService(),
72
78
  Flags: new FlagsService(),
73
79
  Mcp: new McpService(),
74
- MediaProcessor: new MediaProcessorService(Clients),
80
+ MediaProcessor,
81
+ Conversion,
75
82
  Plugins,
76
83
  Tools,
77
84
  knowhowApiClient: new KnowhowSimpleClient(),
@@ -12,6 +12,7 @@ import { ToolsService } from "../Tools";
12
12
  import { MediaProcessorService } from "../MediaProcessorService";
13
13
  import { TunnelHandler } from "@tyvm/knowhow-tunnel";
14
14
  import { EventService } from "../EventService";
15
+ import { ConversionService } from "../conversion/ConversionService";
15
16
 
16
17
  /*
17
18
  *
@@ -57,6 +58,7 @@ export interface ModuleContext {
57
58
  Tools: ToolsService;
58
59
  Events: EventService;
59
60
  MediaProcessor?: MediaProcessorService;
61
+ Conversion?: ConversionService;
60
62
  Tunnel?: TunnelHandler;
61
63
  Program?: Command;
62
64
  }
@@ -143,6 +143,12 @@ export class FsSyncedAgentWatcher implements SyncedAgentWatcher {
143
143
  console.log(`🛑 Killed remote agent: ${this.taskId}`);
144
144
  }
145
145
 
146
+ async interrupt(message?: string): Promise<void> {
147
+ const inputPath = path.join(this.taskPath, "input.txt");
148
+ await fsPromises.writeFile(inputPath, `/poke ${message || ""}`.trim(), "utf8");
149
+ console.log(`🫵 Interrupt written to input for agent: ${this.taskId}`);
150
+ }
151
+
146
152
  private async readMetadata(): Promise<any> {
147
153
  try {
148
154
  const metaPath = path.join(this.taskPath, "metadata.json");
@@ -150,4 +150,9 @@ export class WebSyncedAgentWatcher implements SyncedAgentWatcher {
150
150
  await this.client.killAgent(this.taskId);
151
151
  console.log(`🛑 Killed remote web agent: ${this.taskId}`);
152
152
  }
153
+
154
+ async interrupt(message?: string): Promise<void> {
155
+ await this.client.sendMessageToAgent(this.taskId, `/poke ${message || ""}`.trim());
156
+ console.log(`🫵 Interrupt sent to remote web agent: ${this.taskId}`);
157
+ }
153
158
  }
package/src/types.ts CHANGED
@@ -112,6 +112,7 @@ export type McpConfig = {
112
112
  params?: Partial<{ socket: WebSocket }>;
113
113
  authorization_token?: string;
114
114
  authorization_token_file?: string;
115
+ authorization_scheme?: "bearer" | "basic";
115
116
  };
116
117
 
117
118
  export type ModelProvider = {
@@ -0,0 +1,88 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { readFile } from "../../../src/agents/tools/readFile";
5
+
6
+ /**
7
+ * These tests cover the readFile tool improvements:
8
+ * - plain text output (no unified-diff / Index: wrapper)
9
+ * - optional 1-based inclusive line ranges with real source line numbers
10
+ */
11
+ describe("readFile tool", () => {
12
+ let tmpFile: string;
13
+ const fileLines = [
14
+ "import x from 'y';",
15
+ "",
16
+ "function add(a, b) {",
17
+ " return a + b;",
18
+ "}",
19
+ "",
20
+ "export default add;",
21
+ ];
22
+
23
+ // Minimal ToolsService-like context so readFile can resolve getContext()
24
+ // without depending on the global singletons.
25
+ const fakeThis: any = {
26
+ getContext: () => ({ Events: undefined }),
27
+ };
28
+
29
+ beforeEach(() => {
30
+ tmpFile = path.join(
31
+ os.tmpdir(),
32
+ `readFile-test-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`
33
+ );
34
+ fs.writeFileSync(tmpFile, fileLines.join("\n"), "utf8");
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (fs.existsSync(tmpFile)) {
39
+ fs.unlinkSync(tmpFile);
40
+ }
41
+ });
42
+
43
+ it("returns plain text with no diff/patch wrapper", async () => {
44
+ const result = await readFile.call(fakeThis, tmpFile);
45
+
46
+ expect(result).toBe(fileLines.join("\n"));
47
+ // The old behavior wrapped reads in a unified diff; make sure that's gone.
48
+ expect(result).not.toContain("Index:");
49
+ expect(result).not.toContain("@@");
50
+ expect(result).not.toMatch(/^\+/m);
51
+ });
52
+
53
+ it("returns a line range with real source line numbers", async () => {
54
+ const result = await readFile.call(fakeThis, tmpFile, 3, 5);
55
+
56
+ expect(result).toBe(
57
+ ["3: function add(a, b) {", "4: return a + b;", "5: }"].join("\n")
58
+ );
59
+ });
60
+
61
+ it("defaults toLine to the end of file when omitted", async () => {
62
+ const result = await readFile.call(fakeThis, tmpFile, 6);
63
+
64
+ expect(result).toBe(["6: ", "7: export default add;"].join("\n"));
65
+ });
66
+
67
+ it("clamps an out-of-range toLine to the last line", async () => {
68
+ const result = await readFile.call(fakeThis, tmpFile, 6, 9999);
69
+
70
+ expect(result).toBe(["6: ", "7: export default add;"].join("\n"));
71
+ });
72
+
73
+ it("throws when fromLine is greater than toLine", async () => {
74
+ await expect(readFile.call(fakeThis, tmpFile, 5, 2)).rejects.toThrow(
75
+ /Invalid line range/
76
+ );
77
+ });
78
+
79
+ it("throws a helpful error when the file does not exist", async () => {
80
+ const missing = path.join(
81
+ os.tmpdir(),
82
+ "definitely-not-here-xyz.unknownext"
83
+ );
84
+ await expect(readFile.call(fakeThis, missing)).rejects.toThrow(
85
+ /File not found/
86
+ );
87
+ });
88
+ });
@@ -1,4 +1,9 @@
1
1
  import { AIClient } from "../../src/clients";
2
+
3
+ // Prevent real HTTP calls during provider initialisation — resolveClient
4
+ // returns null so all DEFAULT_PROVIDERS are skipped by registerModelProviders.
5
+ jest.spyOn(AIClient.prototype as any, "resolveClient").mockReturnValue(null);
6
+
2
7
  import {
3
8
  GenericClient,
4
9
  CompletionOptions,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tests that all models included in the Models object (anthropic, openai, google, xai)
3
+ * have a corresponding context limit recorded in ContextLimits.
4
+ */
5
+ import { Models } from "../../src/types";
6
+ import { ContextLimits, getModelContextLimit, DEFAULT_CONTEXT_LIMIT } from "../../src/clients/contextLimits";
7
+
8
+ describe("ContextLimits", () => {
9
+ describe("coverage - all Models.* values have a recorded context limit", () => {
10
+ const providers = Object.keys(Models) as Array<keyof typeof Models>;
11
+
12
+ for (const provider of providers) {
13
+ describe(`Models.${provider}`, () => {
14
+ const providerModels = Models[provider] as Record<string, string>;
15
+ const modelEntries = Object.entries(providerModels);
16
+
17
+ it(`should have at least one model defined for ${provider}`, () => {
18
+ expect(modelEntries.length).toBeGreaterThan(0);
19
+ });
20
+
21
+ for (const [key, modelId] of modelEntries) {
22
+ it(`${provider}.${key} (${modelId}) should have a context limit`, () => {
23
+ const limit = ContextLimits[modelId];
24
+ expect(limit).toBeDefined();
25
+ expect(typeof limit).toBe("number");
26
+ // Non-text models (image/video/audio) are recorded as 0; text models must be > 0
27
+ expect(limit).toBeGreaterThanOrEqual(0);
28
+ expect(Number.isFinite(limit)).toBe(true);
29
+ });
30
+ }
31
+ });
32
+ }
33
+ });
34
+
35
+ describe("getModelContextLimit", () => {
36
+ it("returns the correct limit for a known OpenAI model", () => {
37
+ expect(getModelContextLimit(Models.openai.GPT_4o)).toBe(128_000);
38
+ });
39
+
40
+ it("returns the correct limit for a known Anthropic model", () => {
41
+ expect(getModelContextLimit(Models.anthropic.Opus4)).toBe(200_000);
42
+ });
43
+
44
+ it("returns the correct limit for a known Google model", () => {
45
+ expect(getModelContextLimit(Models.google.Gemini_15_Pro)).toBe(2_000_000);
46
+ });
47
+
48
+ it("returns the correct limit for a known xAI model", () => {
49
+ expect(getModelContextLimit(Models.xai.Grok3Beta)).toBe(131_072);
50
+ });
51
+
52
+ it("returns DEFAULT_CONTEXT_LIMIT for an unknown model", () => {
53
+ expect(getModelContextLimit("unknown-model-xyz")).toBe(DEFAULT_CONTEXT_LIMIT);
54
+ });
55
+
56
+ it("DEFAULT_CONTEXT_LIMIT is a positive number", () => {
57
+ expect(DEFAULT_CONTEXT_LIMIT).toBeGreaterThan(0);
58
+ });
59
+ });
60
+
61
+ describe("ContextLimits values are all valid numbers", () => {
62
+ it("all recorded limits are non-negative finite numbers", () => {
63
+ for (const [model, limit] of Object.entries(ContextLimits)) {
64
+ expect(typeof limit).toBe("number");
65
+ // 0 is allowed for non-text models (image/video/audio)
66
+ expect(limit).toBeGreaterThanOrEqual(0);
67
+ expect(Number.isFinite(limit)).toBe(true);
68
+ }
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,217 @@
1
+ import { patchFile } from "../../src/agents/tools/patch";
2
+ import * as fs from "fs";
3
+
4
+ const mockToolService = {
5
+ getContext: () => ({ Events: null }),
6
+ };
7
+ const boundPatch = (patchFile as any).bind(mockToolService);
8
+
9
+ describe("patchFile return messaging", () => {
10
+ const testFile = "/tmp/patch-test-output.ts";
11
+
12
+ beforeEach(() => {
13
+ fs.writeFileSync(testFile, `function hello() {\n const a = 1;\n const b = 2;\n return a + b;\n}\n`);
14
+ });
15
+
16
+ it("clean patch shows stats and preview", async () => {
17
+ const result = await boundPatch(testFile, `@@ -2,3 +2,4 @@
18
+ const a = 1;
19
+ const b = 2;
20
+ + const c = 3;
21
+ return a + b;
22
+ `);
23
+ console.log("CLEAN RESULT:\n", result);
24
+ expect(result).toContain("✅ Original patch applied cleanly.");
25
+ expect(result).toContain("1/1 hunks applied.");
26
+ expect(result).toContain("+ ");
27
+ });
28
+
29
+ it("wrong line numbers auto-corrected patch shows warning", async () => {
30
+ // This patch has severely wrong line numbers - the diff library can't apply it directly
31
+ // but fixPatch can re-anchor it using the deletion content
32
+ const result = await boundPatch(testFile, `@@ -999,5 +999,5 @@
33
+ const a = 1;
34
+ const b = 2;
35
+ - return a + b;
36
+ + return a + b + c;
37
+ }
38
+ `);
39
+ console.log("FIXED RESULT:\n", result);
40
+ // Either it was auto-corrected (⚠️) or applied cleanly via fuzzy match (✅)
41
+ // Either way it should succeed and show a change
42
+ expect(result).not.toContain("❌ Patch failed");
43
+ expect(result).toContain("lines");
44
+ // The change summary should show the replacement
45
+ expect(result).toContain("return a + b + c");
46
+ });
47
+
48
+ it("bad context lines returns descriptive failure", async () => {
49
+ const result = await boundPatch(testFile, `@@ -1,3 +1,4 @@
50
+ NONEXISTENT_LINE_1;
51
+ NONEXISTENT_LINE_2;
52
+ + new line here;
53
+ NONEXISTENT_LINE_3;
54
+ `);
55
+ console.log("FAIL RESULT:\n", result);
56
+ expect(result).toContain("❌ Patch failed");
57
+ expect(result).toContain("hunk(s) attempted");
58
+ expect(result).toContain("Tip:");
59
+ });
60
+ });
61
+
62
+ /**
63
+ * Regression for a real-world mangling bug observed while patching
64
+ * `src/clients/anthropic.ts`.
65
+ *
66
+ * The patch inserted several new `const` declaration lines right after an
67
+ * `else {` opening brace AND replaced two property lines that live *inside* a
68
+ * nested `source: { ... }` object literal. The auto-correction (fixPatch /
69
+ * fixSingleHunk) re-anchored the hunk incorrectly: it
70
+ * 1. dropped the newly added `const` declaration lines, and
71
+ * 2. moved the replaced `media_type` / `data` lines OUTSIDE of the
72
+ * `source: { ... }` object (placing them after its closing `},`),
73
+ * which produced syntactically broken / scope-corrupted output.
74
+ *
75
+ * This test reproduces the exact shape (leading-context additions + nested
76
+ * object property replacement in the same hunk) and asserts the result is
77
+ * structurally coherent.
78
+ */
79
+ describe("patchFile nested-object + leading-context additions regression", () => {
80
+ const testFile = "/tmp/patch-nested-object-regression.ts";
81
+
82
+ const original = ` if (isUrl) {
83
+ return {
84
+ type: "image",
85
+ source: {
86
+ type: "url" as const,
87
+ url: e.image_url.url,
88
+ },
89
+ } as Anthropic.ContentBlockParam;
90
+ } else {
91
+ return {
92
+ type: "image",
93
+ source: {
94
+ type: "base64" as const,
95
+ media_type: "image/jpeg",
96
+ data: e.image_url.url,
97
+ },
98
+ } as Anthropic.ContentBlockParam;
99
+ }
100
+ `;
101
+
102
+ beforeEach(() => {
103
+ fs.writeFileSync(testFile, original);
104
+ });
105
+
106
+ it("inserts new const lines after `else {` and replaces nested props in-place without corrupting scope", async () => {
107
+ // Patch: add 3 const declarations after the `else {` line, and replace the
108
+ // two property lines inside the nested `source: { ... }` object.
109
+ const patch = `@@ -9,9 +9,15 @@
110
+ } else {
111
+ + const dataUrlMatch = e.image_url.url.match(/^data:([^;]+);base64,(.*)$/s);
112
+ + const mediaType = dataUrlMatch ? dataUrlMatch[1] : "image/jpeg";
113
+ + const data = dataUrlMatch ? dataUrlMatch[2] : e.image_url.url;
114
+ return {
115
+ type: "image",
116
+ source: {
117
+ type: "base64" as const,
118
+ - media_type: "image/jpeg",
119
+ - data: e.image_url.url,
120
+ + media_type: mediaType,
121
+ + data,
122
+ },
123
+ } as Anthropic.ContentBlockParam;
124
+ }
125
+ `;
126
+
127
+ const result = await boundPatch(testFile, patch);
128
+ console.log("NESTED REGRESSION RESULT:\n", result);
129
+
130
+ expect(result).not.toContain("❌ Patch failed");
131
+
132
+ const updated = fs.readFileSync(testFile, "utf8");
133
+ console.log("UPDATED FILE:\n", updated);
134
+
135
+ // 1. The new const declarations must be present (they were dropped by the bug).
136
+ expect(updated).toContain("const dataUrlMatch =");
137
+ expect(updated).toContain("const mediaType =");
138
+ expect(updated).toContain("const data =");
139
+
140
+ // 2. The replacements must have happened.
141
+ expect(updated).toContain("media_type: mediaType,");
142
+ expect(updated).toContain("data,");
143
+ expect(updated).not.toContain('media_type: "image/jpeg",\n data: e.image_url.url,');
144
+
145
+ // 3. CRITICAL: the replaced property lines must remain INSIDE the
146
+ // `source: { ... }` object, i.e. they must appear BEFORE the object's
147
+ // closing `},`. The bug moved them after it.
148
+ const base64Idx = updated.indexOf('type: "base64" as const,');
149
+ const mediaTypeIdx = updated.indexOf("media_type: mediaType,");
150
+ const dataIdx = updated.indexOf("\n data,");
151
+ // The `source` object closes with the first `},` that follows the
152
+ // base64 source type line.
153
+ const sourceCloseIdx = updated.indexOf("},", base64Idx);
154
+
155
+ expect(base64Idx).toBeGreaterThan(-1);
156
+ expect(mediaTypeIdx).toBeGreaterThan(-1);
157
+ expect(dataIdx).toBeGreaterThan(-1);
158
+ expect(sourceCloseIdx).toBeGreaterThan(-1);
159
+
160
+ // media_type and data must come BEFORE the source object's closing brace.
161
+ expect(mediaTypeIdx).toBeLessThan(sourceCloseIdx);
162
+ expect(dataIdx).toBeLessThan(sourceCloseIdx);
163
+
164
+ // 4. Brace balance sanity: the number of `{` and `}` must be unchanged
165
+ // relative to the original (we didn't add or remove any braces).
166
+ const countChar = (s: string, c: string) => s.split(c).length - 1;
167
+ expect(countChar(updated, "{")).toBe(countChar(original, "{"));
168
+ expect(countChar(updated, "}")).toBe(countChar(original, "}"));
169
+ });
170
+
171
+ it("survives auto-correction (wrong line numbers + short context) without corrupting scope", async () => {
172
+ // This mirrors the *actual* hand-written patch that got mangled: wrong
173
+ // header line numbers and minimal context, which forces the fixPatch /
174
+ // fixSingleHunk re-anchoring path. The bug dropped the added const lines
175
+ // and moved the replaced props outside the `source: { ... }` object.
176
+ const patch = `@@ -200,8 +200,14 @@
177
+ } else {
178
+ + const dataUrlMatch = e.image_url.url.match(/^data:([^;]+);base64,(.*)$/s);
179
+ + const mediaType = dataUrlMatch ? dataUrlMatch[1] : "image/jpeg";
180
+ + const data = dataUrlMatch ? dataUrlMatch[2] : e.image_url.url;
181
+ return {
182
+ type: "image",
183
+ source: {
184
+ type: "base64" as const,
185
+ - media_type: "image/jpeg",
186
+ - data: e.image_url.url,
187
+ + media_type: mediaType,
188
+ + data,
189
+ },
190
+ `;
191
+
192
+ const result = await boundPatch(testFile, patch);
193
+ console.log("AUTOCORRECT REGRESSION RESULT:\n", result);
194
+ expect(result).not.toContain("❌ Patch failed");
195
+
196
+ const updated = fs.readFileSync(testFile, "utf8");
197
+ console.log("AUTOCORRECT UPDATED FILE:\n", updated);
198
+
199
+ // Added const lines must survive.
200
+ expect(updated).toContain("const dataUrlMatch =");
201
+ expect(updated).toContain("const mediaType =");
202
+ expect(updated).toContain("const data =");
203
+
204
+ // Replaced props must stay inside the source object.
205
+ const base64Idx = updated.indexOf('type: "base64" as const,');
206
+ const mediaTypeIdx = updated.indexOf("media_type: mediaType,");
207
+ const sourceCloseIdx = updated.indexOf("},", base64Idx);
208
+ expect(mediaTypeIdx).toBeGreaterThan(-1);
209
+ expect(sourceCloseIdx).toBeGreaterThan(-1);
210
+ expect(mediaTypeIdx).toBeLessThan(sourceCloseIdx);
211
+
212
+ // Brace balance unchanged.
213
+ const countChar = (s: string, c: string) => s.split(c).length - 1;
214
+ expect(countChar(updated, "{")).toBe(countChar(original, "{"));
215
+ expect(countChar(updated, "}")).toBe(countChar(original, "}"));
216
+ });
217
+ });