@tyvm/knowhow 0.0.109 → 0.0.110

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 (94) hide show
  1. package/autodoc/README.md +324 -0
  2. package/autodoc/chat-guide.md +268 -365
  3. package/autodoc/cli-reference.md +399 -473
  4. package/autodoc/config-reference.md +431 -330
  5. package/autodoc/embeddings-guide.md +223 -322
  6. package/autodoc/generate-guide.md +261 -301
  7. package/autodoc/language-plugin-guide.md +221 -247
  8. package/autodoc/modules-guide.md +242 -215
  9. package/autodoc/plugins-guide.md +470 -469
  10. package/autodoc/quickstart-guide.md +67 -70
  11. package/autodoc/skills-guide.md +455 -339
  12. package/autodoc/worker-guide.md +301 -308
  13. package/package.json +1 -1
  14. package/scripts/build-for-node.sh +10 -24
  15. package/src/agents/tools/list.ts +2 -2
  16. package/src/ai.ts +81 -37
  17. package/src/chat/CliChatService.ts +1 -1
  18. package/src/chat/modules/AgentModule.ts +7 -2
  19. package/src/chat/modules/SessionsModule.ts +40 -1
  20. package/src/chat/modules/SystemModule.ts +2 -2
  21. package/src/clients/anthropic.ts +1 -1
  22. package/src/clients/index.ts +25 -6
  23. package/src/clients/openai.ts +8 -5
  24. package/src/clients/types.ts +29 -6
  25. package/src/clients/withRetry.ts +89 -0
  26. package/src/commands/agent.ts +30 -0
  27. package/src/commands/modules.ts +417 -47
  28. package/src/config.ts +1 -1
  29. package/src/fileSync.ts +20 -12
  30. package/src/hashes.ts +43 -22
  31. package/src/index.ts +4 -2
  32. package/src/processors/Base64ImageDetector.ts +73 -0
  33. package/src/services/MediaProcessorService.ts +79 -10
  34. package/src/services/modules/index.ts +47 -18
  35. package/tests/processors/Base64ImageDetector.test.ts +160 -0
  36. package/tests/unit/clients/AIClient.test.ts +446 -0
  37. package/tests/unit/clients/withRetry.test.ts +319 -0
  38. package/tests/unit/commands/github-credentials.test.ts +1 -2
  39. package/ts_build/package.json +1 -1
  40. package/ts_build/src/agents/tools/list.js +2 -2
  41. package/ts_build/src/agents/tools/list.js.map +1 -1
  42. package/ts_build/src/ai.d.ts +3 -3
  43. package/ts_build/src/ai.js +51 -23
  44. package/ts_build/src/ai.js.map +1 -1
  45. package/ts_build/src/chat/CliChatService.js +1 -1
  46. package/ts_build/src/chat/CliChatService.js.map +1 -1
  47. package/ts_build/src/chat/modules/AgentModule.js +5 -2
  48. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  49. package/ts_build/src/chat/modules/SessionsModule.js +30 -1
  50. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  51. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  52. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  53. package/ts_build/src/clients/anthropic.js +1 -1
  54. package/ts_build/src/clients/anthropic.js.map +1 -1
  55. package/ts_build/src/clients/index.js +7 -6
  56. package/ts_build/src/clients/index.js.map +1 -1
  57. package/ts_build/src/clients/openai.js +4 -4
  58. package/ts_build/src/clients/openai.js.map +1 -1
  59. package/ts_build/src/clients/types.d.ts +12 -6
  60. package/ts_build/src/clients/withRetry.d.ts +2 -0
  61. package/ts_build/src/clients/withRetry.js +60 -0
  62. package/ts_build/src/clients/withRetry.js.map +1 -0
  63. package/ts_build/src/commands/agent.js +25 -0
  64. package/ts_build/src/commands/agent.js.map +1 -1
  65. package/ts_build/src/commands/modules.js +359 -32
  66. package/ts_build/src/commands/modules.js.map +1 -1
  67. package/ts_build/src/config.js +1 -1
  68. package/ts_build/src/config.js.map +1 -1
  69. package/ts_build/src/fileSync.d.ts +2 -2
  70. package/ts_build/src/fileSync.js +13 -11
  71. package/ts_build/src/fileSync.js.map +1 -1
  72. package/ts_build/src/hashes.d.ts +2 -2
  73. package/ts_build/src/hashes.js +40 -16
  74. package/ts_build/src/hashes.js.map +1 -1
  75. package/ts_build/src/index.js +1 -1
  76. package/ts_build/src/index.js.map +1 -1
  77. package/ts_build/src/processors/Base64ImageDetector.d.ts +3 -0
  78. package/ts_build/src/processors/Base64ImageDetector.js +42 -0
  79. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  80. package/ts_build/src/services/MediaProcessorService.d.ts +5 -4
  81. package/ts_build/src/services/MediaProcessorService.js +53 -8
  82. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  83. package/ts_build/src/services/modules/index.js +35 -12
  84. package/ts_build/src/services/modules/index.js.map +1 -1
  85. package/ts_build/tests/processors/Base64ImageDetector.test.js +111 -0
  86. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
  87. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  88. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  89. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  90. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  91. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  92. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  93. package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
  94. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
package/src/fileSync.ts CHANGED
@@ -6,7 +6,7 @@ import { loadJwt } from "./login";
6
6
  import { getConfig } from "./config";
7
7
  import { services } from "./services";
8
8
  import { S3Service } from "./services/S3";
9
- import { getHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote, isLocalFileMatchingDownloadHash, saveDownloadHash } from "./hashes";
9
+ import { getHashes, saveHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote, isLocalFileMatchingDownloadHash, saveDownloadHash } from "./hashes";
10
10
 
11
11
  export const DEFAULT_BATCH_SIZE = 5;
12
12
 
@@ -165,7 +165,8 @@ export async function downloadFile(
165
165
  s3Service: S3Service,
166
166
  remotePath: string,
167
167
  localPath: string,
168
- dryRun: boolean
168
+ dryRun: boolean,
169
+ hashes?: any
169
170
  ): Promise<void> {
170
171
  console.log(`ā¬‡ļø Downloading ${remotePath} → ${localPath}`);
171
172
 
@@ -176,8 +177,7 @@ export async function downloadFile(
176
177
 
177
178
  try {
178
179
  // Fast-path: check stored download hash before hitting the API
179
- const hashes = await getHashes();
180
- if (await isLocalFileMatchingDownloadHash(localPath, hashes)) {
180
+ if (hashes && await isLocalFileMatchingDownloadHash(localPath, hashes)) {
181
181
  console.log(` āœ“ Skipping ${localPath} (matches stored download hash)`);
182
182
  return;
183
183
  }
@@ -189,7 +189,7 @@ export async function downloadFile(
189
189
  if (isLocalFileMatchingRemote(localPath, checksumSHA256)) {
190
190
  console.log(` āœ“ Skipping ${localPath} (matches remote checksum)`);
191
191
  // Store the hash so future syncs can skip without hitting the API
192
- await saveDownloadHash(localPath);
192
+ await saveDownloadHash(localPath, hashes);
193
193
  return;
194
194
  }
195
195
 
@@ -203,7 +203,7 @@ export async function downloadFile(
203
203
  await s3Service.downloadFromPresignedUrl(downloadUrl, localPath);
204
204
 
205
205
  // Save download hash so we can skip unchanged files next time
206
- await saveDownloadHash(localPath);
206
+ await saveDownloadHash(localPath, hashes);
207
207
 
208
208
  // Get file size for logging
209
209
  const stats = fs.statSync(localPath);
@@ -221,7 +221,8 @@ export async function uploadFile(
221
221
  s3Service: S3Service,
222
222
  remotePath: string,
223
223
  localPath: string,
224
- dryRun: boolean
224
+ dryRun: boolean,
225
+ hashes?: any
225
226
  ): Promise<void> {
226
227
  console.log(`ā¬†ļø Uploading ${localPath} → ${remotePath}`);
227
228
 
@@ -237,8 +238,7 @@ export async function uploadFile(
237
238
  }
238
239
 
239
240
  // Skip upload if file hasn't changed since last upload
240
- const hashes = await getHashes();
241
- const changed = await hasFileChangedSinceUpload(localPath, hashes);
241
+ const changed = hashes ? await hasFileChangedSinceUpload(localPath, hashes) : true;
242
242
  if (!changed) {
243
243
  console.log(` āœ“ Skipping ${localPath} (unchanged since last upload)`);
244
244
  return;
@@ -254,7 +254,7 @@ export async function uploadFile(
254
254
  await client.markOrgFileUploadComplete(remotePath);
255
255
 
256
256
  // Save upload hash so we can skip unchanged files next time
257
- await saveUploadHash(localPath);
257
+ await saveUploadHash(localPath, hashes);
258
258
 
259
259
  const stats = fs.statSync(localPath);
260
260
  console.log(` āœ“ Uploaded ${stats.size} bytes`);
@@ -276,6 +276,8 @@ export async function uploadDirectory(
276
276
 
277
277
  console.log(`ā¬†ļø Uploading directory ${localDir} → ${remoteDir}`);
278
278
 
279
+ const hashes = await getHashes();
280
+
279
281
  if (!fs.existsSync(localDir)) {
280
282
  console.warn(` āš ļø Local directory not found: ${localDir}`);
281
283
  return 0;
@@ -295,7 +297,7 @@ export async function uploadDirectory(
295
297
  const localFilePath = localDir + relFile;
296
298
  const remoteFilePath = remoteDir + relFile;
297
299
  try {
298
- await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun);
300
+ await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun, hashes);
299
301
  return 1;
300
302
  } catch (error) {
301
303
  console.error(
@@ -306,6 +308,8 @@ export async function uploadDirectory(
306
308
  });
307
309
 
308
310
  const counts = await batchRun(tasks);
311
+ await saveHashes(hashes);
312
+
309
313
  return counts.reduce((sum, n) => sum + n, 0);
310
314
  }
311
315
 
@@ -325,6 +329,8 @@ export async function downloadDirectory(
325
329
 
326
330
  console.log(`ā¬‡ļø Downloading directory ${remoteDir} → ${localDir}`);
327
331
 
332
+ const hashes = await getHashes();
333
+
328
334
  // List all org files and find those in the remote directory
329
335
  const response = await client.listOrgFiles();
330
336
  const allFiles = response.data;
@@ -352,10 +358,12 @@ export async function downloadDirectory(
352
358
  // Strip the base remote dir prefix to get relative path
353
359
  const relativePath = fullRemotePath.slice(remoteDir.length);
354
360
  const localFilePath = localDir + relativePath;
355
- await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun);
361
+ await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun, hashes);
356
362
  return 1;
357
363
  });
358
364
 
359
365
  const counts = await batchRun(tasks);
366
+ await saveHashes(hashes);
367
+
360
368
  return counts.reduce((sum, n) => sum + n, 0);
361
369
  }
package/src/hashes.ts CHANGED
@@ -1,16 +1,35 @@
1
1
  import fs from "fs";
2
2
  import * as crypto from "crypto";
3
3
  import { Hashes } from "./types";
4
- import { readFile, writeFile } from "./utils";
4
+ import { readFile } from "./utils";
5
5
  import { convertToText } from "./conversion";
6
6
 
7
7
  export async function getHashes() {
8
- const hashes = JSON.parse(await readFile(".knowhow/.hashes.json", "utf8"));
9
- return hashes as Hashes;
8
+ try {
9
+ const hashes = JSON.parse(await readFile(".knowhow/.hashes.json", "utf8"));
10
+ return hashes as Hashes;
11
+ } catch (err: any) {
12
+ if (err.code === "ENOENT") {
13
+ return {} as Hashes;
14
+ }
15
+ throw err;
16
+ }
10
17
  }
11
18
 
19
+ /**
20
+ * Atomically save hashes to disk — writes to a temp file then renames,
21
+ * preventing concurrent writes from producing corrupted/truncated JSON.
22
+ */
12
23
  export async function saveHashes(hashes: any) {
13
- await writeFile(".knowhow/.hashes.json", JSON.stringify(hashes, null, 2));
24
+ const target = ".knowhow/.hashes.json";
25
+ const tmp = `${target}.tmp.${process.pid}`;
26
+ try {
27
+ fs.writeFileSync(tmp, JSON.stringify(hashes, null, 2));
28
+ fs.renameSync(tmp, target);
29
+ } catch (err) {
30
+ try { fs.unlinkSync(tmp); } catch (_) {}
31
+ throw err;
32
+ }
14
33
  }
15
34
 
16
35
  export async function md5Hash(str: string) {
@@ -35,18 +54,17 @@ export async function checkNoFilesChanged(
35
54
  return false;
36
55
  }
37
56
 
38
- if (
57
+ // Check if this file has changed (either format)
58
+ const matchesLegacy =
39
59
  hashes[file].promptHash === promptHash &&
40
- hashes[file].fileHash === fileHash
41
- ) {
42
- return true;
43
- }
60
+ hashes[file].fileHash === fileHash;
61
+ const matchesCurrent = hashes[file][promptHash] === fileHash;
44
62
 
45
- if (hashes[file][promptHash] === fileHash) {
46
- return true;
63
+ if (!matchesLegacy && !matchesCurrent) {
64
+ // This file has changed — re-generation needed
65
+ return false;
47
66
  }
48
-
49
- return false;
67
+ // This file is unchanged — continue checking the rest
50
68
  }
51
69
 
52
70
  return true;
@@ -90,17 +108,19 @@ export async function hasFileChangedSinceUpload(
90
108
  }
91
109
 
92
110
  /**
93
- * Saves the hash of the file at the time of a successful upload
111
+ * Mutates the provided hashes object with the upload hash for localPath.
112
+ * If no hashes object is provided, loads, mutates, and saves independently.
94
113
  */
95
- export async function saveUploadHash(localPath: string) {
96
- const hashes = await getHashes();
114
+ export async function saveUploadHash(localPath: string, hashes?: any) {
115
+ const standalone = !hashes;
116
+ if (standalone) hashes = await getHashes();
97
117
  const content = fs.readFileSync(localPath);
98
118
  const currentHash = crypto.createHash("md5").update(content).digest("hex");
99
119
  if (!hashes[localPath]) {
100
120
  hashes[localPath] = { fileHash: currentHash, promptHash: "" };
101
121
  }
102
122
  hashes[localPath][UPLOAD_KEY] = currentHash;
103
- await saveHashes(hashes);
123
+ if (standalone) await saveHashes(hashes);
104
124
  }
105
125
 
106
126
  /**
@@ -120,18 +140,19 @@ export async function isLocalFileMatchingDownloadHash(
120
140
  }
121
141
 
122
142
  /**
123
- * Saves the SHA-256 hash of the file after a successful download so we can
124
- * skip unchanged files on the next sync.
143
+ * Mutates the provided hashes object with the download hash for localPath.
144
+ * If no hashes object is provided, loads, mutates, and saves independently.
125
145
  */
126
- export async function saveDownloadHash(localPath: string) {
127
- const hashes = await getHashes();
146
+ export async function saveDownloadHash(localPath: string, hashes?: any) {
147
+ const standalone = !hashes;
148
+ if (standalone) hashes = await getHashes();
128
149
  const content = fs.readFileSync(localPath);
129
150
  const currentHash = crypto.createHash("sha256").update(content).digest("base64");
130
151
  if (!hashes[localPath]) {
131
152
  hashes[localPath] = { fileHash: currentHash, promptHash: "" };
132
153
  }
133
154
  hashes[localPath][DOWNLOAD_KEY] = currentHash;
134
- await saveHashes(hashes);
155
+ if (standalone) await saveHashes(hashes);
135
156
  }
136
157
 
137
158
  /**
package/src/index.ts CHANGED
@@ -184,13 +184,15 @@ export async function upload() {
184
184
  * - Standard glob patterns (e.g. "src/**\/*.ts")
185
185
  * - Brace expansion (e.g. "{src/a.ts,src/b.ts}")
186
186
  * - Comma-separated file paths (e.g. "src/a.ts,src/b.ts") — auto-converted to brace expansion
187
+ * - Mixed comma-separated list with globs (e.g. "src/a.ts,src/commands/**\/*.ts")
187
188
  */
188
189
  function normalizeInputPattern(input: string): string {
189
- // If it already has braces or glob chars other than comma, use as-is
190
- if (input.includes("{") || input.includes("*") || input.includes("?")) {
190
+ // If it already has braces, use as-is (already brace-expanded)
191
+ if (input.includes("{")) {
191
192
  return input;
192
193
  }
193
194
  // If it contains commas, treat as comma-separated list and wrap in braces
195
+ // This also handles the mixed case: "src/a.ts,src/commands/**/*.ts"
194
196
  if (input.includes(",")) {
195
197
  const parts = input.split(",").map((p) => p.trim());
196
198
  return `{${parts.join(",")}}`;
@@ -17,6 +17,15 @@ interface TextContent {
17
17
  text: string;
18
18
  }
19
19
 
20
+ /**
21
+ * Regex that matches common image file paths (absolute, relative, or just filenames)
22
+ * ending in a known image extension.
23
+ */
24
+ const IMAGE_PATH_REGEX =
25
+ /(?:^|[\s"'(,\[{])([^\s"'(,\[{]*?\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s"'),\]}])/gi;
26
+
27
+ const IMAGE_EXTENSIONS = ["png", "jpeg", "jpg", "gif", "webp", "bmp", "svg"];
28
+
20
29
  export class Base64ImageProcessor {
21
30
  private imageDetail: "auto" | "low" | "high" = "auto";
22
31
  private supportedFormats = ["png", "jpeg", "jpg", "gif", "webp"];
@@ -118,6 +127,61 @@ export class Base64ImageProcessor {
118
127
  }
119
128
  }
120
129
 
130
+ /**
131
+ * Finds all image file paths mentioned in a text string.
132
+ * Returns deduplicated list of paths like "/tmp/screenshot.png" or "screenshot.jpg".
133
+ */
134
+ private findImageFilePaths(text: string): string[] {
135
+ const found: string[] = [];
136
+ const seen = new Set<string>();
137
+ IMAGE_PATH_REGEX.lastIndex = 0;
138
+ let match: RegExpExecArray | null;
139
+ while ((match = IMAGE_PATH_REGEX.exec(text)) !== null) {
140
+ const filePath = match[1];
141
+ if (filePath && !seen.has(filePath)) {
142
+ seen.add(filePath);
143
+ found.push(filePath);
144
+ }
145
+ }
146
+ return found;
147
+ }
148
+
149
+ /**
150
+ * Appends a hint to a text string if image file paths are detected.
151
+ * The hint tells the model it can use loadImageAsBase64 to view the image.
152
+ */
153
+ private addImagePathHint(text: string): string {
154
+ const paths = this.findImageFilePaths(text);
155
+ if (paths.length === 0) return text;
156
+
157
+ const hints = paths.map(
158
+ (p) =>
159
+ `loadImageAsBase64("${p}") to view the image at ${p}`
160
+ );
161
+
162
+ const hintBlock =
163
+ paths.length === 1
164
+ ? `\n\n[TIP: An image file path was detected: ${paths[0]}. Use the \`loadImageAsBase64\` tool with this path to load and view the image: ${hints[0]}]`
165
+ : `\n\n[TIP: Image file paths were detected. Use the \`loadImageAsBase64\` tool to load and view them:\n${hints.map((h) => ` - ${h}`).join("\n")}]`;
166
+
167
+ return text + hintBlock;
168
+ }
169
+
170
+ /**
171
+ * Applies image path hints to a message's text content items.
172
+ */
173
+ private applyImagePathHintsToMessage(message: Message): void {
174
+ if (typeof message.content === "string") {
175
+ message.content = this.addImagePathHint(message.content);
176
+ } else if (Array.isArray(message.content)) {
177
+ for (const item of message.content) {
178
+ if (item && (item as TextContent).type === "text" && typeof (item as TextContent).text === "string") {
179
+ (item as TextContent).text = this.addImagePathHint((item as TextContent).text);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
121
185
  private processToolCallArguments(message: Message): void {
122
186
  if (message.tool_calls) {
123
187
  for (const toolCall of message.tool_calls) {
@@ -209,6 +273,15 @@ export class Base64ImageProcessor {
209
273
  // and converted to proper image content before the agent sees them
210
274
  if (message.role === "tool") {
211
275
  this.processToolMessageContent(message);
276
+ // After processing tool content (which may not convert to image if it's plain text
277
+ // describing a screenshot path), add hints for any image file paths found in the text.
278
+ this.applyImagePathHintsToMessage(message);
279
+ }
280
+
281
+ // Also apply hints to assistant messages — e.g. when an assistant message
282
+ // contains the result of a screenshot tool that returned a file path.
283
+ if (message.role === "assistant") {
284
+ this.applyImagePathHintsToMessage(message);
212
285
  }
213
286
 
214
287
  // Process tool calls in any message
@@ -1,9 +1,10 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { exec } from "child_process";
3
+ import { exec, spawn } from "child_process";
4
4
  import { promisify } from "util";
5
5
  import { fileExists, readFile, mkdir } from "../utils";
6
6
  import { AIClient } from "../clients";
7
+ import { Models } from "../types";
7
8
 
8
9
  const execPromise = promisify(exec);
9
10
 
@@ -36,7 +37,7 @@ export interface KeyframeInfo {
36
37
  * audio/video processing steps after downloading with ytdl.
37
38
  */
38
39
  export class MediaProcessorService {
39
- constructor(private clients: any) {}
40
+ constructor(private clients: AIClient) {}
40
41
 
41
42
  /**
42
43
  * Split an audio/video file into fixed-length mp3 chunks using ffmpeg.
@@ -45,7 +46,8 @@ export class MediaProcessorService {
45
46
  filePath: string,
46
47
  outputDir: string,
47
48
  CHUNK_LENGTH_SECONDS = 30,
48
- reuseExistingChunks = true
49
+ reuseExistingChunks = true,
50
+ onProgress?: (progressFraction: number) => void
49
51
  ): Promise<string[]> {
50
52
  const parsed = path.parse(filePath);
51
53
  const fileName = parsed.name;
@@ -72,8 +74,70 @@ export class MediaProcessorService {
72
74
  }
73
75
  }
74
76
 
75
- const command = `ffmpeg -i "${filePath}" -f segment -segment_time ${CHUNK_LENGTH_SECONDS} -map 0:a:0 -acodec mp3 -vn "${outputDirPath}/chunk%04d.mp3"`;
76
- await execAsync(command);
77
+ // Use faster encoding settings:
78
+ // - mono audio (-ac 1): halves encoding work, Whisper handles mono fine
79
+ // - low bitrate (-b:a 32k): sufficient for speech, much faster encode + smaller files
80
+ // - fast preset not available for mp3 encoder, but limiting bitrate helps
81
+ // - -threads 0: use all available CPU threads for faster processing
82
+ // If the input is already an mp3, copy the audio stream to avoid re-encoding
83
+ const inputExt = path.extname(filePath).toLowerCase().replace('.', '');
84
+ const isAlreadyMp3 = inputExt === 'mp3';
85
+ const audioCodecArgs = isAlreadyMp3
86
+ ? '-acodec copy'
87
+ : '-acodec libmp3lame -ac 1 -b:a 32k -threads 0';
88
+
89
+ // Use -progress pipe:1 to get real-time progress from ffmpeg
90
+ // We need the total duration first to calculate fraction
91
+ await new Promise<void>((resolve, reject) => {
92
+ // Get total duration via ffprobe first
93
+ let totalDurationSeconds = 0;
94
+ exec(
95
+ `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
96
+ (err, stdout) => {
97
+ if (!err && stdout.trim()) {
98
+ totalDurationSeconds = parseFloat(stdout.trim()) || 0;
99
+ }
100
+
101
+ // Now run ffmpeg with progress reporting
102
+ const args = [
103
+ '-i', filePath,
104
+ '-f', 'segment',
105
+ '-segment_time', String(CHUNK_LENGTH_SECONDS),
106
+ '-map', '0:a:0',
107
+ ...audioCodecArgs.split(' '),
108
+ '-vn',
109
+ ...(onProgress ? ['-progress', 'pipe:1'] : []),
110
+ `${outputDirPath}/chunk%04d.mp3`,
111
+ ];
112
+
113
+ const proc = spawn('ffmpeg', args);
114
+
115
+ let stdoutBuf = '';
116
+ proc.stdout?.on('data', (data: Buffer) => {
117
+ stdoutBuf += data.toString();
118
+ if (onProgress && totalDurationSeconds > 0) {
119
+ // ffmpeg -progress outputs key=value lines; look for out_time_ms
120
+ const match = stdoutBuf.match(/out_time_ms=(\d+)/g);
121
+ if (match) {
122
+ const last = match[match.length - 1];
123
+ const ms = parseInt(last.split('=')[1], 10);
124
+ const fraction = Math.min(ms / 1000 / totalDurationSeconds, 1);
125
+ onProgress(fraction);
126
+ // Keep only tail to avoid unbounded buffer growth
127
+ stdoutBuf = stdoutBuf.slice(-500);
128
+ }
129
+ }
130
+ });
131
+
132
+ proc.on('close', (code) => {
133
+ if (code === 0) resolve();
134
+ else reject(new Error(`ffmpeg exited with code ${code}`));
135
+ });
136
+ proc.on('error', reject);
137
+ }
138
+ );
139
+ });
140
+
77
141
  await fs.promises.writeFile(doneFilePath, "done");
78
142
 
79
143
  const folderFiles = await fs.promises.readdir(outputDirPath);
@@ -298,8 +362,9 @@ export class MediaProcessorService {
298
362
  });
299
363
  const image = `data:image/jpeg;base64,${base64}`;
300
364
  return this.clients.createCompletion("openai", {
301
- model: "gpt-4o",
365
+ model: Models.openai.GPT_4o,
302
366
  max_tokens: 2500,
367
+ timeout: 20000,
303
368
  messages: [
304
369
  {
305
370
  role: "user",
@@ -315,7 +380,8 @@ export class MediaProcessorService {
315
380
  async *streamProcessVideo(
316
381
  filePath: string,
317
382
  reusePreviousTranscript = true,
318
- chunkTime = 30
383
+ chunkTime = 30,
384
+ onChunkingProgress?: (fraction: number) => void
319
385
  ) {
320
386
  const parsed = path.parse(filePath);
321
387
  const videoJson = `${parsed.dir}/${parsed.name}/video.json`;
@@ -324,7 +390,8 @@ export class MediaProcessorService {
324
390
  const transcriptions = this.streamProcessAudio(
325
391
  filePath,
326
392
  reusePreviousTranscript,
327
- chunkTime
393
+ chunkTime,
394
+ onChunkingProgress
328
395
  );
329
396
 
330
397
  console.log("Extracting keyframes...");
@@ -352,7 +419,8 @@ export class MediaProcessorService {
352
419
  async *streamProcessAudio(
353
420
  filePath: string,
354
421
  reusePreviousTranscript = true,
355
- chunkTime = 30
422
+ chunkTime = 30,
423
+ onChunkingProgress?: (fraction: number) => void
356
424
  ): AsyncGenerator<TranscriptChunk> {
357
425
  const parsed = path.parse(filePath);
358
426
  const outputPath = `${parsed.dir}/${parsed.name}/transcript.json`;
@@ -382,7 +450,8 @@ export class MediaProcessorService {
382
450
  filePath,
383
451
  parsed.dir,
384
452
  chunkTime,
385
- reusePreviousTranscript
453
+ reusePreviousTranscript,
454
+ onChunkingProgress
386
455
  );
387
456
 
388
457
  for await (const chunk of this.streamTranscription(
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import * as os from "os";
2
3
 
3
4
  import { getConfig, getGlobalConfig } from "../../config";
4
5
  import { KnowhowModule, ModuleContext } from "./types";
@@ -26,29 +27,57 @@ export class ModulesService {
26
27
 
27
28
  const allModulePaths = config.modules;
28
29
 
30
+ // Search paths: .knowhow/node_modules first (where `knowhow modules install`
31
+ // puts packages), then cwd node_modules, then global node_modules.
32
+ // This allows modules installed via `knowhow modules install` to be found
33
+ // even when knowhow itself is installed globally.
34
+ const resolvePaths = [
35
+ path.join(process.cwd(), ".knowhow", "node_modules"),
36
+ path.join(os.homedir(), ".knowhow", "node_modules"),
37
+ path.join(process.cwd(), "node_modules"),
38
+ ];
39
+
29
40
  for (const modulePath of allModulePaths) {
30
41
  // Resolve relative paths relative to process.cwd() so that paths like
31
42
  // "../../packages/knowhow-module-load-webpage" in knowhow.json work
32
43
  // regardless of where the compiled output lives.
33
- const resolvedPath = modulePath.startsWith(".")
34
- ? path.resolve(process.cwd(), modulePath)
35
- : modulePath;
36
- const rawModule = require(resolvedPath);
37
- const importedModule = (rawModule.default || rawModule) as KnowhowModule;
38
- context.Events?.log(
39
- "ModulesService",
40
- `šŸ”Œ Loading module: ${modulePath} (resolved: ${resolvedPath})`
41
- );
42
- await importedModule.init({
43
- config,
44
- cwd: process.cwd(),
45
- context: context as ModuleContext,
46
- });
47
- context.Events?.log(
48
- "ModulesService",
49
- `āœ… Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
50
- );
44
+ let resolvedPath: string;
45
+ if (modulePath.startsWith(".")) {
46
+ resolvedPath = path.resolve(process.cwd(), modulePath);
47
+ } else {
48
+ // For npm package names, try resolving from cwd first so locally-installed
49
+ // modules are found even when knowhow is installed globally.
50
+ try {
51
+ resolvedPath = require.resolve(modulePath, { paths: resolvePaths });
52
+ } catch {
53
+ resolvedPath = modulePath; // fall back to normal require resolution
54
+ }
55
+ }
51
56
 
57
+ let importedModule: KnowhowModule;
58
+ try {
59
+ const rawModule = require(resolvedPath);
60
+ importedModule = (rawModule.default || rawModule) as KnowhowModule;
61
+ context.Events?.log(
62
+ "ModulesService",
63
+ `šŸ”Œ Loading module: ${modulePath} (resolved: ${resolvedPath})`
64
+ );
65
+ await importedModule.init({
66
+ config,
67
+ cwd: process.cwd(),
68
+ context: context as ModuleContext,
69
+ });
70
+ context.Events?.log(
71
+ "ModulesService",
72
+ `āœ… Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
73
+ );
74
+ } catch (err: any) {
75
+ process.stderr.write(
76
+ `\nāš ļø Failed to load module "${modulePath}": ${err.message}\n` +
77
+ ` Run "knowhow modules setup --global" or "knowhow modules install ${modulePath} --global" to fix this.\n\n`
78
+ );
79
+ continue;
80
+ }
52
81
  // Only register tools/agents/plugins/clients if the relevant services
53
82
  // are available in context (they may not be during early CLI command registration)
54
83
  if (context.Agents) {