@tyvm/knowhow 0.0.125 → 0.0.127
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/PROCESSING.md +21 -0
- package/package.json +1 -1
- package/src/agents/base/base.ts +4 -3
- package/src/processors/TokenCompressor.ts +47 -1
- package/src/services/MediaProcessorService.ts +219 -15
- package/src/services/conversion/ConversionService.ts +20 -26
- package/src/services/conversion/types.ts +8 -0
- package/src/services/modules/index.ts +11 -0
- package/tests/patching/regression-2026.test.ts +191 -0
- package/tests/processors/TokenCompressor.test.ts +66 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.js +4 -3
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/processors/TokenCompressor.d.ts +1 -0
- package/ts_build/src/processors/TokenCompressor.js +24 -1
- package/ts_build/src/processors/TokenCompressor.js.map +1 -1
- package/ts_build/src/services/MediaProcessorService.d.ts +14 -1
- package/ts_build/src/services/MediaProcessorService.js +94 -11
- package/ts_build/src/services/MediaProcessorService.js.map +1 -1
- package/ts_build/src/services/conversion/ConversionService.js +15 -20
- package/ts_build/src/services/conversion/ConversionService.js.map +1 -1
- package/ts_build/src/services/conversion/types.d.ts +1 -0
- package/ts_build/src/services/modules/index.js +6 -0
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/tests/patching/regression-2026.test.js +151 -0
- package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
- package/ts_build/tests/processors/TokenCompressor.test.js +46 -0
- package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
package/PROCESSING.md
CHANGED
|
@@ -117,6 +117,27 @@ Video files are processed by:
|
|
|
117
117
|
3. Analyzing visual content of each keyframe
|
|
118
118
|
4. Combining transcript and visual analysis with timestamps
|
|
119
119
|
|
|
120
|
+
### Keyframe Extraction Interval
|
|
121
|
+
|
|
122
|
+
The `interval` parameter controls how often (in seconds) a frame is sampled from the video during keyframe extraction. It is accepted by both `streamKeyFrameExtraction()` and `extractKeyframes()` with a default of **10 seconds**.
|
|
123
|
+
|
|
124
|
+
| Value | Effect |
|
|
125
|
+
|-------|--------|
|
|
126
|
+
| `5` | Sample a frame every 5 seconds — more frames, finer detail |
|
|
127
|
+
| `10` | Default — one frame every 10 seconds, balanced coverage |
|
|
128
|
+
| `30` | One frame every 30 seconds — fewer frames, lower cost |
|
|
129
|
+
|
|
130
|
+
**Example usage (programmatic):**
|
|
131
|
+
```typescript
|
|
132
|
+
// Extract a keyframe every 5 seconds
|
|
133
|
+
await mediaProcessorService.extractKeyframes(filePath, outputPath, true, 5);
|
|
134
|
+
|
|
135
|
+
// Stream keyframes every 30 seconds
|
|
136
|
+
for await (const frame of mediaProcessorService.streamKeyFrameExtraction(filePath, videoJsonPath, true, 30)) {
|
|
137
|
+
console.log(frame);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
120
141
|
The actual output format from `convertVideoToText()` includes:
|
|
121
142
|
```
|
|
122
143
|
Chunk: (1/10):
|
package/package.json
CHANGED
package/src/agents/base/base.ts
CHANGED
|
@@ -1001,8 +1001,9 @@ export abstract class BaseAgent implements IAgent {
|
|
|
1001
1001
|
this.logStatus();
|
|
1002
1002
|
|
|
1003
1003
|
const continuation = `<Workflow>
|
|
1004
|
-
|
|
1005
|
-
|
|
1004
|
+
Task terminates after you call on of these tools: ${JSON.stringify(this.requiredToolNames)}.\n
|
|
1005
|
+
User likely only sees output from the required tool call.
|
|
1006
|
+
<TaskStatus>${statusMessage}</TaskStatus>
|
|
1006
1007
|
</Workflow>`;
|
|
1007
1008
|
|
|
1008
1009
|
messages.push({
|
|
@@ -1060,7 +1061,7 @@ export abstract class BaseAgent implements IAgent {
|
|
|
1060
1061
|
}
|
|
1061
1062
|
|
|
1062
1063
|
getStatusMessage() {
|
|
1063
|
-
const baseMessage =
|
|
1064
|
+
const baseMessage = `Task spend: $${this.getTotalCostUsd().toPrecision(
|
|
1064
1065
|
3
|
|
1065
1066
|
)}\nElapsed: ${Math.floor(this.runTime() / 1000)}s\n`;
|
|
1066
1067
|
|
|
@@ -277,14 +277,60 @@ export class TokenCompressor implements JsonCompressorStorage {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Tool names whose responses should never be compressed.
|
|
282
|
+
* These are compression/retrieval tools themselves - compressing their output
|
|
283
|
+
* would prevent the agent from seeing the uncompressed data it just retrieved.
|
|
284
|
+
*/
|
|
285
|
+
static readonly COMPRESSION_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
286
|
+
"expandTokens",
|
|
287
|
+
"grepToolResponse",
|
|
288
|
+
"jqToolResponse",
|
|
289
|
+
"tailToolResponse",
|
|
290
|
+
"listStoredToolResponses",
|
|
291
|
+
]);
|
|
292
|
+
|
|
280
293
|
createProcessor(
|
|
281
294
|
filterFn?: (msg: Message) => boolean
|
|
282
295
|
): MessageProcessorFunction {
|
|
283
296
|
return async (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
284
|
-
|
|
297
|
+
// Find the index of the last assistant message so we only compress
|
|
298
|
+
// the most recent batch of tool responses (after the last agent output).
|
|
299
|
+
// This prevents re-compressing already-cached historical messages.
|
|
300
|
+
let lastAssistantIndex = -1;
|
|
301
|
+
for (let i = modifiedMessages.length - 1; i >= 0; i--) {
|
|
302
|
+
if (modifiedMessages[i].role === "assistant") {
|
|
303
|
+
lastAssistantIndex = i;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < modifiedMessages.length; i++) {
|
|
309
|
+
const message = modifiedMessages[i];
|
|
310
|
+
|
|
285
311
|
if (filterFn && !filterFn(message)) {
|
|
286
312
|
continue;
|
|
287
313
|
}
|
|
314
|
+
|
|
315
|
+
// Only compress tool messages that are part of the last batch
|
|
316
|
+
// (i.e., they appear after the last assistant message).
|
|
317
|
+
// Messages before the last assistant message are historical and must
|
|
318
|
+
// not be modified to preserve prompt caching.
|
|
319
|
+
if (message.role === "tool" && i <= lastAssistantIndex) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Skip responses from compression-related tools — the agent called
|
|
324
|
+
// these specifically to see uncompressed data, so we must not
|
|
325
|
+
// re-compress their output.
|
|
326
|
+
if (
|
|
327
|
+
message.role === "tool" &&
|
|
328
|
+
message.name &&
|
|
329
|
+
TokenCompressor.COMPRESSION_TOOL_NAMES.has(message.name)
|
|
330
|
+
) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
288
334
|
await this.compressMessage(message);
|
|
289
335
|
}
|
|
290
336
|
};
|
|
@@ -26,6 +26,26 @@ export interface KeyframeInfo {
|
|
|
26
26
|
usd_cost?: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Options for processing a full video (transcription + keyframes).
|
|
31
|
+
* Prefer passing this object over positional args so new options can be
|
|
32
|
+
* added without changing call sites.
|
|
33
|
+
*/
|
|
34
|
+
export interface ProcessVideoOptions {
|
|
35
|
+
/** Reuse a previously cached transcript/keyframes if present. Default true. */
|
|
36
|
+
reusePreviousTranscript?: boolean;
|
|
37
|
+
/** Audio transcription chunk length in seconds. Default 30. */
|
|
38
|
+
chunkTime?: number;
|
|
39
|
+
/** Progress callback (0..1) fired during ffmpeg audio chunking. */
|
|
40
|
+
onChunkingProgress?: (fraction: number) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Seconds between sampled keyframes. When omitted, an adaptive interval is
|
|
43
|
+
* computed from the video duration (clamped 0.5s..30s), so short videos get
|
|
44
|
+
* a much denser set of frames (down to 500ms).
|
|
45
|
+
*/
|
|
46
|
+
keyframeInterval?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* MediaProcessorService handles audio/video processing using:
|
|
31
51
|
* - ffmpeg (system tool) for chunking audio/video
|
|
@@ -272,13 +292,25 @@ export class MediaProcessorService {
|
|
|
272
292
|
|
|
273
293
|
/**
|
|
274
294
|
* Extract keyframes from a video file using ffmpeg, then describe each with vision AI.
|
|
295
|
+
*
|
|
296
|
+
* @param filePath - Path to the input video file.
|
|
297
|
+
* @param videoJsonPath - Path where the resulting keyframe JSON data will be saved/cached.
|
|
298
|
+
* @param reusePreviousKeyframes - Whether to reuse previously extracted keyframes (default: true).
|
|
299
|
+
* @param interval - How often (in seconds) to sample a frame from the video (default: 10).
|
|
300
|
+
* Lower values produce more frames; higher values produce fewer.
|
|
275
301
|
*/
|
|
276
302
|
public async *streamKeyFrameExtraction(
|
|
277
303
|
filePath: string,
|
|
278
304
|
videoJsonPath: string,
|
|
279
305
|
reusePreviousKeyframes = true,
|
|
280
|
-
interval
|
|
306
|
+
interval?: number
|
|
281
307
|
): AsyncGenerator<KeyframeInfo> {
|
|
308
|
+
if (interval === undefined) {
|
|
309
|
+
const duration = await this.getMediaDuration(filePath);
|
|
310
|
+
const rawInterval = (duration / 3600) * 30;
|
|
311
|
+
interval = Math.min(30, Math.max(0.5, rawInterval));
|
|
312
|
+
}
|
|
313
|
+
|
|
282
314
|
if (reusePreviousKeyframes && fs.existsSync(videoJsonPath)) {
|
|
283
315
|
const contents = await readFile(videoJsonPath);
|
|
284
316
|
const data = JSON.parse(contents.toString()) as KeyframeInfo[];
|
|
@@ -335,11 +367,21 @@ export class MediaProcessorService {
|
|
|
335
367
|
);
|
|
336
368
|
}
|
|
337
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Extract keyframes from a video file and return them as an array.
|
|
372
|
+
*
|
|
373
|
+
* @param filePath - Path to the input video file.
|
|
374
|
+
* @param outputPath - Path where the resulting keyframe JSON data will be saved/cached.
|
|
375
|
+
* @param reusePreviousKeyframes - Whether to reuse previously extracted keyframes (default: true).
|
|
376
|
+
* @param interval - How often (in seconds) to sample a frame from the video (default: 10).
|
|
377
|
+
* Lower values produce more frames; higher values produce fewer.
|
|
378
|
+
*/
|
|
379
|
+
|
|
338
380
|
public async extractKeyframes(
|
|
339
381
|
filePath: string,
|
|
340
382
|
outputPath: string,
|
|
341
383
|
reusePreviousKeyframes = true,
|
|
342
|
-
interval
|
|
384
|
+
interval?: number
|
|
343
385
|
): Promise<KeyframeInfo[]> {
|
|
344
386
|
const keyframes: KeyframeInfo[] = [];
|
|
345
387
|
for await (const keyframe of this.streamKeyFrameExtraction(
|
|
@@ -354,6 +396,110 @@ export class MediaProcessorService {
|
|
|
354
396
|
return keyframes;
|
|
355
397
|
}
|
|
356
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Extract (and optionally describe) keyframes for a specific time slice of a
|
|
401
|
+
* video, e.g. "just the first minute". This is useful for focusing a denser
|
|
402
|
+
* frame sample on a short region without reprocessing the whole video.
|
|
403
|
+
*
|
|
404
|
+
* @param filePath Path to the input video file.
|
|
405
|
+
* @param startSec Start of the slice in seconds (inclusive).
|
|
406
|
+
* @param endSec End of the slice in seconds (exclusive).
|
|
407
|
+
* @param options.interval Seconds between sampled frames within the slice.
|
|
408
|
+
* Defaults to 1s. Clamped to a minimum of 0.5s.
|
|
409
|
+
* @param options.describe Whether to run the vision model on each frame.
|
|
410
|
+
* Defaults to true. Set false to get frames only.
|
|
411
|
+
* @param options.outputDir Directory to write extracted frames into.
|
|
412
|
+
* Defaults to <dir>/<name>/slices/<start>-<end>.
|
|
413
|
+
*/
|
|
414
|
+
public async *streamKeyFrameExtractionForRange(
|
|
415
|
+
filePath: string,
|
|
416
|
+
startSec: number,
|
|
417
|
+
endSec: number,
|
|
418
|
+
options: {
|
|
419
|
+
interval?: number;
|
|
420
|
+
describe?: boolean;
|
|
421
|
+
outputDir?: string;
|
|
422
|
+
} = {}
|
|
423
|
+
): AsyncGenerator<KeyframeInfo> {
|
|
424
|
+
const interval = Math.max(0.5, options.interval ?? 1);
|
|
425
|
+
const describe = options.describe ?? true;
|
|
426
|
+
const parsed = path.parse(filePath);
|
|
427
|
+
const outputDir =
|
|
428
|
+
options.outputDir ??
|
|
429
|
+
path.join(parsed.dir, parsed.name, "slices", `${startSec}-${endSec}`);
|
|
430
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
431
|
+
|
|
432
|
+
const duration = Math.max(0, endSec - startSec);
|
|
433
|
+
// -ss before -i seeks quickly; -t limits to the slice length.
|
|
434
|
+
const command =
|
|
435
|
+
`ffmpeg -y -ss ${startSec} -t ${duration} -i "${filePath}" ` +
|
|
436
|
+
`-vf "fps=1/${interval},scale=640:-1" "${outputDir}/frame%04d.jpg"`;
|
|
437
|
+
await execAsync(command);
|
|
438
|
+
|
|
439
|
+
const frames = (await fs.promises.readdir(outputDir))
|
|
440
|
+
.filter((f) => f.endsWith(".jpg"))
|
|
441
|
+
.sort();
|
|
442
|
+
|
|
443
|
+
for (const frame of frames) {
|
|
444
|
+
const framePath = path.join(outputDir, frame);
|
|
445
|
+
const frameNumber = parseInt(frame.match(/\d+/)?.[0] ?? "0", 10);
|
|
446
|
+
// ffmpeg names frames starting at 1; map back to absolute video time.
|
|
447
|
+
const timestamp = startSec + (frameNumber - 1) * interval;
|
|
448
|
+
|
|
449
|
+
if (!describe) {
|
|
450
|
+
yield { path: framePath, description: "", timestamp, usd_cost: 0 };
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const description = await this.describeKeyframe(framePath);
|
|
455
|
+
yield {
|
|
456
|
+
path: framePath,
|
|
457
|
+
description: description.choices[0].message.content,
|
|
458
|
+
timestamp,
|
|
459
|
+
usd_cost: description.usd_cost,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Extract an audio slice [startSec, endSec) from a media file into an mp3.
|
|
466
|
+
* Returns the path to the produced mp3, suitable for transcription or download.
|
|
467
|
+
*/
|
|
468
|
+
public async extractAudioSlice(
|
|
469
|
+
filePath: string,
|
|
470
|
+
startSec: number,
|
|
471
|
+
endSec: number,
|
|
472
|
+
outputPath?: string
|
|
473
|
+
): Promise<string> {
|
|
474
|
+
const parsed = path.parse(filePath);
|
|
475
|
+
const outPath =
|
|
476
|
+
outputPath ??
|
|
477
|
+
path.join(
|
|
478
|
+
parsed.dir,
|
|
479
|
+
parsed.name,
|
|
480
|
+
"slices",
|
|
481
|
+
`audio-${startSec}-${endSec}.mp3`
|
|
482
|
+
);
|
|
483
|
+
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
|
|
484
|
+
|
|
485
|
+
const duration = Math.max(0, endSec - startSec);
|
|
486
|
+
const command =
|
|
487
|
+
`ffmpeg -y -ss ${startSec} -t ${duration} -i "${filePath}" ` +
|
|
488
|
+
`-vn -acodec libmp3lame -ac 1 -b:a 32k "${outPath}"`;
|
|
489
|
+
await execAsync(command);
|
|
490
|
+
return outPath;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Get the duration of a media file in seconds using ffprobe.
|
|
495
|
+
*/
|
|
496
|
+
public async getMediaDuration(filePath: string): Promise<number> {
|
|
497
|
+
const output = await execAsync(
|
|
498
|
+
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`
|
|
499
|
+
);
|
|
500
|
+
return parseFloat(output.trim());
|
|
501
|
+
}
|
|
502
|
+
|
|
357
503
|
private async describeKeyframe(keyframePath: string) {
|
|
358
504
|
const question =
|
|
359
505
|
"Describe this image in detail, focusing on the main elements and actions visible.";
|
|
@@ -379,40 +525,98 @@ export class MediaProcessorService {
|
|
|
379
525
|
|
|
380
526
|
async *streamProcessVideo(
|
|
381
527
|
filePath: string,
|
|
382
|
-
|
|
528
|
+
reusePreviousTranscriptOrOptions: boolean | ProcessVideoOptions = true,
|
|
383
529
|
chunkTime = 30,
|
|
384
|
-
onChunkingProgress?: (fraction: number) => void
|
|
530
|
+
onChunkingProgress?: (fraction: number) => void,
|
|
531
|
+
keyframeInterval?: number
|
|
385
532
|
) {
|
|
533
|
+
// Support both the legacy positional signature and a new options object
|
|
534
|
+
// passed as the second argument. Prefer the options object going forward.
|
|
535
|
+
const opts: Required<Pick<ProcessVideoOptions, "reusePreviousTranscript" | "chunkTime">> &
|
|
536
|
+
ProcessVideoOptions =
|
|
537
|
+
typeof reusePreviousTranscriptOrOptions === "object"
|
|
538
|
+
? {
|
|
539
|
+
reusePreviousTranscript:
|
|
540
|
+
reusePreviousTranscriptOrOptions.reusePreviousTranscript ?? true,
|
|
541
|
+
chunkTime: reusePreviousTranscriptOrOptions.chunkTime ?? 30,
|
|
542
|
+
onChunkingProgress:
|
|
543
|
+
reusePreviousTranscriptOrOptions.onChunkingProgress,
|
|
544
|
+
keyframeInterval:
|
|
545
|
+
reusePreviousTranscriptOrOptions.keyframeInterval,
|
|
546
|
+
}
|
|
547
|
+
: {
|
|
548
|
+
reusePreviousTranscript: reusePreviousTranscriptOrOptions,
|
|
549
|
+
chunkTime,
|
|
550
|
+
onChunkingProgress,
|
|
551
|
+
keyframeInterval,
|
|
552
|
+
};
|
|
553
|
+
|
|
386
554
|
const parsed = path.parse(filePath);
|
|
387
555
|
const videoJson = `${parsed.dir}/${parsed.name}/video.json`;
|
|
388
556
|
|
|
389
557
|
console.log("Processing audio...");
|
|
390
558
|
const transcriptions = this.streamProcessAudio(
|
|
391
559
|
filePath,
|
|
392
|
-
reusePreviousTranscript,
|
|
393
|
-
chunkTime,
|
|
394
|
-
onChunkingProgress
|
|
560
|
+
opts.reusePreviousTranscript,
|
|
561
|
+
opts.chunkTime,
|
|
562
|
+
opts.onChunkingProgress
|
|
395
563
|
);
|
|
396
564
|
|
|
397
565
|
console.log("Extracting keyframes...");
|
|
566
|
+
// When keyframeInterval is undefined, streamKeyFrameExtraction computes an
|
|
567
|
+
// adaptive interval based on video duration, clamped between 0.5s and 30s.
|
|
568
|
+
// This gives short videos a much denser set of frames (down to 500ms) while
|
|
569
|
+
// keeping long videos at a reasonable frame count. Previously this passed
|
|
570
|
+
// chunkTime (30s), which meant every video only got 1 frame per 30s.
|
|
398
571
|
const videoAnalysis = this.streamKeyFrameExtraction(
|
|
399
572
|
filePath,
|
|
400
573
|
videoJson,
|
|
401
|
-
reusePreviousTranscript,
|
|
402
|
-
|
|
574
|
+
opts.reusePreviousTranscript,
|
|
575
|
+
opts.keyframeInterval
|
|
403
576
|
);
|
|
404
577
|
|
|
578
|
+
// Frames and transcript chunks can arrive at different cadences: with an
|
|
579
|
+
// adaptive keyframe interval a short video may have many more frames than
|
|
580
|
+
// 30s transcript chunks. Rather than a 1:1 positional zip (which would
|
|
581
|
+
// strand most frames with "[missing transcript]" and drop transcripts),
|
|
582
|
+
// we map each frame to the transcript chunk that covers its timestamp.
|
|
583
|
+
// Transcript chunk N covers [N*chunkTime, (N+1)*chunkTime).
|
|
584
|
+
const transcriptBuffer: TranscriptChunk[] = [];
|
|
585
|
+
const missingTranscript: TranscriptChunk = {
|
|
586
|
+
chunkPath: "",
|
|
587
|
+
text: "[missing transcript]",
|
|
588
|
+
usd_cost: 0,
|
|
589
|
+
};
|
|
590
|
+
|
|
405
591
|
for await (const frame of videoAnalysis) {
|
|
406
|
-
const
|
|
407
|
-
|
|
592
|
+
const chunkIndex = Math.floor((frame.timestamp || 0) / opts.chunkTime);
|
|
593
|
+
|
|
594
|
+
// Pull transcript chunks until we have one that covers this frame's window.
|
|
595
|
+
while (transcriptBuffer.length <= chunkIndex) {
|
|
596
|
+
const next = await transcriptions.next();
|
|
597
|
+
if (next.done) break;
|
|
598
|
+
transcriptBuffer.push(next.value as TranscriptChunk);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const transcription = transcriptBuffer[chunkIndex] || missingTranscript;
|
|
602
|
+
yield { frame, transcription };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Drain any remaining transcript chunks (e.g. audio longer than the last
|
|
606
|
+
// frame's window) so their content/cost isn't silently dropped.
|
|
607
|
+
let remaining = await transcriptions.next();
|
|
608
|
+
while (!remaining.done) {
|
|
408
609
|
yield {
|
|
409
|
-
frame
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
610
|
+
frame: {
|
|
611
|
+
path: "",
|
|
612
|
+
description: "",
|
|
613
|
+
timestamp: transcriptBuffer.length * opts.chunkTime,
|
|
413
614
|
usd_cost: 0,
|
|
414
615
|
},
|
|
616
|
+
transcription: remaining.value as TranscriptChunk,
|
|
415
617
|
};
|
|
618
|
+
transcriptBuffer.push(remaining.value as TranscriptChunk);
|
|
619
|
+
remaining = await transcriptions.next();
|
|
416
620
|
}
|
|
417
621
|
}
|
|
418
622
|
|
|
@@ -151,21 +151,10 @@ export class ConversionService {
|
|
|
151
151
|
},
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
// ── text passthrough (
|
|
155
|
-
this.register({
|
|
156
|
-
name: "text-passthrough",
|
|
157
|
-
inputExts: ["txt", "md", "json", "yaml", "yml", "csv", "xml", "html", "htm", "js", "ts", "py", "rb", "sh", "text"],
|
|
158
|
-
outputType: "text",
|
|
159
|
-
async convert(input, _ctx): Promise<ConvertResult> {
|
|
160
|
-
const text = fs.readFileSync(input.filePath, "utf8");
|
|
161
|
-
return { outputType: "text", text };
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ── text passthrough fallback ───────────────────────────────────────────
|
|
154
|
+
// ── text passthrough (catch-all: any file is assumed to be readable as text) ─
|
|
166
155
|
this.register({
|
|
167
156
|
name: "text-passthrough-fallback",
|
|
168
|
-
|
|
157
|
+
catchAll: true,
|
|
169
158
|
outputType: "text",
|
|
170
159
|
async convert(input, _ctx): Promise<ConvertResult> {
|
|
171
160
|
const text = fs.readFileSync(input.filePath, "utf8");
|
|
@@ -467,16 +456,7 @@ export class ConversionService {
|
|
|
467
456
|
const queue: State[] = [{ node: inputNode, path: [] }];
|
|
468
457
|
const visited = new Set<string>();
|
|
469
458
|
visited.add(inputNode);
|
|
470
|
-
|
|
471
|
-
const knownModalities: Modality[] = ["text", "html", "image", "audio", "video"];
|
|
472
|
-
const isKnownModality = knownModalities.includes(inputNode as Modality);
|
|
473
|
-
const hasDirectMatch = this.converters.some(
|
|
474
|
-
(c) => c.inputExts?.includes(inputNode) || c.inputModality === inputNode
|
|
475
|
-
);
|
|
476
|
-
if (!isKnownModality && !hasDirectMatch && inputNode !== "text") {
|
|
477
|
-
queue.push({ node: "text", path: [] });
|
|
478
|
-
visited.add("text");
|
|
479
|
-
}
|
|
459
|
+
let catchAllPath: Converter[] | null = null;
|
|
480
460
|
|
|
481
461
|
while (queue.length > 0) {
|
|
482
462
|
const { node, path } = queue.shift()!;
|
|
@@ -484,13 +464,21 @@ export class ConversionService {
|
|
|
484
464
|
const candidates = this.converters.filter((c) => {
|
|
485
465
|
if (c.inputExts && c.inputExts.includes(node)) return true;
|
|
486
466
|
if (c.inputModality && c.inputModality === node) return true;
|
|
467
|
+
// catch-all converters match any node (only at the start node, not intermediate)
|
|
468
|
+
if (c.catchAll && node === inputNode) return true;
|
|
487
469
|
return false;
|
|
488
470
|
});
|
|
489
471
|
|
|
490
472
|
for (const converter of candidates) {
|
|
491
473
|
const newPath = [...path, converter];
|
|
492
474
|
if (converter.outputType === targetType) {
|
|
493
|
-
|
|
475
|
+
if (converter.catchAll) {
|
|
476
|
+
// Defer catch-all paths — only use if no specific path is found
|
|
477
|
+
if (!catchAllPath) catchAllPath = newPath;
|
|
478
|
+
} else {
|
|
479
|
+
return [newPath];
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
494
482
|
}
|
|
495
483
|
if (!visited.has(converter.outputType)) {
|
|
496
484
|
visited.add(converter.outputType);
|
|
@@ -499,7 +487,8 @@ export class ConversionService {
|
|
|
499
487
|
}
|
|
500
488
|
}
|
|
501
489
|
|
|
502
|
-
|
|
490
|
+
// Fall back to catch-all path if no specific path was found
|
|
491
|
+
return catchAllPath ? [catchAllPath] : null;
|
|
503
492
|
}
|
|
504
493
|
|
|
505
494
|
/**
|
|
@@ -742,10 +731,15 @@ export class ConversionService {
|
|
|
742
731
|
return inputMatch && c.outputType === outputType;
|
|
743
732
|
});
|
|
744
733
|
|
|
734
|
+
// Append catch-all converters as last-resort fallbacks (only if output type matches)
|
|
735
|
+
const catchAlls = this.converters.filter(
|
|
736
|
+
(c) => c.catchAll && c.outputType === outputType && !matching.includes(c)
|
|
737
|
+
);
|
|
738
|
+
|
|
745
739
|
const preferred: Converter[] = [];
|
|
746
740
|
const rest: Converter[] = [];
|
|
747
741
|
|
|
748
|
-
for (const c of matching) {
|
|
742
|
+
for (const c of [...matching, ...catchAlls]) {
|
|
749
743
|
if (preferredNames.includes(c.name)) {
|
|
750
744
|
preferred.push(c);
|
|
751
745
|
} else {
|
|
@@ -41,6 +41,14 @@ export interface Converter {
|
|
|
41
41
|
name: string; // unique
|
|
42
42
|
inputExts?: string[]; // e.g. ["pdf"]
|
|
43
43
|
inputModality?: Modality; // OR an input modality (e.g. "image")
|
|
44
|
+
/**
|
|
45
|
+
* If true, this converter is used as a last-resort fallback for any input
|
|
46
|
+
* type that has no other converter registered. Catch-all converters are
|
|
47
|
+
* appended after all specific matches in buildAlternatives, and are also
|
|
48
|
+
* added as BFS nodes so they can be discovered even for unknown extensions.
|
|
49
|
+
* Use sparingly — only for truly generic converters like text-passthrough.
|
|
50
|
+
*/
|
|
51
|
+
catchAll?: boolean;
|
|
44
52
|
outputType: Modality;
|
|
45
53
|
/**
|
|
46
54
|
* If true, the ConversionService will cache the result of this converter to
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
2
|
import * as os from "os";
|
|
3
|
+
import * as Module from "module";
|
|
3
4
|
|
|
4
5
|
import { getConfig, getGlobalConfig } from "../../config";
|
|
5
6
|
import { KnowhowModule, ModuleContext } from "./types";
|
|
@@ -25,6 +26,16 @@ export class ModulesService {
|
|
|
25
26
|
context = { ...(await this.getDefaultContext()) };
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// Prepend the global knowhow install's own node_modules to Node's global module
|
|
30
|
+
// search paths so that locally-installed modules (loaded from .knowhow/node_modules)
|
|
31
|
+
// that depend on @tyvm/knowhow resolve to the current global version rather than
|
|
32
|
+
// a stale bundled copy.
|
|
33
|
+
const globalKnowhowNodeModules = path.join(__dirname, "../../../../node_modules");
|
|
34
|
+
const globalPaths = (Module as any).globalPaths as string[];
|
|
35
|
+
if (globalPaths && !globalPaths.includes(globalKnowhowNodeModules)) {
|
|
36
|
+
globalPaths.unshift(globalKnowhowNodeModules);
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
const allModulePaths = config.modules;
|
|
29
40
|
|
|
30
41
|
// Search paths: local .knowhow/node_modules first (where `knowhow modules install`
|