@wirecat/cli-core 0.498.0 → 0.500.0

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 (49) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/messenger/hearing-command.js +1 -1
  4. package/dist/cli/messenger/models-command.js +2 -2
  5. package/dist/cli/messenger/transcribe-command.js +14 -10
  6. package/dist/cli/runs/command.js +2 -1
  7. package/dist/domain/knowledge.d.ts +1 -1
  8. package/dist/domain/knowledge.js +1 -1
  9. package/dist/entities/fact.d.ts +15 -1
  10. package/dist/entities/fact.js +6 -1
  11. package/dist/entities/fields.d.ts +11 -0
  12. package/dist/entities/fields.js +11 -0
  13. package/dist/entities/index.d.ts +25 -1
  14. package/dist/entities/memory.d.ts +14 -0
  15. package/dist/entities/memory.js +6 -1
  16. package/dist/errors/reasons.d.ts +1 -1
  17. package/dist/errors/reasons.js +8 -0
  18. package/dist/meetings/transcription.d.ts +5 -0
  19. package/dist/meetings/transcription.js +27 -6
  20. package/dist/speech/decode.d.ts +11 -1
  21. package/dist/speech/decode.js +20 -15
  22. package/dist/speech/hearing.d.ts +3 -1
  23. package/dist/speech/hearing.js +4 -4
  24. package/dist/speech/index.d.ts +5 -2
  25. package/dist/speech/index.js +4 -2
  26. package/dist/speech/local.d.ts +8 -0
  27. package/dist/speech/local.js +22 -1
  28. package/dist/speech/models.d.ts +2 -0
  29. package/dist/speech/models.js +2 -0
  30. package/dist/speech/openai.d.ts +54 -0
  31. package/dist/speech/openai.js +199 -0
  32. package/dist/speech/recognize.d.ts +25 -1
  33. package/dist/speech/recognize.js +52 -21
  34. package/dist/speech/transcribe.d.ts +25 -9
  35. package/dist/speech/transcribe.js +72 -13
  36. package/dist/speech/wav.d.ts +2 -0
  37. package/dist/speech/wav.js +20 -0
  38. package/dist/store/index.d.ts +1 -0
  39. package/dist/store/project-transfer/request.d.ts +1 -1
  40. package/dist/store/project-transfer/schema.d.ts +2 -2
  41. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  42. package/dist/store/sqlite/evidence-sources.d.ts +23 -0
  43. package/dist/store/sqlite/evidence-sources.js +59 -0
  44. package/dist/store/sqlite/facts.d.ts +16 -0
  45. package/dist/store/sqlite/facts.js +39 -5
  46. package/dist/store/sqlite/memories.d.ts +28 -0
  47. package/dist/store/sqlite/memories.js +62 -3
  48. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  49. package/package.json +1 -1
@@ -11,6 +11,8 @@ const identifier = (value) => {
11
11
  throw new CliError("validation_error", "transcription identifiers must be bounded nonempty strings");
12
12
  return value;
13
13
  };
14
+ /** Recognized off this machine: the stored source names the provider rather than `local/`. */
15
+ const REMOTE_ENGINES = new Set(["openai"]);
14
16
  const cancelled = (signal) => {
15
17
  if (signal?.aborted) {
16
18
  if (signal.reason instanceof CliError)
@@ -64,21 +66,33 @@ export const transcribeMeeting = async (source, store, target, options = {}) =>
64
66
  const outcome = await dispatchMeetingMutation(mutation, async () => {
65
67
  const rows = [];
66
68
  let textBytes = 0;
67
- let end = 0;
69
+ let start = 0;
70
+ const ends = new Map();
68
71
  let provided = 0;
69
72
  for await (const segment of source.segments(media, context)) {
70
73
  cancelled(context.signal);
71
74
  provided++;
75
+ const given = segment.speaker ?? undefined;
76
+ if (given !== undefined &&
77
+ (typeof given !== "string" || !given.trim() || given.length > 200 || /[\0\r\n]/.test(given)))
78
+ throw new CliError("invalid_response", "invalid recognition segment speaker");
79
+ const speaker = given?.trim();
80
+ // Without speakers, nothing may overlap; with them, only one speaker's own segments may not.
81
+ const overlapping = speaker === undefined
82
+ ? [...ends.values()].reduce((latest, end) => Math.max(latest, end), 0)
83
+ : Math.max(ends.get(undefined) ?? 0, ends.get(speaker) ?? 0);
72
84
  if (provided > maxSegments ||
73
85
  !Number.isSafeInteger(segment.startMs) ||
74
86
  !Number.isSafeInteger(segment.endMs) ||
75
- segment.startMs < end ||
87
+ segment.startMs < start ||
88
+ segment.startMs < overlapping ||
76
89
  segment.endMs <= segment.startMs ||
77
90
  segment.endMs > media.durationMs ||
78
91
  typeof segment.text !== "string" ||
79
92
  segment.text.includes("\0"))
80
93
  throw new CliError("invalid_response", "invalid or excessive recognition segments");
81
- end = segment.endMs;
94
+ start = segment.startMs;
95
+ ends.set(speaker, Math.max(ends.get(speaker) ?? 0, segment.endMs));
82
96
  const text = segment.text.trim();
83
97
  if (text.length > maxTextBytes - textBytes)
84
98
  throw new CliError("validation_error", "transcript exceeds its byte limit");
@@ -90,7 +104,7 @@ export const transcribeMeeting = async (source, store, target, options = {}) =>
90
104
  position: rows.length,
91
105
  startMs: segment.startMs,
92
106
  endMs: segment.endMs,
93
- speakerName: null,
107
+ speakerName: speaker ?? null,
94
108
  text,
95
109
  normalizedText: null,
96
110
  metadata: null,
@@ -114,8 +128,15 @@ export const transcribeMeeting = async (source, store, target, options = {}) =>
114
128
  now,
115
129
  transcripts: [
116
130
  {
117
- source: `local/${encodeURIComponent(media.recognition.engine)}/${encodeURIComponent(media.recognition.model)}`,
118
- format: "local-transcription",
131
+ ...(REMOTE_ENGINES.has(media.recognition.engine)
132
+ ? {
133
+ source: `${encodeURIComponent(media.recognition.engine)}/${encodeURIComponent(media.recognition.model)}`,
134
+ format: `${media.recognition.engine}-transcription`,
135
+ }
136
+ : {
137
+ source: `local/${encodeURIComponent(media.recognition.engine)}/${encodeURIComponent(media.recognition.model)}`,
138
+ format: "local-transcription",
139
+ }),
119
140
  language: null,
120
141
  contentHash: hash,
121
142
  externalCreatedAt: null,
@@ -13,9 +13,19 @@ export declare const formatOf: (bytes: Uint8Array) => Format;
13
13
  * the GPL. From a file rather than a pipe: an MP4 whose index sits at the end cannot be read from a pipe.
14
14
  */
15
15
  export declare const runFfmpeg: Ffmpeg;
16
+ export interface DecodedChannels {
17
+ channels: Pcm[];
18
+ /** True when ffmpeg read the file: whatever channels it had arrive mixed into one, so one speaker per channel is lost. */
19
+ mixed: boolean;
20
+ }
21
+ /**
22
+ * Every channel the file has, one `Pcm` each: a phone call recorded in stereo keeps one speaker per channel.
23
+ * Only the bundled decoders keep channels apart; what ffmpeg reads arrives mixed down to one, and says so.
24
+ */
25
+ export declare const decodeChannels: (bytes: Uint8Array, ffmpeg?: Ffmpeg) => Promise<DecodedChannels>;
16
26
  /**
17
27
  * Opus, Vorbis, FLAC and MP3 by the bundled decoders; anything else, or what one of them could not read, by ffmpeg
18
- * when the machine has it. The bundled decoder's error wins: it names the file's own problem.
28
+ * when the machine has it. The bundled decoder's error wins: it names the file's own problem. The first channel only.
19
29
  */
20
30
  export declare const decodeAudio: (bytes: Uint8Array, ffmpeg?: Ffmpeg) => Promise<Pcm>;
21
31
  export {};
@@ -33,11 +33,10 @@ export const formatOf = (bytes) => {
33
33
  return "mp3";
34
34
  return "other";
35
35
  };
36
- const firstChannel = (decoded) => {
37
- const samples = decoded.channelData[0];
38
- if (!samples || decoded.samplesDecoded === 0)
36
+ const everyChannel = (decoded) => {
37
+ if (!decoded.channelData[0] || decoded.samplesDecoded === 0)
39
38
  throw new CliError("validation_error", "no audio in that file");
40
- return { samples, rate: decoded.sampleRate };
39
+ return decoded.channelData.map((samples) => ({ samples, rate: decoded.sampleRate }));
41
40
  };
42
41
  /**
43
42
  * Found from cli-core's own place and imported by URL, so a CLI's bundler leaves the decoder whole: bundled, it looks
@@ -49,7 +48,7 @@ const decodeVorbis = async (bytes) => {
49
48
  const decoder = new OggVorbisDecoder();
50
49
  await decoder.ready;
51
50
  try {
52
- return firstChannel(await decoder.decodeFile(bytes));
51
+ return everyChannel(await decoder.decodeFile(bytes));
53
52
  }
54
53
  finally {
55
54
  decoder.free();
@@ -60,7 +59,7 @@ const decodeFlac = async (bytes) => {
60
59
  const decoder = new FLACDecoder();
61
60
  await decoder.ready;
62
61
  try {
63
- return firstChannel(await decoder.decodeFile(bytes));
62
+ return everyChannel(await decoder.decodeFile(bytes));
64
63
  }
65
64
  finally {
66
65
  decoder.free();
@@ -71,7 +70,7 @@ const decodeMp3 = async (bytes) => {
71
70
  const decoder = new MPEGDecoder();
72
71
  await decoder.ready;
73
72
  try {
74
- return firstChannel(decoder.decode(bytes));
73
+ return everyChannel(decoder.decode(bytes));
75
74
  }
76
75
  finally {
77
76
  decoder.free();
@@ -147,18 +146,24 @@ const BUNDLED = {
147
146
  flac: decodeFlac,
148
147
  mp3: decodeMp3,
149
148
  };
149
+ const tooLong = (pcm) => pcm.samples.length / pcm.rate > LONGEST_AUDIO_SECONDS;
150
150
  /**
151
- * Opus, Vorbis, FLAC and MP3 by the bundled decoders; anything else, or what one of them could not read, by ffmpeg
152
- * when the machine has it. The bundled decoder's error wins: it names the file's own problem.
151
+ * Every channel the file has, one `Pcm` each: a phone call recorded in stereo keeps one speaker per channel.
152
+ * Only the bundled decoders keep channels apart; what ffmpeg reads arrives mixed down to one, and says so.
153
153
  */
154
- export const decodeAudio = async (bytes, ffmpeg = runFfmpeg) => {
154
+ export const decodeChannels = async (bytes, ffmpeg = runFfmpeg) => {
155
155
  const format = formatOf(bytes);
156
- const pcm = format === "other"
157
- ? await ffmpeg(bytes)
158
- : await BUNDLED[format](bytes).catch((error) => ffmpeg(bytes).catch(() => {
156
+ const decoded = format === "other"
157
+ ? { channels: [await ffmpeg(bytes)], mixed: true }
158
+ : await BUNDLED[format](bytes).then((channels) => ({ channels, mixed: false }), (error) => ffmpeg(bytes).then((pcm) => ({ channels: [pcm], mixed: true }), () => {
159
159
  throw error;
160
160
  }));
161
- if (pcm.samples.length / pcm.rate > LONGEST_AUDIO_SECONDS)
161
+ if (decoded.channels.some(tooLong))
162
162
  throw new CliError("validation_error", `the audio is longer than ${LONGEST_AUDIO_SECONDS / 60} minutes`);
163
- return pcm;
163
+ return decoded;
164
164
  };
165
+ /**
166
+ * Opus, Vorbis, FLAC and MP3 by the bundled decoders; anything else, or what one of them could not read, by ffmpeg
167
+ * when the machine has it. The bundled decoder's error wins: it names the file's own problem. The first channel only.
168
+ */
169
+ export const decodeAudio = async (bytes, ffmpeg = runFfmpeg) => (await decodeChannels(bytes, ffmpeg)).channels[0];
@@ -1,3 +1,4 @@
1
+ import { type KeyringStore } from "@wirecat/cli-base";
1
2
  import type { Missing } from "@wirecat/cli-base/standards";
2
3
  import type { Messenger } from "../cli/messenger/context.js";
3
4
  import type { Settings } from "../cli/settings.js";
@@ -60,9 +61,10 @@ export declare const spoken: <T extends Message & {
60
61
  transcript?: string;
61
62
  }>({ transcript, ...message }: T) => Message;
62
63
  /** For an MCP tool: the same hearing, over the session's own connection. */
63
- export declare const hearForTool: (messenger: Messenger, connect: <T>(work: (adapter: MessengerAdapter) => Promise<T>) => Promise<T>, messages: readonly Message[], transcribe: boolean, { settings, env }: {
64
+ export declare const hearForTool: (messenger: Messenger, connect: <T>(work: (adapter: MessengerAdapter) => Promise<T>) => Promise<T>, messages: readonly Message[], transcribe: boolean, { settings, env, keyring, }: {
64
65
  settings: Pick<Settings, "configured" | "shared" | "profile">;
65
66
  env: NodeJS.ProcessEnv;
67
+ keyring?: KeyringStore;
66
68
  }, model?: string) => Promise<Hearing | undefined>;
67
69
  /** Messages with their transcripts; `unheard` as well when `--transcribe` was asked for. */
68
70
  export declare const heard: <T extends Message>(messages: readonly T[], hearing: Hearing | undefined) => T[];
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { recalledAccount } from "../cli/messenger/accounts.js";
3
3
  import { openStore } from "../store/store.js";
4
- import { choose, hearLocally, hearOnline, isAudioFile } from "./transcribe.js";
4
+ import { choose, hearLocally, hearOnline, isAudioFile, keptSource } from "./transcribe.js";
5
5
  /** `--model` chooses who hears what `--transcribe` asks for; alone it would quietly do nothing. */
6
6
  export const modelWith = (transcribe, model) => {
7
7
  if (model !== undefined && transcribe !== true) {
@@ -73,7 +73,7 @@ export const hearVoices = async (messenger, messages, kept, transcribe) => {
73
73
  if (heard.pending || heard.text.trim() === "")
74
74
  continue;
75
75
  transcripts.set(keyOf(voice), heard.text);
76
- await kept.keep(voice, heard.text, heard.model ?? heard.via);
76
+ await kept.keep(voice, heard.text, keptSource(heard));
77
77
  }
78
78
  catch (error) {
79
79
  problem ??= error instanceof Error ? error.message : String(error);
@@ -92,14 +92,14 @@ export const spoken = ({ transcript, ...message }) => transcript === undefined
92
92
  ? message
93
93
  : { ...message, text: [message.text, `🎤 ${transcript}`].filter(Boolean).join("\n") };
94
94
  /** For an MCP tool: the same hearing, over the session's own connection. */
95
- export const hearForTool = async (messenger, connect, messages, transcribe, { settings, env }, model) => {
95
+ export const hearForTool = async (messenger, connect, messages, transcribe, { settings, env, keyring, }, model) => {
96
96
  if (!messages.some(hasAudio))
97
97
  return undefined;
98
98
  const kept = await openKept(messenger, settings.profile, env);
99
99
  try {
100
100
  return await hearVoices(messenger, messages, kept, transcribe
101
101
  ? {
102
- choice: choose(messenger, settings, model === undefined ? {} : { model }, env),
102
+ choice: choose(messenger, settings, model === undefined ? {} : { model }, env, keyring),
103
103
  connect,
104
104
  }
105
105
  : undefined);
@@ -1,3 +1,6 @@
1
+ export { decodeAudio, decodeChannels, type Ffmpeg, type Pcm, runFfmpeg, } from "./decode.js";
1
2
  export { type Fetch, install, installedBytes, isInstalled, megabytes, modelPath, modelsDirectory, sharedModelsDirectory, vadPath, } from "./install.js";
2
- export { type LocalSpeechBackend, type LocalSpeechRecognitionModel, type LocalSpeechRecognizer, type LocalSpeechRecognizerOptions, localSpeechRecognitionModel, openLocalSpeechRecognizer, } from "./local.js";
3
- export { DEFAULT_ORDER, findModel, MODELS, type ModelFile, orderedModels, type SpeechModel, speechModel, VAD, } from "./models.js";
3
+ export { type LocalSpeechBackend, type LocalSpeechRecognitionModel, type LocalSpeechRecognizer, type LocalSpeechRecognizerOptions, localSpeechRecognitionModel, openLocalSpeechRecognizer, recognizeLocalPieces, } from "./local.js";
4
+ export { DEFAULT_ORDER, findModel, MODELS, type ModelFile, OPENAI_TRANSCRIBE_MODEL, orderedModels, type SpeechModel, speechModel, VAD, } from "./models.js";
5
+ export { LONGEST_REQUEST_SECONDS, type OpenAITranscription, transcribeOpenAI } from "./openai.js";
6
+ export type { TimedText } from "./recognize.js";
@@ -1,3 +1,5 @@
1
+ export { decodeAudio, decodeChannels, runFfmpeg, } from "./decode.js";
1
2
  export { install, installedBytes, isInstalled, megabytes, modelPath, modelsDirectory, sharedModelsDirectory, vadPath, } from "./install.js";
2
- export { localSpeechRecognitionModel, openLocalSpeechRecognizer, } from "./local.js";
3
- export { DEFAULT_ORDER, findModel, MODELS, orderedModels, speechModel, VAD, } from "./models.js";
3
+ export { localSpeechRecognitionModel, openLocalSpeechRecognizer, recognizeLocalPieces, } from "./local.js";
4
+ export { DEFAULT_ORDER, findModel, MODELS, OPENAI_TRANSCRIBE_MODEL, orderedModels, speechModel, VAD, } from "./models.js";
5
+ export { LONGEST_REQUEST_SECONDS, transcribeOpenAI } from "./openai.js";
@@ -1,4 +1,6 @@
1
+ import type { Pcm } from "./decode.js";
1
2
  import { type SpeechModel } from "./models.js";
3
+ import type { TimedText } from "./recognize.js";
2
4
  export interface LocalSpeechRecognitionModel {
3
5
  engine: "sherpa-onnx";
4
6
  model: string;
@@ -8,6 +10,7 @@ export interface LocalSpeechRecognitionModel {
8
10
  }
9
11
  export interface LocalSpeechBackend {
10
12
  recognize(pcm: Float32Array): string;
13
+ pieces?(pcm: Float32Array): TimedText[];
11
14
  free(): void;
12
15
  }
13
16
  export interface LocalSpeechRecognizer extends LocalSpeechRecognitionModel {
@@ -25,5 +28,10 @@ export interface LocalSpeechRecognizerOptions {
25
28
  }
26
29
  /** A pinned registry descriptor; no model files, credentials or network are accessed. */
27
30
  export declare const localSpeechRecognitionModel: (id: string) => Readonly<LocalSpeechRecognitionModel>;
31
+ /**
32
+ * A whole recording, one channel of it, cut at pauses by the voice detector: each piece's text with where it lies,
33
+ * in seconds. Two channels heard this way merge into a dialogue by `start`. Existing installed models only.
34
+ */
35
+ export declare const recognizeLocalPieces: (id: string, audio: Pcm, options?: Pick<LocalSpeechRecognizerOptions, "directory" | "env" | "open">) => Promise<TimedText[]>;
28
36
  /** Existing installed models only. Synchronous inference cancels between bounded windows. */
29
37
  export declare const openLocalSpeechRecognizer: (id: string, input?: LocalSpeechRecognizerOptions) => Promise<LocalSpeechRecognizer>;
@@ -25,6 +25,27 @@ export const localSpeechRecognitionModel = (id) => {
25
25
  .digest("hex");
26
26
  return Object.freeze({ engine: "sherpa-onnx", model: model.id, version, sampleRate: 16000, maxChunkSamples: 480000 });
27
27
  };
28
+ const notInstalled = (id) => new CliError("not_found", `speech model ${id} is not installed; download it explicitly with a WireCat messenger's models download command`, { reason: "model.not-downloaded", model: id });
29
+ /**
30
+ * A whole recording, one channel of it, cut at pauses by the voice detector: each piece's text with where it lies,
31
+ * in seconds. Two channels heard this way merge into a dialogue by `start`. Existing installed models only.
32
+ */
33
+ export const recognizeLocalPieces = async (id, audio, options = {}) => {
34
+ const model = speechModel(id);
35
+ const directory = options.directory ?? modelsDirectory(options.env);
36
+ if (!isInstalled(model, directory))
37
+ throw notInstalled(model.id);
38
+ const { openRecognizer, toModelRate } = await import("./recognize.js");
39
+ const engine = (options.open ?? openRecognizer)(model, modelPath(directory, model), vadPath(directory));
40
+ try {
41
+ if (!engine.pieces)
42
+ throw new CliError("configuration_error", "this speech backend gives no timed pieces");
43
+ return engine.pieces(toModelRate(audio.samples, audio.rate));
44
+ }
45
+ finally {
46
+ engine.free();
47
+ }
48
+ };
28
49
  /** Existing installed models only. Synchronous inference cancels between bounded windows. */
29
50
  export const openLocalSpeechRecognizer = async (id, input = {}) => {
30
51
  const options = { ...input };
@@ -41,7 +62,7 @@ export const openLocalSpeechRecognizer = async (id, input = {}) => {
41
62
  throw new CliError("validation_error", "local speech requires an absolute model directory and bounded output bytes");
42
63
  cancelled(options.signal);
43
64
  if (!isInstalled(model, directory))
44
- throw new CliError("not_found", `speech model ${description.model} is not installed; download it explicitly with a WireCat messenger's models download command`, { reason: "model.not-downloaded", model: description.model });
65
+ throw notInstalled(description.model);
45
66
  const { openRecognizer, toModelRate } = await import("./recognize.js");
46
67
  cancelled(options.signal);
47
68
  const engine = (options.open ?? openRecognizer)(model, modelPath(directory, model), vadPath(directory));
@@ -24,6 +24,8 @@ export interface SpeechModel {
24
24
  featureDim: number;
25
25
  }
26
26
  export declare const MODELS: SpeechModel[];
27
+ /** OpenAI's recommended transcription model (speech-to-text guide, read 2026-10-31). */
28
+ export declare const OPENAI_TRANSCRIBE_MODEL = "gpt-transcribe";
27
29
  /** Cuts speech at pauses. Every model needs it: five minutes in one piece crash the WebAssembly build. */
28
30
  export declare const VAD: ModelFile;
29
31
  /**
@@ -105,6 +105,8 @@ export const MODELS = [
105
105
  }),
106
106
  },
107
107
  ];
108
+ /** OpenAI's recommended transcription model (speech-to-text guide, read 2026-10-31). */
109
+ export const OPENAI_TRANSCRIBE_MODEL = "gpt-transcribe";
108
110
  /** Cuts speech at pauses. Every model needs it: five minutes in one piece crash the WebAssembly build. */
109
111
  export const VAD = {
110
112
  name: "silero_vad.onnx",
@@ -0,0 +1,54 @@
1
+ import { type RetryConfig } from "@wirecat/cli-base";
2
+ import { type Fetch } from "../embeddings/remote.js";
3
+ import type { Pcm } from "./decode.js";
4
+ import { type SpeechCutter, type TimedText } from "./recognize.js";
5
+ /**
6
+ * Ten minutes of 16 kHz PCM16 is 19.2 MB: under OpenAI's 25 MB per file (speech-to-text guide, read 2026-10-31)
7
+ * whichever megabyte it means, with room for the multipart body around it.
8
+ */
9
+ export declare const LONGEST_REQUEST_SECONDS = 600;
10
+ export interface OpenAITranscription {
11
+ apiKey: string;
12
+ /** `gpt-transcribe` when not given. */
13
+ model?: string;
14
+ /** ISO-639-1 codes the speech may be in. Sent as `languages`; the singular `language` is never sent with it. */
15
+ languages?: readonly string[];
16
+ prompt?: string;
17
+ /** Literal terms expected in the speech; OpenAI refuses the whole request if one holds `<`, `>` or a line break. */
18
+ keywords?: readonly string[];
19
+ baseUrl?: string;
20
+ signal?: AbortSignal;
21
+ fetch?: Fetch;
22
+ /**
23
+ * Fewer, longer requests for a caller that wants the text and not the turns: a recording of up to ten minutes in
24
+ * one request, a longer one in pieces of up to ten minutes joined across pauses. Each piece then spans the silence
25
+ * between its utterances, so two channels heard this way do not merge into a dialogue.
26
+ */
27
+ bundle?: boolean;
28
+ /** The voice detector model; the shared models directory's when not given. */
29
+ vadModel?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ /** Seams for tests: the detector, the retry policy and the clock. */
32
+ cutter?: (vadModel: string) => SpeechCutter;
33
+ retry?: RetryConfig;
34
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
35
+ random?: () => number;
36
+ }
37
+ /**
38
+ * Each stretch of speech the detector found is a piece of its own: on one channel of a call, a pause is often the
39
+ * other person talking. Two stretches the detector split without a pause stay one. With `limit`, neighbours are also
40
+ * joined across pauses while the piece stays within it.
41
+ */
42
+ export declare const piecesOf: (spans: readonly {
43
+ start: number;
44
+ end: number;
45
+ }[], limit?: number) => {
46
+ start: number;
47
+ end: number;
48
+ }[];
49
+ /**
50
+ * Speech to text by OpenAI's `/audio/transcriptions`, from one channel's samples: one piece per stretch of speech the
51
+ * voice detector found, each sent alone, with where it lies in seconds. Two channels heard this way merge into a
52
+ * dialogue by `start`. `gpt-transcribe` gives no timestamps of its own, so the times are the detector's.
53
+ */
54
+ export declare const transcribeOpenAI: (audio: Pcm, options: OpenAITranscription) => Promise<TimedText[]>;
@@ -0,0 +1,199 @@
1
+ import { setTimeout as wait } from "node:timers/promises";
2
+ import { backoffMs, CliError, DEFAULT_RETRY } from "@wirecat/cli-base";
3
+ import { providerWaitMs } from "@wirecat/cli-base/http";
4
+ import { OPENAI_URL } from "../embeddings/remote.js";
5
+ import { filesPresent, modelsDirectory, vadPath } from "./install.js";
6
+ import { OPENAI_TRANSCRIBE_MODEL, VAD } from "./models.js";
7
+ import { SAMPLE_RATE, toModelRate } from "./recognize.js";
8
+ import { encodeWav } from "./wav.js";
9
+ /**
10
+ * Ten minutes of 16 kHz PCM16 is 19.2 MB: under OpenAI's 25 MB per file (speech-to-text guide, read 2026-10-31)
11
+ * whichever megabyte it means, with room for the multipart body around it.
12
+ */
13
+ export const LONGEST_REQUEST_SECONDS = 600;
14
+ /**
15
+ * The detector cuts speech that runs past 20 seconds with no pause; on real speech that cut left 0.074 s between the
16
+ * two stretches, and the shortest real pause it found was 0.43 s. Below this the two are one stretch, cut mid-word.
17
+ */
18
+ const JOINED_SECONDS = 0.2;
19
+ /** Kept around each piece so its first and last sounds are not clipped; never reaches into the next piece. */
20
+ const PAD_SECONDS = 0.2;
21
+ const LEAD = SAMPLE_RATE / 2;
22
+ /** OpenAI's codes for money or limits run out (error-codes guide, read 2026-10-31): waiting does not help. */
23
+ const EXHAUSTED = new Set([
24
+ "insufficient_quota",
25
+ "credit_balance_exhausted",
26
+ "organization_spend_limit_exceeded",
27
+ "project_spend_limit_exceeded",
28
+ "organization_usage_limit_exceeded",
29
+ ]);
30
+ const RETRY = { ...DEFAULT_RETRY, retries: 4 };
31
+ const interrupted = (signal) => {
32
+ if (!signal?.aborted)
33
+ return;
34
+ if (signal.reason instanceof CliError)
35
+ throw signal.reason;
36
+ const timeout = signal.reason instanceof Error && signal.reason.name === "TimeoutError";
37
+ throw new CliError(timeout ? "timeout" : "cancelled", "the transcription was interrupted");
38
+ };
39
+ const sleeping = (ms, signal) => wait(ms, undefined, signal ? { signal } : {}).then(() => undefined, (error) => {
40
+ interrupted(signal);
41
+ throw error;
42
+ });
43
+ const formOf = (wav, options) => {
44
+ const form = new FormData();
45
+ form.append("file", new Blob([wav], { type: "audio/wav" }), "audio.wav");
46
+ form.append("model", options.model ?? OPENAI_TRANSCRIBE_MODEL);
47
+ // Repeated `name[]` fields: how OpenAI's own SDK sends an array in a multipart body.
48
+ for (const language of options.languages ?? [])
49
+ form.append("languages[]", language);
50
+ for (const keyword of options.keywords ?? [])
51
+ form.append("keywords[]", keyword);
52
+ if (options.prompt)
53
+ form.append("prompt", options.prompt);
54
+ return form;
55
+ };
56
+ const safe = (value) => typeof value === "string" && /^[\w.-]{1,64}$/.test(value) ? value : undefined;
57
+ /** OpenAI's error code and type, never its message: a message can quote the request. */
58
+ const failureOf = async (response) => {
59
+ try {
60
+ const body = (await response.json());
61
+ const code = safe(body.error?.code);
62
+ const type = safe(body.error?.type);
63
+ return { ...(code ? { code } : {}), ...(type ? { type } : {}) };
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ };
69
+ /** One file, one request. 429, 408 and server errors are tried again; the provider's `Retry-After` beats our backoff. */
70
+ const send = async (wav, options) => {
71
+ const post = options.fetch ?? fetch;
72
+ const retry = options.retry ?? RETRY;
73
+ const sleep = options.sleep ?? sleeping;
74
+ const random = options.random ?? Math.random;
75
+ const url = `${(options.baseUrl ?? OPENAI_URL).replace(/\/+$/, "")}/audio/transcriptions`;
76
+ for (let attempt = 1;; attempt++) {
77
+ interrupted(options.signal);
78
+ let response;
79
+ try {
80
+ response = await post(url, {
81
+ method: "POST",
82
+ headers: { authorization: `Bearer ${options.apiKey}` },
83
+ body: formOf(wav, options),
84
+ ...(options.signal ? { signal: options.signal } : {}),
85
+ });
86
+ }
87
+ catch {
88
+ interrupted(options.signal);
89
+ if (attempt > retry.retries)
90
+ throw new CliError("network_error", "OpenAI could not be reached", { reason: "network.failed" });
91
+ await sleep(backoffMs(attempt, retry, random), options.signal);
92
+ continue;
93
+ }
94
+ if (response.ok) {
95
+ const body = (await response.json().catch(() => interrupted(options.signal)));
96
+ if (typeof body?.text !== "string")
97
+ throw new CliError("invalid_response", "OpenAI answered without a transcript", {
98
+ reason: "provider.invalid-response",
99
+ });
100
+ return body.text.trim();
101
+ }
102
+ const { code, type } = await failureOf(response);
103
+ const status = response.status;
104
+ const said = `HTTP ${status}${code ? `, ${code}` : type ? `, ${type}` : ""}`;
105
+ if (status === 401)
106
+ throw new CliError("authentication_error", `OpenAI refused the key (${said}) — set it again with \`models keys set openai\``, { reason: "auth.failed" });
107
+ if (status === 403)
108
+ throw new CliError("permission_error", `OpenAI refused the request (${said}) — it answers this when the country or region is not supported, or the key's project may not use the model`, { reason: "access.denied" });
109
+ if (status === 413)
110
+ throw new CliError("validation_error", `OpenAI refused the audio as too large (${said})`, {
111
+ reason: "transcription.too-large",
112
+ });
113
+ if (EXHAUSTED.has(code ?? "") || EXHAUSTED.has(type ?? ""))
114
+ throw new CliError("permission_error", `OpenAI refused: the account's credit, spend limit or usage limit is used up (${said}) — see the billing page`, { reason: "transcription.quota-exhausted" });
115
+ const again = status === 408 || status === 429 || status >= 500;
116
+ const asked = providerWaitMs(response.headers, () => new Date());
117
+ if (again && attempt <= retry.retries && (asked === undefined || asked <= retry.maxRetryAfterMs)) {
118
+ await sleep(asked ?? backoffMs(attempt, retry, random), options.signal);
119
+ continue;
120
+ }
121
+ if (status === 429)
122
+ throw new CliError("rate_limited", `OpenAI answered ${said}`, {
123
+ reason: "provider.rate-limited",
124
+ ...(asked === undefined ? {} : { retry_after_ms: asked }),
125
+ });
126
+ if (status >= 500)
127
+ throw new CliError([502, 503, 504].includes(status) ? "provider_unavailable" : "provider_error", `OpenAI answered ${said}`, { reason: [502, 503, 504].includes(status) ? "provider.unavailable" : "provider.refused" });
128
+ throw new CliError("provider_error", `OpenAI answered ${said}`, { reason: "provider.refused" });
129
+ }
130
+ };
131
+ /**
132
+ * Each stretch of speech the detector found is a piece of its own: on one channel of a call, a pause is often the
133
+ * other person talking. Two stretches the detector split without a pause stay one. With `limit`, neighbours are also
134
+ * joined across pauses while the piece stays within it.
135
+ */
136
+ export const piecesOf = (spans, limit) => {
137
+ const pieces = [];
138
+ for (const span of spans) {
139
+ const last = pieces.at(-1);
140
+ const joined = last !== undefined && span.start - last.end < JOINED_SECONDS;
141
+ const fits = last !== undefined && limit !== undefined && span.end - last.start <= limit;
142
+ if (last && (fits || (joined && span.end - last.start <= LONGEST_REQUEST_SECONDS)))
143
+ last.end = span.end;
144
+ else
145
+ pieces.push({ start: span.start, end: span.end });
146
+ }
147
+ return pieces;
148
+ };
149
+ const openCutter = async (vadModel) => {
150
+ const { openSpeechCutter } = await import("./recognize.js");
151
+ return openSpeechCutter(vadModel);
152
+ };
153
+ const checkKeywords = (keywords = []) => {
154
+ const bad = keywords.find((keyword) => typeof keyword !== "string" || !keyword.trim() || /[<>\r\n]/.test(keyword));
155
+ if (bad !== undefined)
156
+ throw new CliError("validation_error", "a keyword must be a non-empty single line without < or > — OpenAI refuses the whole request otherwise");
157
+ };
158
+ /**
159
+ * Speech to text by OpenAI's `/audio/transcriptions`, from one channel's samples: one piece per stretch of speech the
160
+ * voice detector found, each sent alone, with where it lies in seconds. Two channels heard this way merge into a
161
+ * dialogue by `start`. `gpt-transcribe` gives no timestamps of its own, so the times are the detector's.
162
+ */
163
+ export const transcribeOpenAI = async (audio, options) => {
164
+ if (!options.apiKey)
165
+ throw new CliError("authentication_error", "no OpenAI key — `models keys set openai`, or OPENAI_API_KEY", {
166
+ reason: "transcription.no-key",
167
+ });
168
+ checkKeywords(options.keywords);
169
+ interrupted(options.signal);
170
+ const pcm = toModelRate(audio.samples, audio.rate);
171
+ const duration = (pcm.length - LEAD) / SAMPLE_RATE;
172
+ if (options.bundle && duration <= LONGEST_REQUEST_SECONDS) {
173
+ const text = await send(encodeWav(pcm.subarray(LEAD)), options);
174
+ return text ? [{ start: 0, end: duration, text }] : [];
175
+ }
176
+ const vadModel = options.vadModel ?? vadPath(modelsDirectory(options.env));
177
+ if (!options.cutter && !filesPresent([[VAD, vadModel]]))
178
+ throw new CliError("not_found", "the voice detector that finds the speech is not on this machine — it comes with any speech model: `models download`", { reason: "model.not-downloaded", model: "silero_vad" });
179
+ const cutter = await (options.cutter ?? openCutter)(vadModel);
180
+ let spans;
181
+ try {
182
+ spans = cutter.cut(pcm);
183
+ }
184
+ finally {
185
+ cutter.free();
186
+ }
187
+ const pieces = piecesOf(spans, options.bundle ? LONGEST_REQUEST_SECONDS : undefined);
188
+ const heard = [];
189
+ for (const [index, piece] of pieces.entries()) {
190
+ const end = Math.min(piece.end, duration);
191
+ const from = Math.max(piece.start - PAD_SECONDS, pieces[index - 1]?.end ?? 0);
192
+ const to = Math.min(end + PAD_SECONDS, pieces[index + 1]?.start ?? duration);
193
+ const samples = pcm.subarray(LEAD + Math.floor(from * SAMPLE_RATE), LEAD + Math.ceil(to * SAMPLE_RATE));
194
+ const text = await send(encodeWav(samples), options);
195
+ if (text)
196
+ heard.push({ start: Math.min(piece.start, end), end, text });
197
+ }
198
+ return heard;
199
+ };
@@ -1,7 +1,15 @@
1
1
  import type { SpeechModel } from "./models.js";
2
+ /** Seconds from the start of the recording. */
3
+ export interface TimedText {
4
+ start: number;
5
+ end: number;
6
+ text: string;
7
+ }
2
8
  /** What transcription needs from a speech engine — small enough for a test to stand in for it. */
3
9
  export interface Recognizer {
4
10
  recognize(pcm: Float32Array): string;
11
+ /** Each stretch of speech the voice detector cut, with where it lies; the same input as `recognize`. */
12
+ pieces?(pcm: Float32Array): TimedText[];
5
13
  free(): void;
6
14
  }
7
15
  export declare const SAMPLE_RATE = 16000;
@@ -13,7 +21,7 @@ export declare const toModelRate: (samples: Float32Array, rate: number) => Float
13
21
  export declare const decodeOgg: (bytes: Uint8Array) => Promise<{
14
22
  samples: Float32Array;
15
23
  rate: number;
16
- }>;
24
+ }[]>;
17
25
  interface SherpaStream {
18
26
  acceptWaveform(rate: number, samples: Float32Array): void;
19
27
  free(): void;
@@ -31,6 +39,7 @@ interface Sherpa {
31
39
  acceptWaveform(samples: Float32Array): void;
32
40
  isEmpty(): boolean;
33
41
  front(): {
42
+ start: number;
34
43
  samples: Float32Array;
35
44
  };
36
45
  pop(): void;
@@ -55,6 +64,21 @@ export declare const detector: (vadModel: string) => {
55
64
  debug: number;
56
65
  bufferSizeInSeconds: number;
57
66
  };
67
+ export interface SpeechSpan {
68
+ start: number;
69
+ end: number;
70
+ samples: Float32Array;
71
+ }
72
+ export interface SpeechCutter {
73
+ /** Where the speech is, in seconds of the recording; `pcm` as `toModelRate` made it. */
74
+ cut(pcm: Float32Array): {
75
+ start: number;
76
+ end: number;
77
+ }[];
78
+ free(): void;
79
+ }
80
+ /** The voice detector alone, for a recognizer that is not on this machine. */
81
+ export declare const openSpeechCutter: (vadModel: string) => SpeechCutter;
58
82
  /**
59
83
  * The engine's WebAssembly writes its logs (a growing buffer says "Overflow! … No data loss!") with
60
84
  * `printf`, which its file system sends straight to file descriptor 1 and so into `--json` output.