@wirecat/cli-core 0.497.0 → 0.499.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.499.0 — 31.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`decodeChannels(bytes)` in `@wirecat/cli-core/speech` returns every channel of a recording:**
15
+ `{ channels: Pcm[], mixed }`. A phone call recorded in stereo keeps one speaker per channel, and `decodeAudio`
16
+ keeps only the first, so the other speaker was lost. `decodeAudio` is unchanged and now exported too. Opus,
17
+ Vorbis, FLAC and MP3 keep their channels apart; a format only ffmpeg reads arrives as one channel with
18
+ `mixed: true`, so a caller can tell a mixed-down call from a mono one.
19
+ - **OpenAI transcription: `transcribeOpenAI(pcm, { apiKey, model, languages, prompt, keywords })`.** It sends one
20
+ channel to OpenAI's `/audio/transcriptions` (`gpt-transcribe` by default, `languages[]` and `keywords[]` as
21
+ repeated fields, no SDK) and answers `[{ start, end, text }]` in seconds: one piece per stretch of speech the
22
+ voice detector found, each sent alone, so two channels merge into a dialogue by `start`. Two stretches the
23
+ detector split only because speech ran past 20 seconds stay one piece. `gpt-transcribe` gives no timestamps, so
24
+ the times are the detector's: about 0.1–0.3 s late at the start and 0.5–0.8 s late at the end, and very quiet
25
+ speech is missed, as it is locally. `bundle: true` trades the turns for fewer requests: up to ten minutes
26
+ (`LONGEST_REQUEST_SECONDS`) in one. It needs the voice detector file that comes with any speech model
27
+ (`models download`), except bundled audio of up to ten minutes. A 429 or a server error is tried again, waiting
28
+ as long as `Retry-After` asks; errors carry `auth.failed` (401), `access.denied` (403, which OpenAI also answers
29
+ for an unsupported country), `transcription.quota-exhausted` (credit, spend or usage limit; never retried),
30
+ `transcription.no-key`, `transcription.too-large`, `provider.rate-limited` or `provider.unavailable`, and never
31
+ the key. A keyword with `<`, `>` or a line break is refused before sending.
32
+ - **`recognizeLocalPieces(model, pcm)` gives the same timed pieces from a local model**, one per stretch of speech
33
+ the voice detector cut.
34
+ - **`transcribeWith: "openai"` sends voice messages to OpenAI**, with the key `models keys set openai` or
35
+ `OPENAI_API_KEY` holds and the model in the profile's `transcribeModel` (`gpt-transcribe` when unset). Only when
36
+ named: `auto` never sends audio off the machine, and a read-only process refuses. A transcript heard this way is
37
+ kept as `openai:<model>`.
38
+ - **Meeting transcription keeps who spoke.** `MeetingRecognitionSegment.speaker` (trimmed; `null` is none) is stored
39
+ as the row's speaker, and segments of different speakers may overlap — two people on a call talk over each
40
+ other. One speaker's own segments still may not, and segments without a speaker are checked as before. A
41
+ transcript whose `recognition.engine` is `openai` is stored as `openai/<model>`, format `openai-transcription`;
42
+ local transcripts keep `local/<engine>/<model>` exactly.
43
+
44
+ ### Changed — may break callers
45
+
46
+ - **An unknown `transcribeWith` in a profile now refuses to transcribe** with `validation_error`, naming the four
47
+ values. Before, any value other than `local` silently meant asking the messenger first. `models list` and
48
+ `models download` still read such a profile. Set it to `auto`, `messenger`, `local` or `openai`.
49
+
50
+ ### Fixed
51
+
52
+ - **`runs list` with nothing recorded no longer says a run is kept only with `--record`.** Every command that does
53
+ its work is kept by default; `record: false` in the config keeps failures only, and `--no-record` keeps nothing.
54
+
8
55
  ## 0.497.0 — 31.10.2026
9
56
 
10
57
  ### Added
@@ -95,6 +142,12 @@ shape of this file.
95
142
 
96
143
  - **`NoteQuery.refs` checks a list of notes against a query, and `NoteHit.at` is the time `date:` read.**
97
144
 
145
+ ### Added
146
+
147
+ - **`chatCoverage` and `ChatCoverage` are exported from `@wirecat/cli-core/services`, and `coverageGaps` from
148
+ `@wirecat/cli-core/cli`.** A CLI can now pass `ports.coverage` to `gatherProjectEvidence` and build `missing` and
149
+ `next` for chats held in part, as cli-core's own agenda and search do.
150
+
98
151
  ## 0.495.0 — 31.10.2026
99
152
 
100
153
  ### Changed — may break callers
@@ -10,13 +10,13 @@ export declare const calendarTools: ({
10
10
  inputSchema: {
11
11
  type: string;
12
12
  properties: {
13
+ limit?: undefined;
13
14
  account_id: {
14
15
  type: string;
15
16
  minimum: number;
16
17
  };
17
18
  words?: undefined;
18
19
  timezone?: undefined;
19
- limit?: undefined;
20
20
  event?: undefined;
21
21
  } | {
22
22
  account_id: {
@@ -38,9 +38,9 @@ export declare const calendarTools: ({
38
38
  };
39
39
  event?: undefined;
40
40
  } | {
41
+ limit?: undefined;
41
42
  words?: undefined;
42
43
  timezone?: undefined;
43
- limit?: undefined;
44
44
  event: {
45
45
  type: string;
46
46
  minLength: number;
@@ -50,9 +50,9 @@ export declare const calendarTools: ({
50
50
  minimum: number;
51
51
  };
52
52
  } | {
53
+ limit?: undefined;
53
54
  words?: undefined;
54
55
  timezone?: undefined;
55
- limit?: undefined;
56
56
  event?: undefined;
57
57
  account_id?: undefined;
58
58
  };
@@ -71,13 +71,13 @@ export declare const calendarTools: ({
71
71
  inputSchema: {
72
72
  type: string;
73
73
  properties: {
74
+ limit?: undefined;
74
75
  account_id: {
75
76
  type: string;
76
77
  minimum: number;
77
78
  };
78
79
  words?: undefined;
79
80
  timezone?: undefined;
80
- limit?: undefined;
81
81
  event?: undefined;
82
82
  } | {
83
83
  account_id: {
@@ -99,9 +99,9 @@ export declare const calendarTools: ({
99
99
  };
100
100
  event?: undefined;
101
101
  } | {
102
+ limit?: undefined;
102
103
  words?: undefined;
103
104
  timezone?: undefined;
104
- limit?: undefined;
105
105
  event: {
106
106
  type: string;
107
107
  minLength: number;
@@ -111,9 +111,9 @@ export declare const calendarTools: ({
111
111
  minimum: number;
112
112
  };
113
113
  } | {
114
+ limit?: undefined;
114
115
  words?: undefined;
115
116
  timezone?: undefined;
116
- limit?: undefined;
117
117
  event?: undefined;
118
118
  account_id?: undefined;
119
119
  };
@@ -132,13 +132,13 @@ export declare const calendarTools: ({
132
132
  inputSchema: {
133
133
  type: string;
134
134
  properties: {
135
+ limit?: undefined;
135
136
  account_id: {
136
137
  type: string;
137
138
  minimum: number;
138
139
  };
139
140
  words?: undefined;
140
141
  timezone?: undefined;
141
- limit?: undefined;
142
142
  event?: undefined;
143
143
  } | {
144
144
  account_id: {
@@ -160,9 +160,9 @@ export declare const calendarTools: ({
160
160
  };
161
161
  event?: undefined;
162
162
  } | {
163
+ limit?: undefined;
163
164
  words?: undefined;
164
165
  timezone?: undefined;
165
- limit?: undefined;
166
166
  event: {
167
167
  type: string;
168
168
  minLength: number;
@@ -172,9 +172,9 @@ export declare const calendarTools: ({
172
172
  minimum: number;
173
173
  };
174
174
  } | {
175
+ limit?: undefined;
175
176
  words?: undefined;
176
177
  timezone?: undefined;
177
- limit?: undefined;
178
178
  event?: undefined;
179
179
  account_id?: undefined;
180
180
  };
@@ -193,13 +193,13 @@ export declare const calendarTools: ({
193
193
  inputSchema: {
194
194
  type: string;
195
195
  properties: {
196
+ limit?: undefined;
196
197
  account_id: {
197
198
  type: string;
198
199
  minimum: number;
199
200
  };
200
201
  words?: undefined;
201
202
  timezone?: undefined;
202
- limit?: undefined;
203
203
  event?: undefined;
204
204
  } | {
205
205
  account_id: {
@@ -221,9 +221,9 @@ export declare const calendarTools: ({
221
221
  };
222
222
  event?: undefined;
223
223
  } | {
224
+ limit?: undefined;
224
225
  words?: undefined;
225
226
  timezone?: undefined;
226
- limit?: undefined;
227
227
  event: {
228
228
  type: string;
229
229
  minLength: number;
@@ -233,9 +233,9 @@ export declare const calendarTools: ({
233
233
  minimum: number;
234
234
  };
235
235
  } | {
236
+ limit?: undefined;
236
237
  words?: undefined;
237
238
  timezone?: undefined;
238
- limit?: undefined;
239
239
  event?: undefined;
240
240
  account_id?: undefined;
241
241
  };
@@ -96,7 +96,7 @@ export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from ".
96
96
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
97
97
  export { type ModeProblem, type PrivateFiles, privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
98
98
  export { buildReport as buildDoctorReport, type Report as DoctorReport, type ReportContext as DoctorReportContext, reportFileName as doctorReportFileName, } from "./messenger/report.js";
99
- export { allAnswer, allHitItem, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, } from "./messenger/search-answers.js";
99
+ export { allAnswer, allHitItem, coverageGaps, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, } from "./messenger/search-answers.js";
100
100
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
101
101
  export { servingProfiles } from "./messenger/serve-command.js";
102
102
  export { DAY_MS, FIRST_FETCH_MS, type MemberFetches, memberFetches } from "./messenger/serve-members.js";
package/dist/cli/index.js CHANGED
@@ -87,7 +87,7 @@ export { httpTokenFile, serverEntry } from "./messenger/mcp-command.js";
87
87
  export { messageItem } from "./messenger/message-items.js";
88
88
  export { privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
89
89
  export { buildReport as buildDoctorReport, reportFileName as doctorReportFileName, } from "./messenger/report.js";
90
- export { allAnswer, allHitItem, messageHitItem, noteHitItem, } from "./messenger/search-answers.js";
90
+ export { allAnswer, allHitItem, coverageGaps, messageHitItem, noteHitItem, } from "./messenger/search-answers.js";
91
91
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
92
92
  export { servingProfiles } from "./messenger/serve-command.js";
93
93
  export { DAY_MS, FIRST_FETCH_MS, memberFetches } from "./messenger/serve-members.js";
@@ -23,7 +23,7 @@ export const hearForCommand = async (context, messenger, messages, transcribe, m
23
23
  const hearing = await hearVoices(messenger, messages, kept, transcribe && !offline
24
24
  ? {
25
25
  choice: {
26
- ...choose(messenger, context.settings, model === undefined ? {} : { model }, context.env),
26
+ ...choose(messenger, context.settings, model === undefined ? {} : { model }, context.env, context.keyring),
27
27
  ...(context.recognizer ? { open: context.recognizer } : {}),
28
28
  },
29
29
  connect: connect ?? ((work) => context.withMessenger(work)),
@@ -89,7 +89,7 @@ const downloadAudioModel = async (command, messenger, id) => {
89
89
  finally {
90
90
  recognizer.free();
91
91
  }
92
- const chosen = choose(messenger, context.settings, {}, context.env).model.id;
92
+ const chosen = choose(messenger, context.settings, {}, context.env, context.keyring).model.id;
93
93
  renderAnswer(command, context, itemOf(audioItem(model, directory, chosen)), downloaded);
94
94
  }, { unbounded: true });
95
95
  };
@@ -174,7 +174,7 @@ export const modelsCommand = (messenger) => {
174
174
  const rerankers = kinds === undefined || kinds === "reranker"
175
175
  ? RERANK_MODELS.filter((model) => id === undefined || model.id === id)
176
176
  : [];
177
- const chosen = audioModels.length > 0 ? choose(messenger, context.settings, {}, context.env).model.id : "";
177
+ const chosen = audioModels.length > 0 ? choose(messenger, context.settings, {}, context.env, context.keyring).model.id : "";
178
178
  const catalogue = [
179
179
  ...audioModels.map((model) => audioItem(model, modelsDirectory(context.env), chosen)),
180
180
  ...textModels.map((model) => textItem(model, textModelsDirectory(context.env))),
@@ -1,7 +1,8 @@
1
1
  import { annotate } from "@wirecat/cli-base/commands";
2
2
  import { openKept } from "../../speech/hearing.js";
3
3
  import { isInstalled } from "../../speech/install.js";
4
- import { choose, hearLocally, hearOnline, notDownloaded } from "../../speech/transcribe.js";
4
+ import { choose, heardVia, hearLocally, hearOnline, keptSource, notDownloaded, } from "../../speech/transcribe.js";
5
+ import { environmentOf } from "../context.js";
5
6
  import { REMOTE_READ } from "../effects.js";
6
7
  import { renderAnswer } from "../envelope.js";
7
8
  import { messengerContext } from "./context.js";
@@ -22,10 +23,13 @@ export const transcribeSubcommand = (messages, messenger) => annotate(messages.c
22
23
  .action(async function (chat, messageId) {
23
24
  const context = messengerContext(this, messenger);
24
25
  const choice = {
25
- ...choose(messenger, context.settings, this.opts(), context.env),
26
+ ...choose(messenger, context.settings, this.opts(), context.env, context.keyring),
26
27
  ...(context.recognizer ? { open: context.recognizer } : {}),
28
+ ...signalOf(this),
27
29
  };
28
- // Before connecting: a missing model should not cost a login.
30
+ // Before connecting: a missing model or OpenAI key should not cost a login.
31
+ if (choice.refusal)
32
+ throw choice.refusal;
29
33
  if (choice.with === "local" && !isInstalled(choice.model, choice.directory)) {
30
34
  throw notDownloaded(messenger, choice.model);
31
35
  }
@@ -51,17 +55,17 @@ export const transcribeSubcommand = (messages, messenger) => annotate(messages.c
51
55
  return [resolved, undefined, await hearOnline(messenger, connection, resolved, id, choice, read)];
52
56
  });
53
57
  const heard = before
54
- ? {
55
- messageId: id,
56
- text: before.text,
57
- pending: false,
58
- ...(before.source === messenger.provider ? { via: before.source } : { via: "local", model: before.source }),
59
- }
58
+ ? { messageId: id, text: before.text, pending: false, ...heardVia(before.source, messenger.provider) }
60
59
  : online instanceof Uint8Array
61
60
  ? await hearLocally(online, id, choice)
62
61
  : online;
63
62
  if (!heard.pending && !before) {
64
- await withKept((kept) => kept.keep({ chatId, messageId: id }, heard.text, heard.model ?? heard.via));
63
+ await withKept((kept) => kept.keep({ chatId, messageId: id }, heard.text, keptSource(heard)));
65
64
  }
66
65
  renderAnswer(this, context, heardAnswer(heard, chatId), () => `${heard.text}\n`);
67
66
  });
67
+ const signalOf = (command) => {
68
+ const { signal, commandSignal } = environmentOf(command);
69
+ const signals = [signal, commandSignal].filter((one) => one !== undefined);
70
+ return signals.length ? { signal: AbortSignal.any(signals) } : {};
71
+ };
@@ -41,7 +41,8 @@ export const runsCommand = (app) => {
41
41
  args: words.length ? { query: typedQuery(words) } : {},
42
42
  }));
43
43
  if (runs.length === 0)
44
- renderer.note("nothing recorded — a run is kept with `--record`, or when it fails");
44
+ renderer.note("nothing recorded — a command that does its work is kept unless the config says `record: false` (then only " +
45
+ "failures) or it ran with `--no-record`");
45
46
  });
46
47
  annotate(command.command("show"), {
47
48
  examples: [`${app.command} runs show run-101`],
@@ -7,7 +7,7 @@ export declare const REASONS: ErrorReason[];
7
7
  * Detail keys an error from this package may carry, until each is registered on its own reason. Not `cause`:
8
8
  * that can hold a provider's or SQLite's raw text.
9
9
  */
10
- export declare const DETAILS: readonly ["contracts", "calendars", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "partial_output", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
10
+ export declare const DETAILS: readonly ["contracts", "calendars", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "partial_output", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "retry_after_ms", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
11
11
  /** What a CLI adds to the shared registry: its own reasons, and the detail keys its adapters raise. */
12
12
  export interface AppErrors {
13
13
  reasons?: readonly ErrorReason[];
@@ -88,6 +88,13 @@ const OTHERS = [
88
88
  validation("memory.evidence-required", "a memory needs its evidence"),
89
89
  validation("memory.scope-required", "a memory needs its scope"),
90
90
  ["model.not-downloaded", "not_found", "the model is not downloaded on this machine"],
91
+ ["transcription.no-key", "authentication_error", "no OpenAI key is set for transcription"],
92
+ validation("transcription.too-large", "the audio is over the transcription provider's size limit"),
93
+ [
94
+ "transcription.quota-exhausted",
95
+ "permission_error",
96
+ "the transcription provider account has no credit or limit left",
97
+ ],
91
98
  validation("name.invalid", "the name is not valid"),
92
99
  validation("output.choose-fields", "at a terminal, --json names the fields it prints"),
93
100
  validation("output.one-object", "a report is one JSON object; --jsonl streams lists only"),
@@ -201,6 +208,7 @@ export const DETAILS = [
201
208
  "reference",
202
209
  "remembered",
203
210
  "retry_at",
211
+ "retry_after_ms",
204
212
  "runtime",
205
213
  "saved",
206
214
  "scheduled_for",
@@ -19,6 +19,11 @@ export interface MeetingRecognitionSegment {
19
19
  startMs: number;
20
20
  endMs: number;
21
21
  text: string;
22
+ /**
23
+ * Who spoke, when the source knows — a channel of a two-channel call. Speakers may overlap; one speaker may not.
24
+ * Trimmed; `null` is no speaker.
25
+ */
26
+ speaker?: string | null;
22
27
  }
23
28
  export interface MeetingTranscriptionContext {
24
29
  maxBytes: number;
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { type BotCheckService } from "../botcheck/service.js";
2
+ export { type ChatCoverage, chatCoverage } from "./chat-coverage.js";
2
3
  export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextIndex, ContextReadLimits, ContextReadUsage, ContextRecordReader, ContextSource, ScopedContextEvidenceReader, } from "./context-evidence.js";
3
4
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
4
5
  export { excerpt } from "./excerpt.js";
@@ -1,4 +1,5 @@
1
1
  import { botCheckService } from "../botcheck/service.js";
2
+ export { chatCoverage } from "./chat-coverage.js";
2
3
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
3
4
  export { excerpt } from "./excerpt.js";
4
5
  export { archiveMedia, checkMediaArchive } from "./media-archive.js";
@@ -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";