@alia.onl/sdk 7.2.9 → 10.0.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/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export { IdentityMark } from './components/IdentityMark';
13
13
  export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
14
14
  export { ThinkingIndicator } from './components/ThinkingIndicator';
15
15
  export { AliaMarkdown } from './components/Markdown';
16
- export type { AliaMarkdownProps } from './components/Markdown';
16
+ export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
17
17
  export { Reasoning, ReasoningTrigger, ReasoningContent } from './components/Reasoning';
18
18
  export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
19
19
  export { ResearchProgressCard } from './components/ResearchProgressCard';
@@ -27,11 +27,13 @@ export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat'
27
27
  export { useTTS } from './hooks/useTTS';
28
28
  export type { UseTTSOptions } from './hooks/useTTS';
29
29
  export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
30
+ export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
31
+ export type { VoiceErrorCode } from './lib/speech-messages';
30
32
  export type { UseSTTOptions } from './hooks/useSpeechToText';
31
33
 
32
34
  // ── Model catalogue (GET /catalogue) ──
33
- // Exported so a consumer building its own picker reads the surface the SDK
34
- // reads, rather than hardcoding an identifier the way this package used to.
35
+ // Exported so a consumer building its own picker reads the real models the
36
+ // server offers; the SDK itself ships no model identifier.
35
37
  export {
36
38
  fetchCatalogue,
37
39
  parseCatalogue,
@@ -39,8 +41,7 @@ export {
39
41
  resolveModelId,
40
42
  clearCatalogueCache,
41
43
  } from './lib/catalogue';
42
- export type { CatalogueEntry, CatalogueEntryKind, ModelSelection } from './lib/catalogue';
43
- export { PREFERRED_CHAT_MODEL_ID, PREFERRED_VOICE_MODEL_ID } from './lib/config';
44
+ export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from './lib/catalogue';
44
45
 
45
46
  // ── Types ──
46
47
  export type {
@@ -64,24 +65,10 @@ export type {
64
65
  export { Button, buttonVariants, buttonTextVariants } from './components/ui/button';
65
66
  export type { ButtonProps } from './components/ui/button';
66
67
  export { Text, TextClassContext } from './components/ui/text';
67
- export { ChatTextInput } from './components/ui/chat-text-input';
68
68
  export { cn, formatFileSize } from './lib/utils';
69
69
  export { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from './lib/tool-registry';
70
70
  export { getTextFromContent, getImagesFromContent } from './lib/content-utils';
71
71
 
72
- // ── PromptInput ──
73
- export { PromptInput } from './components/ui/prompt-input/prompt-input';
74
- export type { PromptInputProps } from './components/ui/prompt-input/prompt-input';
75
- export { usePromptInput, useIsFullscreen, PromptInputContext } from './components/ui/prompt-input/context';
76
- export type { Attachment, Completion, PromptInputContextType } from './components/ui/prompt-input/context';
77
- export { PromptInputTextarea } from './components/ui/prompt-input/textarea';
78
- export { PromptInputActions } from './components/ui/prompt-input/actions';
79
- export { PromptInputSubmitButton } from './components/ui/prompt-input/submit-button';
80
- export { PromptInputMicButton } from './components/ui/prompt-input/mic-button';
81
- export { PromptInputAddMenu } from './components/ui/prompt-input/add-menu';
82
- export { PromptInputAttachments } from './components/ui/prompt-input/attachments';
83
- export { PromptInputAutocomplete } from './components/ui/prompt-input/autocomplete';
84
-
85
72
  // ── Picker hooks ──
86
73
  export { useImagePicker } from './hooks/useImagePicker';
87
74
  export type { ImagePickerAsset } from './hooks/useImagePicker';
@@ -2,8 +2,9 @@
2
2
  * How audio becomes the ambient field's 0..1 level — one curve, one pair of
3
3
  * time constants, for every source that drives it.
4
4
  *
5
- * Dictation got here first and is the reference: the recorder reports dBFS,
6
- * `useSpeechToText` normalises it against a -60 floor, and `useAmbientWave`
5
+ * Dictation got here first and is the reference: the microphone's level is
6
+ * taken as dBFS (on web, from an analyser beside the recognizer —
7
+ * `speech-recognition.ts`), normalised against a -60 floor, and `useAmbientWave`
7
8
  * smooths it with a fast attack and a slow decay before it reaches
8
9
  * `AmbientField`. That is the behaviour people like, so it is the behaviour
9
10
  * everything else adopts rather than re-invents — including "read aloud", which
@@ -1,58 +1,56 @@
1
1
  /**
2
- * The model catalogue, as the SDK consumes it (`GET /catalogue`, epic #139
3
- * workstream 5).
2
+ * The model catalogue, as the SDK consumes it (`GET /catalogue`).
4
3
  *
5
- * Every surface used to hardcode an `alia-*` identifier as its default —
6
- * `useAliaChat` sent `route:auto`, `useTTS` and `useVoiceRoom` sent `route:voice`
7
- * — which meant a retired identifier became a 400 in a consumer's app with
8
- * nothing the consumer could do about it, since the string was baked into the
9
- * package they installed. This module is how the SDK asks the server what it
10
- * offers instead.
4
+ * Alia has no models of its own: the catalogue lists the real models Oxy serves
5
+ * (ids `publisher/model`), and the server decides which one a request with no
6
+ * `model` gets (`defaultModelId`) and which ones a picker shows first
7
+ * (`featuredIds`). This package therefore ships NO model identifier: a caller
8
+ * that names no model sends no `model`, and the server answers with its default.
11
9
  *
12
10
  * ## Deliberately not React, and deliberately not react-query
13
11
  *
14
- * `packages/app` reads the same surface through `useCatalogue`, a react-query
15
- * hook. The SDK cannot copy that: it declares `@tanstack/react-query` as a
16
- * dependency but imports it in **zero** modules today — measured — so a consumer
17
- * mounting `<AliaChatSheet>` is not required to have a `QueryClientProvider`
18
- * anywhere above it. Introducing one here would turn "the model default came
19
- * from the server" into a breaking change for every consumer, which is a bad
20
- * trade for a fallback.
12
+ * The SDK imports `@tanstack/react-query` in no module, so a consumer mounting
13
+ * `<AliaChatSheet>` is not required to have a `QueryClientProvider`. Resolution
14
+ * happens inside `send`, at the moment a request is about to name a model, and a
15
+ * module-level cache keeps that from becoming a fetch per message.
21
16
  *
22
- * Resolution therefore happens where it matters — inside `send`, at the moment a
23
- * request is about to name a model — rather than at render. A module-level cache
24
- * keeps that from becoming a fetch per message.
17
+ * ## Parsing does not invent
25
18
  *
26
- * ## The parsing rules are the app's, because the contract is the same one
27
- *
28
- * Two rules, both about not inventing (see `packages/app/lib/hooks/use-catalogue.ts`,
29
- * which states them at length):
30
- *
31
- * - An entry whose `object` is neither known value is DROPPED. Defaulting it to
32
- * either kind would break ADR 0003 invariant 1 in one direction or the other.
33
- * - A response whose entries all fail to parse THROWS rather than reading as an
34
- * empty catalogue, because "no models" and "we could not read the models"
35
- * look identical to every caller below and one of them means "offer nothing".
36
- *
37
- * This file is a second implementation of that contract rather than a shared
38
- * one, and that is forced rather than chosen: `@alia.onl/sdk` ships as raw
39
- * source, so it cannot depend on an unpublished workspace package — a consumer's
40
- * Metro would fail to resolve it. The three Node-side clients (Codea, Cowork,
41
- * the CLI) share one instead.
19
+ * An entry without an id or a name is dropped, and a response whose entries all
20
+ * fail to parse THROWS rather than reading as an empty catalogue: "no models"
21
+ * and "we could not read the models" look identical to every caller below, and
22
+ * one of them means "offer nothing".
42
23
  */
43
24
 
44
- /** A product-owned policy that picks a model per request, or a reference to one named model. */
45
- export type CatalogueEntryKind = 'routing_profile' | 'model';
25
+ /** A reasoning effort a model accepts. */
26
+ export type ReasoningEffort = 'low' | 'medium' | 'high';
27
+
28
+ const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high'];
46
29
 
47
30
  export interface CatalogueEntry {
31
+ /** `publisher/model`. */
48
32
  readonly id: string;
49
- readonly kind: CatalogueEntryKind;
50
- readonly displayName: string;
51
- readonly description: string;
52
- /** Product policy: whether a chat picker surfaces this entry. */
53
- readonly chatVisible: boolean;
54
- /** The server states this as a claim; an entry that omits it is not called unavailable. */
55
- readonly unavailable: boolean;
33
+ readonly name: string;
34
+ readonly publisher: { readonly id: string; readonly name: string };
35
+ readonly description: string | null;
36
+ readonly contextWindow: number | null;
37
+ readonly maxOutput: number | null;
38
+ readonly inputModalities: readonly string[];
39
+ readonly outputModalities: readonly string[];
40
+ readonly tools: boolean;
41
+ /** The efforts the model accepts, cheapest first. Empty: send none. */
42
+ readonly reasoningEfforts: readonly ReasoningEffort[];
43
+ readonly pricing: { readonly inputPerMTok: string; readonly outputPerMTok: string } | null;
44
+ readonly releasedAt: string | null;
45
+ readonly featured: boolean;
46
+ }
47
+
48
+ export interface Catalogue {
49
+ readonly entries: readonly CatalogueEntry[];
50
+ /** What a request without `model` is answered with, or `null` if the server did not say. */
51
+ readonly defaultModelId: string | null;
52
+ /** The ids a picker shows first, in order. */
53
+ readonly featuredIds: readonly string[];
56
54
  }
57
55
 
58
56
  type JsonObject = Record<string, unknown>;
@@ -64,7 +62,15 @@ function asObject(value: unknown): JsonObject | null {
64
62
  }
65
63
 
66
64
  function asText(value: unknown): string | null {
67
- return typeof value === 'string' ? value : null;
65
+ return typeof value === 'string' && value !== '' ? value : null;
66
+ }
67
+
68
+ function asCount(value: unknown): number | null {
69
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
70
+ }
71
+
72
+ function asTexts(value: unknown): string[] {
73
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
68
74
  }
69
75
 
70
76
  function parseEntry(value: unknown): CatalogueEntry | null {
@@ -72,27 +78,41 @@ function parseEntry(value: unknown): CatalogueEntry | null {
72
78
  if (raw === null) return null;
73
79
 
74
80
  const id = asText(raw.id);
75
- const displayName = asText(raw.display_name);
76
- if (id === null || displayName === null) return null;
81
+ const name = asText(raw.name);
82
+ if (id === null || name === null) return null;
77
83
 
78
- if (raw.object !== 'model' && raw.object !== 'routing_profile') return null;
79
- const availability = asObject(raw.availability) ?? {};
84
+ const publisher = asObject(raw.publisher);
85
+ const publisherId = asText(publisher?.id) ?? id.split('/')[0] ?? id;
86
+ const pricing = asObject(raw.pricing);
87
+ const input = asText(pricing?.inputPerMTok);
88
+ const output = asText(pricing?.outputPerMTok);
80
89
 
81
90
  return {
82
91
  id,
83
- kind: raw.object,
84
- displayName,
85
- description: asText(raw.description) ?? '',
86
- chatVisible: raw.chat_visible === true,
87
- unavailable: availability.status === 'unavailable',
92
+ name,
93
+ publisher: { id: publisherId, name: asText(publisher?.name) ?? publisherId },
94
+ description: asText(raw.description),
95
+ contextWindow: asCount(raw.contextWindow),
96
+ maxOutput: asCount(raw.maxOutput),
97
+ inputModalities: asTexts(raw.inputModalities),
98
+ outputModalities: asTexts(raw.outputModalities),
99
+ tools: raw.tools === true,
100
+ reasoningEfforts: REASONING_EFFORTS.filter((effort) =>
101
+ asTexts(raw.reasoningEfforts).includes(effort),
102
+ ),
103
+ pricing: input !== null && output !== null ? { inputPerMTok: input, outputPerMTok: output } : null,
104
+ releasedAt: asText(raw.releasedAt),
105
+ featured: raw.featured === true,
88
106
  };
89
107
  }
90
108
 
91
- /** Turn a catalogue response into entries, or throw. */
92
- export function parseCatalogue(payload: unknown): CatalogueEntry[] {
109
+ /** Turn a catalogue response into a {@link Catalogue}, or throw. */
110
+ export function parseCatalogue(payload: unknown): Catalogue {
93
111
  const body = asObject(payload);
94
112
  const data = body === null ? null : body.data;
95
- if (!Array.isArray(data)) throw new Error('The model catalogue response could not be read.');
113
+ if (body === null || !Array.isArray(data)) {
114
+ throw new Error('The model catalogue response could not be read.');
115
+ }
96
116
 
97
117
  const entries: CatalogueEntry[] = [];
98
118
  for (const value of data) {
@@ -102,57 +122,53 @@ export function parseCatalogue(payload: unknown): CatalogueEntry[] {
102
122
  if (data.length > 0 && entries.length === 0) {
103
123
  throw new Error('The model catalogue response could not be read.');
104
124
  }
105
- return entries;
125
+ return {
126
+ entries,
127
+ defaultModelId: asText(body.defaultModelId),
128
+ featuredIds: asTexts(body.featuredIds),
129
+ };
106
130
  }
107
131
 
108
132
  export interface ModelSelection {
109
- /** What the caller asked for, which is what a picker keeps showing as chosen. */
110
- readonly requestedId: string;
111
- /** What a request should carry. Differs from `requestedId` only when replaced. */
112
- readonly effectiveId: string;
133
+ /** What the caller asked for; `undefined` is "the server's default". */
134
+ readonly requestedId: string | undefined;
135
+ /** What a request should carry as `model`; `undefined` means omit it. */
136
+ readonly effectiveId: string | undefined;
137
+ /** The entry the request will be answered by, when the catalogue says. */
138
+ readonly entry: CatalogueEntry | null;
113
139
  /** `replaced` when the requested identifier is not one the catalogue offers. */
114
- readonly source: 'requested' | 'replaced';
140
+ readonly source: 'requested' | 'default' | 'replaced';
115
141
  }
116
142
 
117
143
  /**
118
144
  * Resolve a requested identifier against the catalogue.
119
145
  *
120
- * The same three deliberate non-behaviours as the app's `resolveSelection`:
121
- *
146
+ * - **Nothing requested** → omit `model`; the server's default answers.
122
147
  * - **No catalogue — not loaded, or the request failed — leaves the choice
123
- * alone.** Replacing on missing data changes the model under the user on any
124
- * slow cold start, which is the same wrong answer as a retirement.
125
- * - **An entry reported UNAVAILABLE is still honoured.** That is a health
126
- * signal about the models behind an entry and the server already falls back
127
- * among them; picking a different one is a product decision nobody made.
128
- * - **`preferredId` is never trusted.** It is a build-time value, so it is
129
- * checked against the catalogue like any other and falls through when the
130
- * catalogue does not offer it.
148
+ * alone.** Replacing on missing data would change the model under the user
149
+ * on any slow cold start.
150
+ * - **An identifier the catalogue no longer lists** is not sent (it would be
151
+ * refused); the request omits `model` and the server's default answers.
131
152
  */
132
153
  export function resolveSelection(
133
- requestedId: string,
134
- entries: readonly CatalogueEntry[] | undefined,
135
- preferredId?: string,
154
+ requestedId: string | null | undefined,
155
+ catalogue: Catalogue | undefined,
136
156
  ): ModelSelection {
137
- if (entries === undefined) {
138
- return { requestedId, effectiveId: requestedId, source: 'requested' };
139
- }
157
+ const requested = requestedId === null || requestedId === '' ? undefined : requestedId;
158
+ const defaultEntry = (): CatalogueEntry | null =>
159
+ catalogue?.entries.find((entry) => entry.id === catalogue.defaultModelId) ?? null;
140
160
 
141
- const offered = entries.filter((entry) => entry.chatVisible);
142
- if (offered.some((entry) => entry.id === requestedId)) {
143
- return { requestedId, effectiveId: requestedId, source: 'requested' };
161
+ if (requested === undefined) {
162
+ return { requestedId: undefined, effectiveId: undefined, entry: defaultEntry(), source: 'default' };
144
163
  }
145
-
146
- const replacement =
147
- (preferredId === undefined
148
- ? undefined
149
- : offered.find((entry) => entry.id === preferredId)) ??
150
- offered.find((entry) => !entry.unavailable) ??
151
- offered[0];
152
- if (replacement === undefined) {
153
- return { requestedId, effectiveId: requestedId, source: 'requested' };
164
+ if (catalogue === undefined) {
165
+ return { requestedId: requested, effectiveId: requested, entry: null, source: 'requested' };
166
+ }
167
+ const entry = catalogue.entries.find((candidate) => candidate.id === requested);
168
+ if (entry !== undefined) {
169
+ return { requestedId: requested, effectiveId: requested, entry, source: 'requested' };
154
170
  }
155
- return { requestedId, effectiveId: replacement.id, source: 'replaced' };
171
+ return { requestedId: requested, effectiveId: undefined, entry: defaultEntry(), source: 'replaced' };
156
172
  }
157
173
 
158
174
  /**
@@ -162,9 +178,9 @@ export function resolveSelection(
162
178
  * request. A REJECTED promise is evicted, because caching a failure would make a
163
179
  * single cold-start network blip permanent for the life of the process.
164
180
  */
165
- const inFlight = new Map<string, Promise<CatalogueEntry[]>>();
181
+ const inFlight = new Map<string, Promise<Catalogue>>();
166
182
 
167
- export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<CatalogueEntry[]> {
183
+ export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<Catalogue> {
168
184
  const cached = inFlight.get(apiUrl);
169
185
  if (cached !== undefined) return cached;
170
186
 
@@ -187,23 +203,20 @@ export function clearCatalogueCache(): void {
187
203
  }
188
204
 
189
205
  /**
190
- * The identifier a request should carry, given what the caller asked for.
206
+ * The `model` a request should carry, given what the caller asked for, or
207
+ * `undefined` to omit it and let the server's default answer.
191
208
  *
192
209
  * Never throws: a catalogue that cannot be read leaves the requested identifier
193
- * alone, which is the same answer as "the catalogue has not loaded yet". A
194
- * consumer that wants to KNOW the catalogue is unreadable calls
195
- * {@link fetchCatalogue} directly.
210
+ * alone, which is the same answer as "the catalogue has not loaded yet".
196
211
  */
197
212
  export async function resolveModelId(
198
213
  apiUrl: string,
199
- requestedId: string,
214
+ requestedId: string | undefined,
200
215
  accessToken?: string,
201
- preferredId?: string,
202
- ): Promise<string> {
203
- if (requestedId.startsWith('mode:')) return requestedId;
216
+ ): Promise<string | undefined> {
217
+ if (requestedId === undefined || requestedId === '') return undefined;
204
218
  try {
205
- const entries = await fetchCatalogue(apiUrl, accessToken);
206
- return resolveSelection(requestedId, entries, preferredId).effectiveId;
219
+ return resolveSelection(requestedId, await fetchCatalogue(apiUrl, accessToken)).effectiveId;
207
220
  } catch {
208
221
  return requestedId;
209
222
  }
@@ -177,11 +177,6 @@ function validateIgnoredNamedEvent(eventName: string, payload: JsonObject): void
177
177
  requireEventVersion(payload, eventName);
178
178
 
179
179
  switch (eventName) {
180
- case 'alia.model_switch':
181
- assertOnlyKeys(payload, ['eventVersion', 'model', 'modelName'], eventName);
182
- asString(payload.model, `${eventName} model`);
183
- asString(payload.modelName, `${eventName} modelName`);
184
- return;
185
180
  case 'alia.suggest_new_conversation':
186
181
  assertOnlyKeys(payload, ['eventVersion', 'reason'], eventName);
187
182
  asString(payload.reason, `${eventName} reason`);
@@ -16,8 +16,18 @@ export interface AuthenticatedResponseClient {
16
16
 
17
17
  export interface AliaChatRequest {
18
18
  readonly url: string;
19
- readonly model: string;
19
+ /** `publisher/model`; omitted, the server's default model answers. */
20
+ readonly model?: string;
20
21
  readonly messages: ReadonlyArray<{ readonly role: string; readonly content: string }>;
22
+ /**
23
+ * `'voice'` asks for an answer meant to be heard: short, conversational, no
24
+ * formatting (the API layers its voice response profile over the turn's
25
+ * prompt). Omitted for ordinary text chat, which sends exactly what it
26
+ * always sent.
27
+ */
28
+ readonly responseMode?: 'voice';
29
+ /** The agent whose thread this turn belongs to, when there is one. */
30
+ readonly agentId?: string;
21
31
  }
22
32
 
23
33
  /**
@@ -39,9 +49,11 @@ export async function streamAliaChat(
39
49
  'Content-Type': 'application/json',
40
50
  },
41
51
  body: JSON.stringify({
42
- model: request.model,
52
+ ...(request.model === undefined ? {} : { model: request.model }),
43
53
  messages: request.messages,
44
54
  stream: true,
55
+ ...(request.responseMode === undefined ? {} : { responseMode: request.responseMode }),
56
+ ...(request.agentId === undefined ? {} : { agentId: request.agentId }),
45
57
  }),
46
58
  signal,
47
59
  });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The language to recognize when the caller names none.
3
+ *
4
+ * The browser's own language on web; the runtime's resolved locale on native
5
+ * (Hermes implements `Intl`); `en-US` when neither answers. An app should pass
6
+ * its UI locale instead — the Alia app does — because a person who chose
7
+ * Spanish in the app expects to be understood in Spanish whatever the device
8
+ * is set to.
9
+ */
10
+ export function defaultSpeechLanguage(): string {
11
+ const browser = globalThis.navigator?.language;
12
+ if (typeof browser === 'string' && browser !== '') return browser;
13
+ try {
14
+ const resolved = Intl.DateTimeFormat().resolvedOptions().locale;
15
+ if (resolved) return resolved;
16
+ } catch {
17
+ // No Intl on this runtime.
18
+ }
19
+ return 'en-US';
20
+ }
@@ -0,0 +1,76 @@
1
+ import type { SpeechRecognitionFailure } from './speech-recognition-types';
2
+
3
+ /**
4
+ * Every way dictation or a voice call can fail that a person is told about,
5
+ * as a stable code.
6
+ *
7
+ * The hooks expose the code beside the English sentence (`errorCode` /
8
+ * `turnErrorCode` next to `error` / `turnError`) so an app can say it in its
9
+ * own language; the sentence stays for a consumer that has no translations.
10
+ */
11
+ export type VoiceErrorCode =
12
+ | 'speech-unsupported'
13
+ | 'microphone-denied'
14
+ | 'microphone-busy'
15
+ | 'microphone-missing'
16
+ | 'speech-network'
17
+ | 'speech-language'
18
+ | 'speech-failed'
19
+ | 'voice-unavailable'
20
+ | 'not-authenticated'
21
+ | 'turn-failed'
22
+ | 'no-answer'
23
+ | 'not-played';
24
+
25
+ /**
26
+ * The English copy for each code — the same sentences dictation and voice mode
27
+ * used when they recorded through `expo-audio` and LiveKit, so moving
28
+ * recognition onto the device did not change what an error says.
29
+ */
30
+ export const VOICE_ERROR_MESSAGES: Readonly<Record<VoiceErrorCode, string>> = {
31
+ 'speech-unsupported': 'Speech recognition is not available on this device or browser',
32
+ 'microphone-denied': 'Microphone permission required',
33
+ 'microphone-busy': 'Microphone is in use by another application',
34
+ 'microphone-missing': 'No microphone found — check your input devices',
35
+ 'speech-network': 'Speech recognition needs a network connection',
36
+ 'speech-language': 'Speech recognition does not support this language',
37
+ 'speech-failed': 'Speech recognition failed',
38
+ 'voice-unavailable': 'Voice is not available on this device',
39
+ 'not-authenticated': 'Not authenticated',
40
+ 'turn-failed': 'Voice request failed',
41
+ 'no-answer': 'No answer came back — try saying it again',
42
+ 'not-played': 'The answer could not be played aloud',
43
+ };
44
+
45
+ /**
46
+ * The code for a recognition failure. `null` for the failures that are not
47
+ * errors: `aborted` is a stop the caller asked for, and `no-speech` is a
48
+ * silence the caller recovers from.
49
+ */
50
+ export function speechFailureCode(failure: SpeechRecognitionFailure): VoiceErrorCode | null {
51
+ switch (failure.code) {
52
+ case 'aborted':
53
+ case 'no-speech':
54
+ return null;
55
+ case 'unsupported':
56
+ return 'speech-unsupported';
57
+ case 'not-allowed':
58
+ return 'microphone-denied';
59
+ case 'audio-capture':
60
+ return failure.detail === 'NotReadableError' || failure.detail === 'TrackStartError'
61
+ ? 'microphone-busy'
62
+ : 'microphone-missing';
63
+ case 'network':
64
+ return 'speech-network';
65
+ case 'language-not-supported':
66
+ return 'speech-language';
67
+ case 'other':
68
+ return 'speech-failed';
69
+ }
70
+ }
71
+
72
+ /** The English copy a person sees when listening fails; `null` as for `speechFailureCode`. */
73
+ export function speechFailureMessage(failure: SpeechRecognitionFailure): string | null {
74
+ const code = speechFailureCode(failure);
75
+ return code === null ? null : VOICE_ERROR_MESSAGES[code];
76
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The one shape on-device speech recognition has in this package.
3
+ *
4
+ * Two engines sit behind it and neither leaks through: the browser's Web
5
+ * Speech API (`speech-recognition.ts`, picked by web bundlers and by tests) and
6
+ * `expo-speech-recognition` on iOS and Android (`speech-recognition.native.ts`,
7
+ * picked by Metro's platform resolution). Dictation and the voice loop are
8
+ * written against this file only, so the audio never leaves the device through
9
+ * Alia — there is no transcription endpoint, and nothing here posts audio
10
+ * anywhere. Whether the ENGINE uses a network service is the platform's
11
+ * business and is stated in `docs/voice.mdx`.
12
+ */
13
+
14
+ /** Why a recognition session failed, in the vocabulary both engines share. */
15
+ export type SpeechRecognitionFailureCode =
16
+ /** Microphone or speech-recognition permission was refused. */
17
+ | 'not-allowed'
18
+ /** No microphone, or it could not be opened. */
19
+ | 'audio-capture'
20
+ /** The engine needed the network and could not reach it. */
21
+ | 'network'
22
+ /** The engine does not recognize this language. */
23
+ | 'language-not-supported'
24
+ /** Nothing was said before the engine gave up. Recoverable: start again. */
25
+ | 'no-speech'
26
+ /** The session was aborted on purpose. Never an error to report. */
27
+ | 'aborted'
28
+ /** Recognition is not available on this device or in this browser. */
29
+ | 'unsupported'
30
+ /** Anything else — the engine was busy, interrupted, or said nothing useful. */
31
+ | 'other';
32
+
33
+ export interface SpeechRecognitionFailure {
34
+ readonly code: SpeechRecognitionFailureCode;
35
+ /** The engine's own words, for logs. Never shown as-is. */
36
+ readonly detail?: string;
37
+ }
38
+
39
+ export interface SpeechRecognitionHandlers {
40
+ /**
41
+ * The whole of what this session has heard so far, and whether the engine
42
+ * considers it settled. It is the FULL transcript each time, never a delta:
43
+ * both engines revise earlier words as more audio arrives.
44
+ */
45
+ onResult(result: { transcript: string; isFinal: boolean }): void;
46
+ /** Input level, 0..1, on the same scale as dictation's metering. */
47
+ onLevel?(level: number): void;
48
+ onError(failure: SpeechRecognitionFailure): void;
49
+ /** The session is over, whichever way it ended. Always the last call. */
50
+ onEnd(): void;
51
+ }
52
+
53
+ export interface SpeechRecognitionOptions {
54
+ /** BCP-47 tag, e.g. `es-ES`. */
55
+ readonly lang: string;
56
+ /**
57
+ * Android: the recognition service to use, by package, instead of the
58
+ * system's default. `chooseSpeechRecognizer` picks it.
59
+ */
60
+ readonly service?: string;
61
+ /**
62
+ * Remove the device's own playback from what the microphone hears, where the
63
+ * platform can (iOS voice processing). Voice mode listens while it speaks,
64
+ * so without this it can hear itself.
65
+ */
66
+ readonly echoCancellation?: boolean;
67
+ }
68
+
69
+ export interface SpeechRecognitionSession {
70
+ /** Stop listening and deliver a final result, then `onEnd`. */
71
+ stop(): void;
72
+ /** Stop listening and discard; `onEnd` still follows. */
73
+ abort(): void;
74
+ }
75
+
76
+ /** Map a raw 0..1-ish reading to the field's level, clamped. */
77
+ export function clampLevel(level: number): number {
78
+ if (!Number.isFinite(level)) return 0;
79
+ return Math.min(1, Math.max(0, level));
80
+ }
81
+
82
+ /**
83
+ * What to start a session with, decided before the first one opens.
84
+ *
85
+ * `silent` is set when the device has recognition services and every one of
86
+ * them, asked, named no language at all. A `language-not-supported` from such
87
+ * a device is not about the language: nothing on it recognizes speech (a Pixel
88
+ * without Google's speech services, whose remaining service has no language
89
+ * pack). Where nobody could be asked — the web, iOS — it is `false` and a
90
+ * language error stays one.
91
+ */
92
+ export type SpeechRecognizerChoice =
93
+ | { readonly lang: string; readonly service?: string; readonly silent: boolean }
94
+ | { readonly failure: SpeechRecognitionFailure };
95
+
96
+ /**
97
+ * The tag in `offered` that recognizes `lang`: the same tag (`en-US`, however
98
+ * the service spells it — `en_US`, `en-us`), else another region of the same
99
+ * language (`es-US` for `es-ES`), else `null`.
100
+ */
101
+ export function matchSpeechLocale(lang: string, offered: readonly string[]): string | null {
102
+ const norm = (tag: string) => tag.replace(/_/g, '-').toLowerCase();
103
+ const wanted = norm(lang);
104
+ const exact = offered.find((tag) => norm(tag) === wanted);
105
+ if (exact !== undefined) return exact;
106
+ const language = wanted.split('-')[0];
107
+ return offered.find((tag) => norm(tag).split('-')[0] === language) ?? null;
108
+ }