@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.
Files changed (28) hide show
  1. package/PROCESSING.md +21 -0
  2. package/package.json +1 -1
  3. package/src/agents/base/base.ts +4 -3
  4. package/src/processors/TokenCompressor.ts +47 -1
  5. package/src/services/MediaProcessorService.ts +219 -15
  6. package/src/services/conversion/ConversionService.ts +20 -26
  7. package/src/services/conversion/types.ts +8 -0
  8. package/src/services/modules/index.ts +11 -0
  9. package/tests/patching/regression-2026.test.ts +191 -0
  10. package/tests/processors/TokenCompressor.test.ts +66 -0
  11. package/ts_build/package.json +1 -1
  12. package/ts_build/src/agents/base/base.js +4 -3
  13. package/ts_build/src/agents/base/base.js.map +1 -1
  14. package/ts_build/src/processors/TokenCompressor.d.ts +1 -0
  15. package/ts_build/src/processors/TokenCompressor.js +24 -1
  16. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  17. package/ts_build/src/services/MediaProcessorService.d.ts +14 -1
  18. package/ts_build/src/services/MediaProcessorService.js +94 -11
  19. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  20. package/ts_build/src/services/conversion/ConversionService.js +15 -20
  21. package/ts_build/src/services/conversion/ConversionService.js.map +1 -1
  22. package/ts_build/src/services/conversion/types.d.ts +1 -0
  23. package/ts_build/src/services/modules/index.js +6 -0
  24. package/ts_build/src/services/modules/index.js.map +1 -1
  25. package/ts_build/tests/patching/regression-2026.test.js +151 -0
  26. package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
  27. package/ts_build/tests/processors/TokenCompressor.test.js +46 -0
  28. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -1001,8 +1001,9 @@ export abstract class BaseAgent implements IAgent {
1001
1001
  this.logStatus();
1002
1002
 
1003
1003
  const continuation = `<Workflow>
1004
- workflow continues until you call one of ${JSON.stringify(this.requiredToolNames)}.\n
1005
- ${statusMessage}
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 = `$${this.getTotalCostUsd().toPrecision(
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
- for (const message of modifiedMessages) {
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 = 10
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 = 10
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
- reusePreviousTranscript = true,
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
- chunkTime
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 transcription = (await transcriptions.next())
407
- ?.value as TranscriptChunk;
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
- transcription: transcription || {
411
- chunkPath: "",
412
- text: "[missing transcript]",
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 (known extensions) ────────────────────────────────
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
- inputModality: "text",
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
- return [newPath];
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
- return null;
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`