@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.
- package/autodoc/README.md +324 -0
- package/autodoc/chat-guide.md +268 -365
- package/autodoc/cli-reference.md +399 -473
- package/autodoc/config-reference.md +431 -330
- package/autodoc/embeddings-guide.md +223 -322
- package/autodoc/generate-guide.md +261 -301
- package/autodoc/language-plugin-guide.md +221 -247
- package/autodoc/modules-guide.md +242 -215
- package/autodoc/plugins-guide.md +470 -469
- package/autodoc/quickstart-guide.md +67 -70
- package/autodoc/skills-guide.md +455 -339
- package/autodoc/worker-guide.md +301 -308
- package/package.json +1 -1
- package/scripts/build-for-node.sh +10 -24
- package/src/agents/tools/list.ts +2 -2
- package/src/ai.ts +81 -37
- package/src/chat/CliChatService.ts +1 -1
- package/src/chat/modules/AgentModule.ts +7 -2
- package/src/chat/modules/SessionsModule.ts +40 -1
- package/src/chat/modules/SystemModule.ts +2 -2
- package/src/clients/anthropic.ts +1 -1
- package/src/clients/index.ts +25 -6
- package/src/clients/openai.ts +8 -5
- package/src/clients/types.ts +29 -6
- package/src/clients/withRetry.ts +89 -0
- package/src/commands/agent.ts +30 -0
- package/src/commands/modules.ts +417 -47
- package/src/config.ts +1 -1
- package/src/fileSync.ts +20 -12
- package/src/hashes.ts +43 -22
- package/src/index.ts +4 -2
- package/src/processors/Base64ImageDetector.ts +73 -0
- package/src/services/MediaProcessorService.ts +79 -10
- package/src/services/modules/index.ts +47 -18
- package/tests/processors/Base64ImageDetector.test.ts +160 -0
- package/tests/unit/clients/AIClient.test.ts +446 -0
- package/tests/unit/clients/withRetry.test.ts +319 -0
- package/tests/unit/commands/github-credentials.test.ts +1 -2
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/tools/list.js +2 -2
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/ai.d.ts +3 -3
- package/ts_build/src/ai.js +51 -23
- package/ts_build/src/ai.js.map +1 -1
- package/ts_build/src/chat/CliChatService.js +1 -1
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +5 -2
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/SessionsModule.js +30 -1
- package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
- package/ts_build/src/chat/modules/SystemModule.js +2 -2
- package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
- package/ts_build/src/clients/anthropic.js +1 -1
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/index.js +7 -6
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.js +4 -4
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/types.d.ts +12 -6
- package/ts_build/src/clients/withRetry.d.ts +2 -0
- package/ts_build/src/clients/withRetry.js +60 -0
- package/ts_build/src/clients/withRetry.js.map +1 -0
- package/ts_build/src/commands/agent.js +25 -0
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/modules.js +359 -32
- package/ts_build/src/commands/modules.js.map +1 -1
- package/ts_build/src/config.js +1 -1
- package/ts_build/src/config.js.map +1 -1
- package/ts_build/src/fileSync.d.ts +2 -2
- package/ts_build/src/fileSync.js +13 -11
- package/ts_build/src/fileSync.js.map +1 -1
- package/ts_build/src/hashes.d.ts +2 -2
- package/ts_build/src/hashes.js +40 -16
- package/ts_build/src/hashes.js.map +1 -1
- package/ts_build/src/index.js +1 -1
- package/ts_build/src/index.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.d.ts +3 -0
- package/ts_build/src/processors/Base64ImageDetector.js +42 -0
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/services/MediaProcessorService.d.ts +5 -4
- package/ts_build/src/services/MediaProcessorService.js +53 -8
- package/ts_build/src/services/MediaProcessorService.js.map +1 -1
- package/ts_build/src/services/modules/index.js +35 -12
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +111 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
- package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
- package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
- package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
- 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
|
-
|
|
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
|
|
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
|
|
4
|
+
import { readFile } from "./utils";
|
|
5
5
|
import { convertToText } from "./conversion";
|
|
6
6
|
|
|
7
7
|
export async function getHashes() {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 (
|
|
46
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
124
|
-
*
|
|
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
|
|
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
|
|
190
|
-
if (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:
|
|
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
|
-
|
|
76
|
-
|
|
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:
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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) {
|