@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
@@ -3,7 +3,6 @@ import { fileExists } from "../../utils";
3
3
  import { services, ToolsService } from "../../services";
4
4
  import { getConfiguredEmbeddings } from "../../embeddings";
5
5
  import { fileSearch } from "./fileSearch";
6
- import { createPatch } from "diff";
7
6
 
8
7
  /*
9
8
  *export function readFile(filePath: string): string {
@@ -18,7 +17,23 @@ import { createPatch } from "diff";
18
17
  *}
19
18
  */
20
19
 
21
- export async function readFile(filePath: string): Promise<string> {
20
+ /**
21
+ * Reads the contents of a file and returns them as plain text.
22
+ *
23
+ * Optionally accepts a 1-based, inclusive line range so callers can pull just
24
+ * the region they care about in a single call. When a range is supplied the
25
+ * returned content is prefixed with real source line numbers so the output can
26
+ * be mapped straight back to an editable location.
27
+ *
28
+ * @param filePath The path to the file to read
29
+ * @param fromLine Optional 1-based start line (inclusive)
30
+ * @param toLine Optional 1-based end line (inclusive)
31
+ */
32
+ export async function readFile(
33
+ filePath: string,
34
+ fromLine?: number,
35
+ toLine?: number
36
+ ): Promise<string> {
22
37
  // Get context from bound ToolsService
23
38
  const toolService = (
24
39
  this instanceof ToolsService ? this : services().Tools
@@ -48,9 +63,9 @@ export async function readFile(filePath: string): Promise<string> {
48
63
  }
49
64
 
50
65
  const text = fs.readFileSync(filePath, "utf8");
51
- const patch = createPatch(filePath, "", text);
52
66
 
53
- // Emit post-read non-blocking event
67
+ // Emit post-read non-blocking event with the full file content so listeners
68
+ // (e.g. indexers) always see the complete file regardless of any range slice.
54
69
  if (context.Events) {
55
70
  await context.Events.emitNonBlocking("file:post-read", {
56
71
  filePath,
@@ -58,5 +73,33 @@ export async function readFile(filePath: string): Promise<string> {
58
73
  });
59
74
  }
60
75
 
61
- return patch;
76
+ const hasRange =
77
+ typeof fromLine === "number" || typeof toLine === "number";
78
+
79
+ if (!hasRange) {
80
+ return text;
81
+ }
82
+
83
+ // Build a ranged read with real, 1-based source line numbers.
84
+ const lines = text.split("\n");
85
+ const totalLines = lines.length;
86
+
87
+ const start = Math.max(1, typeof fromLine === "number" ? fromLine : 1);
88
+ const end = Math.min(
89
+ totalLines,
90
+ typeof toLine === "number" ? toLine : totalLines
91
+ );
92
+
93
+ if (start > end) {
94
+ throw new Error(
95
+ `Invalid line range for ${filePath}: fromLine (${start}) is greater than toLine (${end}). File has ${totalLines} lines.`
96
+ );
97
+ }
98
+
99
+ const numbered = [];
100
+ for (let i = start; i <= end; i++) {
101
+ numbered.push(`${i}: ${lines[i - 1]}`);
102
+ }
103
+
104
+ return numbered.join("\n");
62
105
  }
@@ -122,6 +122,18 @@ export class AgentModule extends BaseChatModule {
122
122
  }
123
123
  },
124
124
  },
125
+ {
126
+ name: "poke",
127
+ description: "Interrupt the agent's current tool call or AI completion, so it can continue with the next step",
128
+ modes: ["agent:attached"],
129
+ handler: async (args: string[]): Promise<void> => {
130
+ if (this.attachedAgent) {
131
+ const message = args.length > 0 ? args.join(" ") : undefined;
132
+ this.attachedAgent.interrupt(message);
133
+ console.log("Agent interrupted — it will continue with the next step.");
134
+ }
135
+ },
136
+ },
125
137
  {
126
138
  name: "detach",
127
139
  description: "Detach from the currently attached agent",
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { ModulesService } from "./services/modules";
10
10
 
11
11
  // Command registrars
12
12
  import { addModulesCommand } from "./commands/modules";
13
+ import { addMcpCommands } from "./commands/mcp";
13
14
  import {
14
15
  addWorkerCommand,
15
16
  addWorkersCommand,
@@ -35,6 +36,7 @@ import {
35
36
  addChatCommand,
36
37
  addGithubCredentialsCommand,
37
38
  } from "./commands/misc";
39
+ import { addConvertCommand } from "./commands/convert";
38
40
 
39
41
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
40
42
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -96,6 +98,8 @@ async function main() {
96
98
  addCloudWorkerCommand(program);
97
99
  addGithubCredentialsCommand(program);
98
100
  addModulesCommand(program);
101
+ addMcpCommands(program);
102
+ addConvertCommand(program);
99
103
 
100
104
  // Load global modules early (before parse) so they can register CLI subcommands.
101
105
  // We pass only the Program in context — no services are spun up at this stage.
@@ -329,12 +329,22 @@ export class GenericAnthropicClient implements GenericClient {
329
329
  },
330
330
  } as Anthropic.ContentBlockParam;
331
331
  } else {
332
+ // Data URL or raw base64. Anthropic's base64 source expects the
333
+ // media_type separately and the data WITHOUT the
334
+ // "data:<mime>;base64," prefix. Parse it out here.
335
+ const dataUrlMatch = e.image_url.url.match(
336
+ /^data:([^;]+);base64,(.*)$/s
337
+ );
338
+ const mediaType = dataUrlMatch ? dataUrlMatch[1] : "image/jpeg";
339
+ const data = dataUrlMatch
340
+ ? dataUrlMatch[2]
341
+ : e.image_url.url.replace(/^data:[^,]*,/, "");
332
342
  return {
333
343
  type: "image",
334
344
  source: {
335
345
  type: "base64" as const,
336
- media_type: "image/jpeg",
337
- data: e.image_url.url,
346
+ media_type: mediaType,
347
+ data,
338
348
  },
339
349
  } as Anthropic.ContentBlockParam;
340
350
  }
@@ -10,6 +10,7 @@ import { Models, EmbeddingModels } from "../types";
10
10
  */
11
11
  export const ContextLimits: Record<string, number> = {
12
12
  // ─── OpenAI ───────────────────────────────────────────────────────────────
13
+ [Models.openai.GPT_55]: 1_000_000,
13
14
  [Models.openai.GPT_54]: 1_000_000,
14
15
  [Models.openai.GPT_54_Mini]: 400_000,
15
16
  [Models.openai.GPT_54_Nano]: 400_000,
@@ -40,8 +41,49 @@ export const ContextLimits: Record<string, number> = {
40
41
  [Models.openai.o3_Pro]: 200_000,
41
42
  [Models.openai.o3_Mini]: 200_000,
42
43
  [Models.openai.o4_Mini]: 200_000,
44
+ // ─── OpenAI (aliases / deprecated / non-text) ─────────────────────────────
45
+ [Models.openai.GPT_55_Pro]: 1_000_000,
46
+ [Models.openai.GPT_53_Codex_Spark]: 1_000_000,
47
+ [Models.openai.GPT_52_Chat]: 1_000_000,
48
+ [Models.openai.GPT_52_Codex]: 1_000_000,
49
+ [Models.openai.GPT_52_Pro]: 1_000_000,
50
+ [Models.openai.GPT_51_Chat]: 1_000_000,
51
+ [Models.openai.GPT_51_Codex]: 1_000_000,
52
+ [Models.openai.GPT_51_Codex_Max]: 1_000_000,
53
+ [Models.openai.GPT_51_Codex_Mini]: 1_000_000,
54
+ [Models.openai.GPT_5_Pro]: 1_000_000,
55
+ [Models.openai.GPT_5_Chat]: 1_000_000,
56
+ [Models.openai.GPT_5_Codex]: 1_000_000,
57
+ [Models.openai.o1_Preview]: 128_000,
58
+ [Models.openai.o3_Deep_Research]: 200_000,
59
+ [Models.openai.o4_Mini_Deep_Research]: 200_000,
60
+ [Models.openai.GPT_4o_Transcribe]: 128_000,
61
+ [Models.openai.GPT_4o_Mini_Transcribe]: 128_000,
62
+ [Models.openai.GPT_Realtime_15]: 128_000,
63
+ [Models.openai.GPT_Realtime_Mini]: 128_000,
64
+ [Models.openai.GPT_4o_2024_05_13]: 128_000,
65
+ [Models.openai.GPT_4o_2024_11_20]: 128_000,
66
+ [Models.openai.GPT_35_Turbo]: 16_385,
67
+ [Models.openai.GPT_4]: 8_192,
68
+ [Models.openai.GPT_4_Turbo]: 128_000,
69
+ // OpenAI image/video/audio models — no text context window
70
+ [Models.openai.GPT_Image_2]: 0,
71
+ [Models.openai.GPT_Image_15]: 0,
72
+ [Models.openai.GPT_Image_1]: 0,
73
+ [Models.openai.GPT_Image_1_Mini]: 0,
74
+ [Models.openai.ChatGPT_Image]: 0,
75
+ [Models.openai.TTS_1]: 0,
76
+ [Models.openai.Whisper_1]: 0,
77
+ [Models.openai.DALL_E_3]: 0,
78
+ [Models.openai.DALL_E_2]: 0,
79
+ [Models.openai.Sora]: 0,
80
+ [Models.openai.Sora_2]: 0,
81
+ [Models.openai.Sora_2_Pro]: 0,
43
82
 
44
83
  // ─── Anthropic ────────────────────────────────────────────────────────────
84
+ [Models.anthropic.Opus4_8Fast]: 1_000_000,
85
+ [Models.anthropic.Opus4_8]: 1_000_000,
86
+ [Models.anthropic.Opus4_7]: 1_000_000,
45
87
  [Models.anthropic.Opus4_6]: 1_000_000,
46
88
  [Models.anthropic.Opus4_6Fast]: 1_000_000,
47
89
  [Models.anthropic.Sonnet4_6]: 1_000_000,
@@ -57,10 +99,18 @@ export const ContextLimits: Record<string, number> = {
57
99
  [Models.anthropic.Haiku3]: 200_000,
58
100
  [Models.anthropic.Haiku3_5]: 200_000,
59
101
 
102
+ // ─── Anthropic (aliases / deprecated) ────────────────────────────────────
103
+ [Models.anthropic.Sonnet3_5_20240620]: 200_000,
104
+ [Models.anthropic.Haiku3_5_Latest]: 200_000,
105
+ [Models.anthropic.Sonnet3]: 200_000,
106
+ [Models.anthropic.Opus4_0]: 200_000,
107
+ [Models.anthropic.Sonnet4_0]: 200_000,
108
+
60
109
  // ─── Google ───────────────────────────────────────────────────────────────
61
110
  [Models.google.Gemini_31_Pro_Preview]: 1_000_000,
62
111
  [Models.google.Gemini_31_Flash_Image_Preview]: 1_000_000,
63
112
  [Models.google.Gemini_31_Flash_Lite_Preview]: 1_000_000,
113
+ [Models.google.Gemini_31_Flash_Live_Preview]: 1_000_000,
64
114
  [Models.google.Gemini_3_Flash_Preview]: 1_000_000,
65
115
  [Models.google.Gemini_3_Pro_Image_Preview]: 1_000_000,
66
116
  [Models.google.Gemini_25_Pro]: 1_000_000,
@@ -70,9 +120,20 @@ export const ContextLimits: Record<string, number> = {
70
120
  [Models.google.Gemini_25_Pro_Preview]: 1_000_000,
71
121
  [Models.google.Gemini_25_Flash_Image]: 1_000_000,
72
122
  [Models.google.Gemini_25_Flash_Live]: 1_000_000,
123
+ [Models.google.Gemini_25_Flash_Preview_0417]: 1_000_000,
124
+ [Models.google.Gemini_25_Flash_Image_Preview]: 1_000_000,
73
125
  [Models.google.Gemini_25_Flash_Native_Audio]: 1_000_000,
74
126
  [Models.google.Gemini_25_Flash_TTS]: 1_000_000,
75
127
  [Models.google.Gemini_25_Pro_TTS]: 1_000_000,
128
+ // Google image/video generation models — no text context window; use 0
129
+ [Models.google.Imagen_3]: 0,
130
+ [Models.google.Imagen_4_Fast]: 0,
131
+ [Models.google.Imagen_4_Ultra]: 0,
132
+ [Models.google.Veo_2]: 0,
133
+ [Models.google.Veo_3]: 0,
134
+ [Models.google.Veo_3_Fast]: 0,
135
+ [Models.google.Veo_3_1]: 0,
136
+ [Models.google.Veo_3_1_Fast]: 0,
76
137
  [Models.google.Gemini_20_Flash]: 1_000_000,
77
138
  [Models.google.Gemini_20_Flash_Preview_Image_Generation]: 1_000_000,
78
139
  [Models.google.Gemini_20_Flash_Live]: 1_000_000,
@@ -95,6 +156,22 @@ export const ContextLimits: Record<string, number> = {
95
156
  [Models.xai.Grok3MiniFastBeta]: 131_072,
96
157
  [Models.xai.Grok21212]: 131_072,
97
158
  [Models.xai.Grok2Vision1212]: 131_072,
159
+ // ─── xAI (aliases / deprecated / image / video) ───────────────────────────
160
+ [Models.xai.Grok2Latest]: 131_072,
161
+ [Models.xai.Grok2VisionLatest]: 131_072,
162
+ [Models.xai.Grok3Latest]: 131_072,
163
+ [Models.xai.Grok3FastLatest]: 131_072,
164
+ [Models.xai.Grok3MiniLatest]: 131_072,
165
+ [Models.xai.Grok3MiniFastLatest]: 131_072,
166
+ [Models.xai.GrokBeta]: 131_072,
167
+ [Models.xai.GrokVisionBeta]: 131_072,
168
+ [Models.xai.Grok4_1_Fast]: 2_000_000,
169
+ [Models.xai.Grok4Fast]: 2_000_000,
170
+ [Models.xai.Grok4FastNonReasoning]: 2_000_000,
171
+ // xAI image/video models — no text context window
172
+ [Models.xai.GrokImagineImage]: 0,
173
+ [Models.xai.GrokImagineVideo]: 0,
174
+ [Models.xai.Grok2Image1212]: 0,
98
175
  };
99
176
 
100
177
  /** Default fallback context window limit (tokens) used when a model is not found. */
@@ -0,0 +1,291 @@
1
+ import { Command } from "commander";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { services } from "../services";
5
+ import { ModulesService } from "../services/modules";
6
+ import { getConfig } from "../config";
7
+ import { Modality, ConvertOptions } from "../services/conversion/types";
8
+
9
+ /**
10
+ * Load modules from config so that external converters (e.g. pdf-to-img) get
11
+ * registered into ConversionService before we run a conversion.
12
+ */
13
+ async function setupConversionServices() {
14
+ const config = await getConfig();
15
+ const { Conversion } = services();
16
+
17
+ const allModulePaths = [
18
+ ...(config.modules || []),
19
+ ];
20
+
21
+ if (allModulePaths.length) {
22
+ const modulesService = new ModulesService();
23
+ await modulesService.loadModulesFrom(
24
+ { ...config, modules: allModulePaths },
25
+ {
26
+ Conversion,
27
+ Agents: services().Agents,
28
+ Embeddings: services().Embeddings,
29
+ Plugins: services().Plugins,
30
+ Clients: services().Clients,
31
+ Tools: services().Tools,
32
+ Events: services().Events,
33
+ MediaProcessor: services().MediaProcessor,
34
+ }
35
+ );
36
+ }
37
+
38
+ return { Conversion };
39
+ }
40
+
41
+ /**
42
+ * Modality short aliases accepted on the CLI.
43
+ * img, txt, vid, aud are short forms; full names also accepted.
44
+ */
45
+ const MODALITY_ALIASES: Record<string, Modality> = {
46
+ img: "image",
47
+ image: "image",
48
+ txt: "text",
49
+ text: "text",
50
+ vid: "video",
51
+ video: "video",
52
+ aud: "audio",
53
+ audio: "audio",
54
+ html: "html",
55
+ };
56
+
57
+ /**
58
+ * Parse a comma-separated modality chain like "img,txt" or "image,text".
59
+ * Returns { via: Modality[], target: Modality }.
60
+ * Single value (e.g. "text") → { via: [], target: "text" }.
61
+ *
62
+ * Examples:
63
+ * "text" → via=[], target="text"
64
+ * "img,txt" → via=["image"], target="text"
65
+ * "image,text"→ via=["image"], target="text"
66
+ */
67
+ function parseModalityChain(raw: string): { via: Modality[]; target: Modality } {
68
+ const parts = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
69
+ const modalities: Modality[] = parts.map((p) => {
70
+ const m = MODALITY_ALIASES[p];
71
+ if (!m) {
72
+ throw new Error(
73
+ `Unknown modality "${p}". Valid values: text/txt, image/img, audio/aud, video/vid, html`
74
+ );
75
+ }
76
+ return m;
77
+ });
78
+ if (modalities.length === 0) throw new Error("--to requires at least one modality");
79
+ const target = modalities[modalities.length - 1];
80
+ const via = modalities.slice(0, -1);
81
+ return { via, target };
82
+ }
83
+
84
+ export function addConvertCommand(program: Command): void {
85
+ program
86
+ .command("convert")
87
+ .description("Convert a file between modalities (pdf, image, audio, video, text)")
88
+ .option("-i, --input <path>", "Source file path (required unless --list)")
89
+ .option("-o, --output <path>", "Output file or directory (optional; defaults to stdout for text)")
90
+ .option(
91
+ "--to <chain>",
92
+ "Target modality or explicit chain. Single: text|txt|image|img|audio|aud|video|vid|html. Chain (comma-separated): img,txt forces pdf→image→text path.",
93
+ "text"
94
+ )
95
+ .option("--from <modality>", "Override inferred input modality")
96
+ .option("--prefer <names>", "Comma-separated list of preferred converter names (used when --to is a single modality)")
97
+ .option("--force", "Ignore cache and re-run converters")
98
+ .option("--model <name>", "Model to use (injected into converterOptions for all converters, e.g. tts-1, dall-e-3, gpt-4o)")
99
+ .option("--start-page <n>", "First page (documents)", parseInt)
100
+ .option("--end-page <n>", "Last page (documents)", parseInt)
101
+ .option("--start-line <n>", "First line (text output)", parseInt)
102
+ .option("--end-line <n>", "Last line (text output)", parseInt)
103
+ .option("--start-time <n>", "Start time in seconds (audio/video)", parseFloat)
104
+ .option("--end-time <n>", "End time in seconds (audio/video)", parseFloat)
105
+ .option("--opt <spec>", "Per-converter option in format name.key=value (repeatable)", collect, [])
106
+ .option("--list", "List all registered converters and exit")
107
+ .option("--json", "Emit machine-readable JSON result")
108
+ .action(async (opts) => {
109
+ try {
110
+ const { Conversion } = await setupConversionServices();
111
+
112
+ // --list mode: print registered converters
113
+ if (opts.list) {
114
+ const converters = Conversion.list();
115
+ if (opts.json) {
116
+ console.log(JSON.stringify(converters.map(c => ({
117
+ name: c.name,
118
+ inputExts: c.inputExts,
119
+ inputModality: c.inputModality,
120
+ outputType: c.outputType,
121
+ cache: c.cache ?? false,
122
+ })), null, 2));
123
+ } else {
124
+ console.log("\n📦 Registered converters:\n");
125
+ for (const c of converters) {
126
+ const input = c.inputExts
127
+ ? c.inputExts.join(", ")
128
+ : c.inputModality ?? "?";
129
+ console.log(` • ${c.name.padEnd(24)} ${input} → ${c.outputType}${c.cache ? " (cached)" : ""}`);
130
+ }
131
+ console.log();
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Validate --input
137
+ if (!opts.input) {
138
+ console.error("Error: --input <path> is required (use --list to see converters)");
139
+ process.exit(1);
140
+ }
141
+
142
+ const inputPath = path.resolve(opts.input);
143
+ if (!fs.existsSync(inputPath)) {
144
+ console.error(`Error: input file not found: ${inputPath}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ // Parse --to chain (e.g. "img,txt" → via=["image"], target="text")
149
+ let targetModality: Modality;
150
+ let viaModalities: Modality[] = [];
151
+ try {
152
+ const parsed = parseModalityChain(opts.to || "text");
153
+ targetModality = parsed.target;
154
+ viaModalities = parsed.via;
155
+ } catch (e: any) {
156
+ console.error(`Error: ${e.message}`);
157
+ process.exit(1);
158
+ }
159
+
160
+ // Parse --opt flags into converterOptions
161
+ const converterOptions: Record<string, Record<string, any>> = {};
162
+ for (const spec of (opts.opt as string[])) {
163
+ // format: converterName.key=value
164
+ const dotIdx = spec.indexOf(".");
165
+ const eqIdx = spec.indexOf("=");
166
+ if (dotIdx < 0 || eqIdx < 0 || eqIdx < dotIdx) {
167
+ console.error(`Error: --opt must be in format "converterName.key=value", got: ${spec}`);
168
+ process.exit(1);
169
+ }
170
+ const converterName = spec.slice(0, dotIdx);
171
+ const key = spec.slice(dotIdx + 1, eqIdx);
172
+ const rawValue = spec.slice(eqIdx + 1);
173
+ // Try to coerce to number/boolean, else keep as string
174
+ let value: any = rawValue;
175
+ if (rawValue === "true") value = true;
176
+ else if (rawValue === "false") value = false;
177
+ else if (!isNaN(Number(rawValue)) && rawValue !== "") value = Number(rawValue);
178
+
179
+ if (!converterOptions[converterName]) converterOptions[converterName] = {};
180
+ converterOptions[converterName][key] = value;
181
+ }
182
+
183
+ // --model injects model into all converter option scopes
184
+ // It can be overridden by a more specific --opt converterName.model=xxx
185
+ if (opts.model) {
186
+ const modelInjectedNames = [
187
+ "whisper", "ffmpeg-whisper",
188
+ "image-to-text",
189
+ "text-to-audio", "text-to-image", "text-to-video",
190
+ "image-to-video",
191
+ ];
192
+ for (const name of modelInjectedNames) {
193
+ if (!converterOptions[name]) converterOptions[name] = {};
194
+ if (!converterOptions[name].model) {
195
+ converterOptions[name].model = opts.model;
196
+ }
197
+ }
198
+ }
199
+
200
+ const preferredConverters = opts.prefer
201
+ ? opts.prefer.split(",").map((s: string) => s.trim()).filter(Boolean)
202
+ : [];
203
+
204
+ const chainDisplay = [...viaModalities, targetModality].join(" → ");
205
+ console.error(`🔄 Converting ${inputPath} → ${chainDisplay}...`);
206
+
207
+ const convertOpts: ConvertOptions = {
208
+ force: opts.force ?? false,
209
+ preferredConverters,
210
+ startPage: opts.startPage,
211
+ endPage: opts.endPage,
212
+ startLine: opts.startLine,
213
+ endLine: opts.endLine,
214
+ startTime: opts.startTime,
215
+ endTime: opts.endTime,
216
+ converterOptions,
217
+ onProgress: (stage, fraction) => {
218
+ const pct = Math.round(fraction * 100);
219
+ process.stderr.write(`\r [${pct.toString().padStart(3)}%] ${stage} `);
220
+ },
221
+ ...(viaModalities.length > 0 ? { via: viaModalities } : {}),
222
+ };
223
+
224
+ const result = await Conversion.convert(inputPath, targetModality, convertOpts);
225
+ process.stderr.write("\n");
226
+
227
+ if (opts.json) {
228
+ console.log(JSON.stringify(result, null, 2));
229
+ return;
230
+ }
231
+
232
+ // Text / HTML output
233
+ if (result.outputType === "text" || result.outputType === "html") {
234
+ if (opts.output) {
235
+ fs.writeFileSync(path.resolve(opts.output), result.text ?? "");
236
+ console.error(`✅ Written to ${opts.output}`);
237
+ } else {
238
+ process.stdout.write(result.text ?? "");
239
+ process.stdout.write("\n");
240
+ }
241
+ if (result.usd_cost) {
242
+ console.error(`💰 Cost: $${result.usd_cost.toFixed(6)}`);
243
+ }
244
+ return;
245
+ }
246
+
247
+ // File output (image, audio, video)
248
+ const files = result.files ?? [];
249
+ if (files.length === 0) {
250
+ console.error("⚠ Conversion produced no output files.");
251
+ return;
252
+ }
253
+
254
+ if (opts.output) {
255
+ const outPath = path.resolve(opts.output);
256
+ if (files.length === 1) {
257
+ fs.copyFileSync(files[0], outPath);
258
+ console.error(`✅ Written to ${outPath}`);
259
+ } else {
260
+ // Multiple files → write to directory
261
+ fs.mkdirSync(outPath, { recursive: true });
262
+ for (const f of files) {
263
+ const dest = path.join(outPath, path.basename(f));
264
+ fs.copyFileSync(f, dest);
265
+ console.log(dest);
266
+ }
267
+ console.error(`✅ ${files.length} files written to ${outPath}`);
268
+ }
269
+ } else {
270
+ // Print file paths to stdout
271
+ for (const f of files) {
272
+ console.log(f);
273
+ }
274
+ }
275
+
276
+ if (result.usd_cost) {
277
+ console.error(`💰 Cost: $${result.usd_cost.toFixed(6)}`);
278
+ }
279
+ } catch (err: any) {
280
+ console.error(`❌ Conversion failed: ${err.message}`);
281
+ if (process.env.DEBUG) console.error(err);
282
+ process.exit(1);
283
+ }
284
+ });
285
+ }
286
+
287
+ /** Commander helper: collect repeated option values into an array */
288
+ function collect(val: string, acc: string[]): string[] {
289
+ acc.push(val);
290
+ return acc;
291
+ }