@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.
Files changed (126) 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 +2 -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/conversion.ts +15 -61
  13. package/src/index.ts +3 -0
  14. package/src/processors/CustomVariables.ts +45 -20
  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/SyncedAgentWatcher.ts +8 -0
  19. package/src/services/conversion/ConversionService.ts +763 -0
  20. package/src/services/conversion/index.ts +2 -0
  21. package/src/services/conversion/types.ts +79 -0
  22. package/src/services/index.ts +8 -1
  23. package/src/services/modules/types.ts +2 -0
  24. package/src/services/watchers/FsSyncer.ts +6 -0
  25. package/src/services/watchers/RemoteSyncer.ts +5 -0
  26. package/tests/agents/tools/readFile.test.ts +88 -0
  27. package/tests/clients/AIClient.test.ts +5 -0
  28. package/tests/clients/contextLimits.test.ts +71 -0
  29. package/tests/patching/patchFileOutput.test.ts +217 -0
  30. package/tests/patching/regression-2026.test.ts +278 -0
  31. package/tests/processors/CustomVariables.test.ts +4 -4
  32. package/tests/processors/TokenCompressor.test.ts +59 -1
  33. package/tests/processors/tools/grepToolResponse.test.ts +72 -0
  34. package/tests/services/ConversionService.test.ts +154 -0
  35. package/tests/test.spec.ts +1 -1
  36. package/tests/unit/clients/AIClient.test.ts +8 -0
  37. package/ts_build/package.json +1 -3
  38. package/ts_build/src/agents/base/base.d.ts +3 -0
  39. package/ts_build/src/agents/base/base.js +46 -3
  40. package/ts_build/src/agents/base/base.js.map +1 -1
  41. package/ts_build/src/agents/researcher/researcher.js +5 -2
  42. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  43. package/ts_build/src/agents/tools/list.js +10 -2
  44. package/ts_build/src/agents/tools/list.js.map +1 -1
  45. package/ts_build/src/agents/tools/patch.js +202 -24
  46. package/ts_build/src/agents/tools/patch.js.map +1 -1
  47. package/ts_build/src/agents/tools/readFile.d.ts +1 -1
  48. package/ts_build/src/agents/tools/readFile.js +17 -4
  49. package/ts_build/src/agents/tools/readFile.js.map +1 -1
  50. package/ts_build/src/chat/modules/AgentModule.js +12 -0
  51. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  52. package/ts_build/src/cli.js +2 -0
  53. package/ts_build/src/cli.js.map +1 -1
  54. package/ts_build/src/clients/anthropic.js +7 -2
  55. package/ts_build/src/clients/anthropic.js.map +1 -1
  56. package/ts_build/src/clients/contextLimits.js +70 -0
  57. package/ts_build/src/clients/contextLimits.js.map +1 -1
  58. package/ts_build/src/commands/convert.d.ts +2 -0
  59. package/ts_build/src/commands/convert.js +275 -0
  60. package/ts_build/src/commands/convert.js.map +1 -0
  61. package/ts_build/src/conversion.js +6 -38
  62. package/ts_build/src/conversion.js.map +1 -1
  63. package/ts_build/src/index.d.ts +2 -0
  64. package/ts_build/src/index.js +4 -1
  65. package/ts_build/src/index.js.map +1 -1
  66. package/ts_build/src/processors/CustomVariables.js +14 -12
  67. package/ts_build/src/processors/CustomVariables.js.map +1 -1
  68. package/ts_build/src/processors/TokenCompressor.d.ts +2 -0
  69. package/ts_build/src/processors/TokenCompressor.js +57 -7
  70. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  71. package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
  72. package/ts_build/src/services/AgentSyncFs.js +21 -4
  73. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  74. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -0
  75. package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
  76. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  77. package/ts_build/src/services/SyncedAgentWatcher.d.ts +3 -0
  78. package/ts_build/src/services/SyncedAgentWatcher.js +4 -0
  79. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  80. package/ts_build/src/services/conversion/ConversionService.d.ts +18 -0
  81. package/ts_build/src/services/conversion/ConversionService.js +585 -0
  82. package/ts_build/src/services/conversion/ConversionService.js.map +1 -0
  83. package/ts_build/src/services/conversion/index.d.ts +2 -0
  84. package/ts_build/src/services/conversion/index.js +19 -0
  85. package/ts_build/src/services/conversion/index.js.map +1 -0
  86. package/ts_build/src/services/conversion/types.d.ts +49 -0
  87. package/ts_build/src/services/conversion/types.js +3 -0
  88. package/ts_build/src/services/conversion/types.js.map +1 -0
  89. package/ts_build/src/services/index.d.ts +3 -0
  90. package/ts_build/src/services/index.js +6 -1
  91. package/ts_build/src/services/index.js.map +1 -1
  92. package/ts_build/src/services/modules/index.d.ts +2 -0
  93. package/ts_build/src/services/modules/types.d.ts +2 -0
  94. package/ts_build/src/services/watchers/FsSyncer.d.ts +1 -0
  95. package/ts_build/src/services/watchers/FsSyncer.js +5 -0
  96. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  97. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +1 -0
  98. package/ts_build/src/services/watchers/RemoteSyncer.js +4 -0
  99. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  100. package/ts_build/tests/agents/tools/readFile.test.d.ts +1 -0
  101. package/ts_build/tests/agents/tools/readFile.test.js +90 -0
  102. package/ts_build/tests/agents/tools/readFile.test.js.map +1 -0
  103. package/ts_build/tests/clients/AIClient.test.js +1 -0
  104. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  105. package/ts_build/tests/clients/contextLimits.test.d.ts +1 -0
  106. package/ts_build/tests/clients/contextLimits.test.js +57 -0
  107. package/ts_build/tests/clients/contextLimits.test.js.map +1 -0
  108. package/ts_build/tests/patching/patchFileOutput.test.d.ts +1 -0
  109. package/ts_build/tests/patching/patchFileOutput.test.js +187 -0
  110. package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -0
  111. package/ts_build/tests/patching/regression-2026.test.js +214 -0
  112. package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
  113. package/ts_build/tests/processors/CustomVariables.test.js +4 -4
  114. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
  115. package/ts_build/tests/processors/TokenCompressor.test.js +37 -1
  116. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  117. package/ts_build/tests/processors/tools/grepToolResponse.test.d.ts +1 -0
  118. package/ts_build/tests/processors/tools/grepToolResponse.test.js +40 -0
  119. package/ts_build/tests/processors/tools/grepToolResponse.test.js.map +1 -0
  120. package/ts_build/tests/services/ConversionService.test.d.ts +1 -0
  121. package/ts_build/tests/services/ConversionService.test.js +154 -0
  122. package/ts_build/tests/services/ConversionService.test.js.map +1 -0
  123. package/ts_build/tests/test.spec.js +1 -1
  124. package/ts_build/tests/test.spec.js.map +1 -1
  125. package/ts_build/tests/unit/clients/AIClient.test.js +3 -0
  126. 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
@@ -36,6 +36,7 @@ import {
36
36
  addChatCommand,
37
37
  addGithubCredentialsCommand,
38
38
  } from "./commands/misc";
39
+ import { addConvertCommand } from "./commands/convert";
39
40
 
40
41
  // Handle unhandled promise rejections gracefully — particularly from MCP SDK
41
42
  // which fires errors via event emitters that can bypass Promise.allSettled.
@@ -98,6 +99,7 @@ async function main() {
98
99
  addGithubCredentialsCommand(program);
99
100
  addModulesCommand(program);
100
101
  addMcpCommands(program);
102
+ addConvertCommand(program);
101
103
 
102
104
  // Load global modules early (before parse) so they can register CLI subcommands.
103
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
+ }
package/src/conversion.ts CHANGED
@@ -1,16 +1,17 @@
1
- import pdf from "pdf-parse";
2
- import * as fs from "fs";
3
1
  import * as path from "path";
4
2
  import { readFile, fileExists } from "./utils";
5
3
 
6
4
  /**
7
- * Get the MediaProcessorService from services() lazily.
8
- * We import lazily to avoid circular dependency issues.
5
+ * Get services lazily to avoid circular dependency issues.
9
6
  */
10
- function getMediaProcessor() {
7
+ function getServices() {
11
8
  // eslint-disable-next-line @typescript-eslint/no-var-requires
12
9
  const { services } = require("./services") as typeof import("./services");
13
- return services().MediaProcessor;
10
+ return services();
11
+ }
12
+
13
+ function getMediaProcessor() {
14
+ return getServices().MediaProcessor;
14
15
  }
15
16
 
16
17
  export async function processAudio(
@@ -38,15 +39,10 @@ export async function convertAudioToText(
38
39
  filePath: string,
39
40
  reusePreviousTranscript = true,
40
41
  chunkTime = 30
41
- ) {
42
- const audios = await processAudio(
43
- filePath,
44
- reusePreviousTranscript,
45
- chunkTime
46
- );
42
+ ): Promise<string> {
43
+ const audios = await processAudio(filePath, reusePreviousTranscript, chunkTime);
47
44
 
48
45
  let fullString = "";
49
-
50
46
  for (let i = 0; i < audios.length; i++) {
51
47
  const audio = audios[i];
52
48
  fullString += `[${i * chunkTime}:${(i + 1) * chunkTime}s] ${audio}`;
@@ -59,57 +55,15 @@ export async function processVideo(
59
55
  filePath: string,
60
56
  reusePreviousTranscript = true,
61
57
  chunkTime = 30
62
- ) {
63
- const parsed = path.parse(filePath);
64
- const outputPath = `${parsed.dir}/${parsed.name}/video.json`;
65
-
58
+ ): Promise<string[]> {
66
59
  console.log("Processing audio...");
67
- const transcriptions = await processAudio(
68
- filePath,
69
- reusePreviousTranscript,
70
- chunkTime
71
- );
72
-
73
- // Return the transcriptions as text — keyframe extraction requires the
74
- // @tyvm/knowhow-module-video-downloader module
60
+ const transcriptions = await processAudio(filePath, reusePreviousTranscript, chunkTime);
75
61
  return transcriptions;
76
62
  }
77
63
 
78
- async function convertVideoToText(
79
- filePath: string,
80
- reusePreviousTranscript = true,
81
- chunkTime = 30
82
- ): Promise<string> {
83
- const transcriptions = await processVideo(filePath, reusePreviousTranscript, chunkTime);
84
- if (Array.isArray(transcriptions)) {
85
- return transcriptions.join("\n");
86
- }
87
- return String(transcriptions);
88
- }
89
-
90
- async function convertPdfToText(filePath: string) {
91
- const existingPdfBytes = fs.readFileSync(filePath);
92
- const data = await pdf(existingPdfBytes);
93
- return data.text;
94
- }
95
-
64
+ /**
65
+ * Thin compat shim — delegates to ConversionService.
66
+ */
96
67
  export async function convertToText(filePath: string): Promise<string> {
97
- const extension = filePath.split(".").pop();
98
-
99
- switch (extension) {
100
- case "mp4":
101
- case "webm":
102
- case "mov":
103
- case "mpeg":
104
- return convertVideoToText(filePath);
105
- case "mp3":
106
- case "mpga":
107
- case "m4a":
108
- case "wav":
109
- return convertAudioToText(filePath);
110
- case "pdf":
111
- return convertPdfToText(filePath);
112
- default:
113
- return ((await readFile(filePath, "utf8")) as string) || "";
114
- }
68
+ return getServices().Conversion.convertToText(filePath);
115
69
  }
package/src/index.ts CHANGED
@@ -54,6 +54,9 @@ export * as ai from "./ai";
54
54
  // Export module system types for external modules
55
55
  export * from "./services/modules/types";
56
56
  export { ModulesService } from "./services/modules";
57
+ // Export conversion types for external modules (e.g. knowhow-module-pdf)
58
+ export * from "./services/conversion/types";
59
+ export { ConversionService } from "./services/conversion/ConversionService";
57
60
  // Export plugin types for external plugins
58
61
  export { PluginBase } from "./plugins/PluginBase";
59
62
  export { PluginMeta, Plugin, PluginContext } from "./plugins/types";