@lumavox/client 0.1.1 → 0.4.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/README.md CHANGED
@@ -26,7 +26,7 @@ const conversation = lumavox.connect(session.id, {
26
26
  });
27
27
 
28
28
  const outcome = await conversation.send("What should I see on my first morning?");
29
- // { status: "done", frame } | { status: "interrupted", frame } | { status: "dropped", reason }
29
+ // { status: "done", frame } | { status: "interrupted", frame } | { status: "dropped", reason, message }
30
30
  ```
31
31
 
32
32
  React and React Native: `@lumavox/react`, hooks over this.
@@ -55,15 +55,18 @@ for a 402, 403 or 429. `clearToken()` at sign-out.
55
55
  | Resource | Methods |
56
56
  |---|---|
57
57
  | `sessions` | `create`, `list` (every one), `page`, `get`, `history` (the whole transcript, newest first), `delete`, `compact`, `extract` |
58
- | `facts` | `list`, `delete`, `deleteAll` |
59
- | `relations` | `list` |
58
+ | `facts` | `list`, `page`, `delete`, `deleteAll` |
59
+ | `relations` | `list`, `page` |
60
60
 
61
61
  Bodies and answers are the API's own JSON, as the API reference shows them;
62
62
  the SDK's options go in a last argument: `{ timeoutMs, signal, maxRetries,
63
- requestId }`. A delete of what is already gone answers `null`. Errors are
63
+ requestId }`. `list()` is every item, page after page by each page's
64
+ `next_cursor`; `page()` is one page, `{ data, next_cursor }`; a session's
65
+ messages are a page inside it, read back by `messages.next_cursor`. A
66
+ delete answers `true`, and `false` for what is already gone. Errors are
64
67
  `PlatformError` and its subclasses — `AuthenticationError`,
65
68
  `PermissionError`, `NotFoundError`, `RateLimitError`, … — with `status`,
66
- `type`, `requestId`, `details` and `retryAfter`, as `@lumavox/server`'s.
69
+ `code`, `requestId`, `issues` and `retryAfter`, as `@lumavox/server`'s.
67
70
 
68
71
  ## A conversation
69
72
 
@@ -77,8 +80,9 @@ key, dials, authenticates, and from then on:
77
80
  connection limit, 0.5 s for anything else, ±20 % jitter, final after
78
81
  five in a row — and **rests** after the edge hangs up on a quiet
79
82
  connection (4007), dialling again on the next `send`;
80
- - **watches**: 60 s with nothing from the edge is a dead socket, dropped
81
- and dialled again;
83
+ - **watches**: it pings after 30 s with nothing from the edge, and a
84
+ minute with nothing — no `pong` — is a dead socket, dropped and dialled
85
+ again;
82
86
  - publishes `snapshot` — `state`, `ready`, the `turn` (its text, chunks,
83
87
  mood, tool, knowledge), `caption`, `relation`, `queued` — with
84
88
  `subscribe(fn)`.
package/dist/client.d.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  * - **Shaped as the server SDK is** (`sdks/server`): bodies and answers in
8
8
  * the wire's snake_case, camelCase methods, the SDK's options
9
9
  * (`timeoutMs`, `signal`, `maxRetries`, `requestId`) last; `list()` every
10
- * item, `page()` one page; a delete of what is already gone answers
11
- * `null`; the same `transport.ts` and `errors.ts`, copied.
10
+ * item, page after page by each page's `next_cursor`, and `page()` one
11
+ * page, `{ data, next_cursor }`; a delete answers `true`, and `false`
12
+ * for what is already gone; the same `transport.ts` and `errors.ts`,
13
+ * copied.
12
14
  * - **A key is asked for once, and replaced once**: a call refused with 401
13
15
  * asks for a new key and is sent again; a second refusal, on the fresh
14
16
  * key, is the caller's (`token.ts`). **Never on 402, 403 or 429**: on this
@@ -20,7 +22,7 @@
20
22
  * and later, browsers, React Native.
21
23
  */
22
24
  import { type ConnectOptions, Conversation, type ConversationHandlers } from "./conversation.js";
23
- import type { CompactionInfo, CompactRequest, FactList, ForgottenFacts, MessageInfo, RelationList, SessionCreateRequest, SessionDetail, SessionInfo } from "./gen/index.js";
25
+ import type { CompactionInfo, CompactRequest, FactInfo, FactPage, FactsDeleted, MessageInfo, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage } from "./gen/index.js";
24
26
  import type { WebSocketConstructor } from "./socket.js";
25
27
  import { type GetToken } from "./token.js";
26
28
  import { type Fetch, type RequestOptions } from "./transport.js";
@@ -48,49 +50,59 @@ export interface ClientOptions {
48
50
  export interface SessionListParams {
49
51
  /** How many a page holds, up to 200. */
50
52
  limit?: number;
51
- offset?: number;
53
+ /** `page()` only: the `next_cursor` of the page before. */
54
+ cursor?: string;
52
55
  }
53
56
  export interface SessionGetParams {
54
57
  /** Messages, counting back from the newest: 50 by default, up to 200. */
55
58
  limit?: number;
56
- /** Only messages before this `seq`: the last page's `next_before`. */
57
- before?: number;
59
+ /** The older messages: `messages.next_cursor` of the page before. */
60
+ cursor?: string;
58
61
  }
59
62
  export interface Sessions {
60
63
  /** Open a conversation with an agent — or resume the person's last, by default (`resume`). */
61
64
  create(body: SessionCreateRequest, options?: RequestOptions): Promise<SessionDetail>;
62
- /** Every one of the person's conversations, newest first, page after page. */
63
- list(params?: SessionListParams, options?: RequestOptions): AsyncGenerator<SessionInfo, void, undefined>;
64
- /** One page of them. */
65
- page(params?: SessionListParams, options?: RequestOptions): Promise<SessionInfo[]>;
66
- /** One conversation, with a page of its transcript. */
65
+ /** Every one of the person's conversations, most recent first, page after page. */
66
+ list(params?: Omit<SessionListParams, "cursor">, options?: RequestOptions): AsyncGenerator<SessionInfo, void, undefined>;
67
+ /** One page of them, with the `next_cursor` of the next. */
68
+ page(params?: SessionListParams, options?: RequestOptions): Promise<SessionPage>;
69
+ /** One conversation, with a page of its transcript: the newest messages, oldest first. */
67
70
  get(id: string, params?: SessionGetParams, options?: RequestOptions): Promise<SessionDetail>;
68
- /** Its whole transcript, newest message first, paging back by `next_before`. */
71
+ /** Its whole transcript, newest message first, paging back by `messages.next_cursor`. */
69
72
  history(id: string, options?: RequestOptions): AsyncGenerator<MessageInfo, void, undefined>;
70
- /** Delete it; `null` when it is already gone. */
71
- delete(id: string, options?: RequestOptions): Promise<SessionInfo | null>;
73
+ /** Delete it: `true`, or `false` when it is already gone. */
74
+ delete(id: string, options?: RequestOptions): Promise<boolean>;
72
75
  /** Summarise it now; `extract_facts` also stores what is worth remembering. */
73
76
  compact(id: string, body?: CompactRequest, options?: RequestOptions): Promise<CompactionInfo>;
74
77
  /** Compact it and extract facts, in one model call. */
75
78
  extract(id: string, options?: RequestOptions): Promise<CompactionInfo>;
76
79
  }
77
80
  export interface Facts {
78
- /** What is remembered about the person: the first 100, as the route answers (left-overs #109). */
79
- list(options?: RequestOptions): Promise<FactList>;
80
- /** Forget one; `null` when it is already gone. */
81
- delete(id: string, options?: RequestOptions): Promise<ForgottenFacts | null>;
82
- /** Forget everything about the person. Their conversations stay. */
83
- deleteAll(options?: RequestOptions): Promise<ForgottenFacts>;
81
+ /** Everything remembered about the person, oldest first, 200 a page. */
82
+ list(options?: RequestOptions): AsyncGenerator<FactInfo, void, undefined>;
83
+ /**
84
+ * One page of them, 100 by default; `cursor` is the `next_cursor` of the
85
+ * page before.
86
+ */
87
+ page(options?: RequestOptions, cursor?: string): Promise<FactPage>;
88
+ /** Forget one: `true`, or `false` when it is already gone. */
89
+ delete(id: string, options?: RequestOptions): Promise<boolean>;
90
+ /** Forget everything about the person: how many facts went. Their conversations stay. */
91
+ deleteAll(options?: RequestOptions): Promise<FactsDeleted>;
84
92
  }
85
93
  export interface RelationListParams {
86
94
  /** One agent's row alone. */
87
95
  agent_id?: string;
88
- /** Up to 50 by default; clamped to the route's cap, which `total` shows. */
96
+ /** How many a page holds: 50 by default, up to 200. */
89
97
  limit?: number;
98
+ /** `page()` only: the `next_cursor` of the page before. */
99
+ cursor?: string;
90
100
  }
91
101
  export interface Relations {
92
- /** How the agents the person talked to feel about them, most recent first. */
93
- list(params?: RelationListParams, options?: RequestOptions): Promise<RelationList>;
102
+ /** How the agents the person talked to feel about them, most recently active first. */
103
+ list(params?: Omit<RelationListParams, "cursor">, options?: RequestOptions): AsyncGenerator<RelationInfo, void, undefined>;
104
+ /** One page of them, with the `next_cursor` of the next. */
105
+ page(params?: RelationListParams, options?: RequestOptions): Promise<RelationPage>;
94
106
  }
95
107
  export interface Client {
96
108
  readonly sessions: Sessions;
package/dist/client.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * - **Shaped as the server SDK is** (`sdks/server`): bodies and answers in
8
8
  * the wire's snake_case, camelCase methods, the SDK's options
9
9
  * (`timeoutMs`, `signal`, `maxRetries`, `requestId`) last; `list()` every
10
- * item, `page()` one page; a delete of what is already gone answers
11
- * `null`; the same `transport.ts` and `errors.ts`, copied.
10
+ * item, page after page by each page's `next_cursor`, and `page()` one
11
+ * page, `{ data, next_cursor }`; a delete answers `true`, and `false`
12
+ * for what is already gone; the same `transport.ts` and `errors.ts`,
13
+ * copied.
12
14
  * - **A key is asked for once, and replaced once**: a call refused with 401
13
15
  * asks for a new key and is sent again; a second refusal, on the fresh
14
16
  * key, is the caller's (`token.ts`). **Never on 402, 403 or 429**: on this
@@ -26,6 +28,17 @@ import { orNull, segment, Transport, } from "./transport.js";
26
28
  export const DEFAULT_BASE_URL = "https://api.lumavox.ai";
27
29
  /** A page as large as the route allows: fewer calls for a long list. */
28
30
  const PAGE = 200;
31
+ /** Every item, page after page by cursor, until a page names no next one. */
32
+ async function* pages(read) {
33
+ let cursor;
34
+ do {
35
+ const page = await read(cursor);
36
+ yield* page.data;
37
+ cursor = page.next_cursor ?? undefined;
38
+ } while (cursor);
39
+ }
40
+ /** `true` once `promise` is done, `false` when it was a 404: what is not there is gone. */
41
+ const done = (promise) => orNull(promise).then((answer) => answer !== null);
29
42
  export function createClient(options) {
30
43
  if (typeof options?.getToken !== "function") {
31
44
  throw new TypeError("createClient needs getToken: a function answering the end user's ephemeral key.");
@@ -53,53 +66,50 @@ export function createClient(options) {
53
66
  throw error;
54
67
  const fresh = await tokens.replace(key, {
55
68
  status: 401,
56
- code: error.type,
69
+ code: error.code,
57
70
  });
58
71
  return (await transport.request({ ...call, token: fresh }, request))
59
72
  .data;
60
73
  }
61
74
  }
62
75
  const sessionPath = (id, rest = "") => `/v1/agent/sessions/${segment(id)}${rest}`;
63
- const page = (params = {}, request) => send({ method: "GET", path: "/v1/agent/sessions", query: { ...params } }, request);
76
+ /** One page of the list at `path`. */
77
+ const read = (path, params = {}, request) => send({ method: "GET", path, query: { ...params } }, request);
78
+ const page = (params = {}, request) => read("/v1/agent/sessions", params, request);
79
+ const factPage = (request, cursor, limit) => read("/v1/agent/facts", { limit, cursor }, request);
80
+ const relationPage = (params = {}, request) => read("/v1/agent/relations", params, request);
64
81
  const get = (id, params = {}, request) => send({ method: "GET", path: sessionPath(id), query: { ...params } }, request);
65
82
  return {
66
83
  sessions: {
67
84
  create: (body, request) => send({ method: "POST", path: "/v1/agent/sessions", body }, request),
68
- async *list(params = {}, request) {
69
- const limit = params.limit ?? PAGE;
70
- let offset = params.offset ?? 0;
71
- for (;;) {
72
- const items = await page({ limit, offset }, request);
73
- yield* items;
74
- offset += items.length;
75
- if (items.length < limit)
76
- return;
77
- }
78
- },
85
+ list: (params = {}, request) => pages((cursor) => page({ limit: PAGE, ...params, cursor }, request)),
79
86
  page,
80
87
  get,
81
88
  async *history(id, request) {
82
- let before;
89
+ let cursor;
83
90
  do {
84
- const detail = await get(id, { limit: PAGE, before: before ?? undefined }, request);
85
- for (let i = detail.messages.length - 1; i >= 0; i--) {
86
- yield detail.messages[i];
91
+ const { messages } = await get(id, { limit: PAGE, cursor: cursor ?? undefined }, request);
92
+ // Each page is oldest first; the history runs newest first.
93
+ for (let i = messages.data.length - 1; i >= 0; i--) {
94
+ yield messages.data[i];
87
95
  }
88
- before = detail.next_before;
89
- } while (before !== null && before !== undefined);
96
+ cursor = messages.next_cursor;
97
+ } while (cursor);
90
98
  },
91
- delete: (id, request) => orNull(send({ method: "DELETE", path: sessionPath(id) }, request)),
99
+ delete: (id, request) => done(send({ method: "DELETE", path: sessionPath(id) }, request)),
92
100
  // The document requires a body; none is `{}`, the agent's own setting.
93
101
  compact: (id, body = {}, request) => send({ method: "POST", path: sessionPath(id, "/compact"), body }, request),
94
102
  extract: (id, request) => send({ method: "POST", path: sessionPath(id, "/extract") }, request),
95
103
  },
96
104
  facts: {
97
- list: (request) => send({ method: "GET", path: "/v1/agent/facts" }, request),
98
- delete: (id, request) => orNull(send({ method: "DELETE", path: `/v1/agent/facts/${segment(id)}` }, request)),
105
+ list: (request) => pages((cursor) => factPage(request, cursor, PAGE)),
106
+ page: factPage,
107
+ delete: (id, request) => done(send({ method: "DELETE", path: `/v1/agent/facts/${segment(id)}` }, request)),
99
108
  deleteAll: (request) => send({ method: "DELETE", path: "/v1/agent/facts" }, request),
100
109
  },
101
110
  relations: {
102
- list: (params = {}, request) => send({ method: "GET", path: "/v1/agent/relations", query: { ...params } }, request),
111
+ list: (params = {}, request) => pages((cursor) => relationPage({ limit: PAGE, ...params, cursor }, request)),
112
+ page: relationPage,
103
113
  },
104
114
  connect(sessionId, handlers = {}, connectOptions = {}) {
105
115
  const WebSocket = options.WebSocket ??
@@ -22,10 +22,12 @@
22
22
  * had it — audio sent for it would close the socket (4011).
23
23
  * - **Speech is not paid for unheard**: with no `onAudio` handler every
24
24
  * turn says `speech: false`, unless `connect`'s options say otherwise.
25
- * - **A watchdog, no pings**: the edge sends `pong` after 30 s of client
26
- * silence, so 60 s with nothing from it, and nothing said for as long,
27
- * is a dead socket (half-open, after a phone changed networks): it is
28
- * dropped and dialled again. A ping never resets the edge's idle timer.
25
+ * - **A watchdog that pings**: after 30 s with nothing from the edge it
26
+ * sends `ping` (`KEEPALIVE_PING`, which the edge's runtime answers with
27
+ * its object asleep), and past a minute with nothing at all — no `pong`
28
+ * to two pings — the socket is dead (half-open, after a phone changed
29
+ * networks): it is dropped and dialled again. A ping never resets the
30
+ * edge's idle timer.
29
31
  * - **The app's handlers cannot break it**: one that throws is rethrown on
30
32
  * its own (`call`), after the state it was told about is in place.
31
33
  *
@@ -96,7 +98,7 @@ export type TurnOutcome = {
96
98
  export type TranscriptOutcome = {
97
99
  status: "done";
98
100
  text: string;
99
- seconds: number;
101
+ audioSeconds: number;
100
102
  frame: TranscriptFrame;
101
103
  }
102
104
  /** The edge refused or failed it: a rate it does not take, no audio, the provider. */
@@ -108,7 +110,8 @@ export type TranscriptOutcome = {
108
110
  export interface Dropped {
109
111
  status: "dropped";
110
112
  reason: "closed" | "cancelled" | "refused";
111
- detail?: string;
113
+ /** The edge's words for a refusal; `null` otherwise. */
114
+ message: string | null;
112
115
  }
113
116
  /** Why a conversation ended without reconnecting. */
114
117
  export interface CloseInfo {
@@ -235,7 +238,6 @@ export declare class Conversation {
235
238
  private deadline;
236
239
  private watchdog;
237
240
  private heardAt;
238
- private saidAt;
239
241
  constructor(setup: Setup);
240
242
  /** What is known now. A new object on every change: compare by identity. */
241
243
  get snapshot(): Snapshot;
@@ -276,7 +278,7 @@ export declare class Conversation {
276
278
  cancel(): void;
277
279
  /** Stop speaking, and let the turn finish: the rest of the reply is never spoken, nor billed. */
278
280
  mute(): void;
279
- /** Liveness only: answered with `pong`, and never counted as activity. Not needed — the SDK's watchdog listens for the edge's own. */
281
+ /** Liveness only: answered with `pong`, and never counted as activity. Not needed — the SDK pings after 30 s of silence itself. */
280
282
  ping(): void;
281
283
  /**
282
284
  * Dial now: the app came to the foreground, the network came back. A
@@ -1,5 +1,5 @@
1
1
  import { wireBytes } from "./pcm.js";
2
- import { KEEPALIVE_S, MAX_AUDIO_BYTES, MAX_MESSAGE_CHARS, RECORDING_RATES, } from "./protocol.gen.js";
2
+ import { KEEPALIVE_SECONDS, MAX_AUDIO_BYTES, MAX_MESSAGE_CHARS, RECORDING_RATES, } from "./protocol.gen.js";
3
3
  import { dial, isFinalClose, reconnectAfter, socketUrl, } from "./socket.js";
4
4
  /** No `ready` this long after the dial — a proxy that took the upgrade and says nothing — is a dropped connection. */
5
5
  export const READY_DEADLINE_MS = 15000;
@@ -52,9 +52,11 @@ function call(fn, value) {
52
52
  });
53
53
  }
54
54
  }
55
- const dropped = (reason, detail) => detail
56
- ? { status: "dropped", reason, detail }
57
- : { status: "dropped", reason };
55
+ const dropped = (reason, message) => ({
56
+ status: "dropped",
57
+ reason,
58
+ message: message ?? null,
59
+ });
58
60
  /** Characters as the edge counts them: code points. */
59
61
  function characters(text) {
60
62
  let n = 0;
@@ -104,7 +106,6 @@ export class Conversation {
104
106
  this.deadline = null;
105
107
  this.watchdog = null;
106
108
  this.heardAt = 0;
107
- this.saidAt = 0;
108
109
  this.sessionId = setup.sessionId;
109
110
  this.setup = setup;
110
111
  this.speech =
@@ -164,7 +165,7 @@ export class Conversation {
164
165
  recording.dropped = "closed";
165
166
  return id;
166
167
  }
167
- const frame = this.turnFrame({ type: "user_audio.start", id, rate }, options);
168
+ const frame = this.turnFrame({ type: "user_audio.start", recording_id: id, rate }, options);
168
169
  if (language)
169
170
  frame.language = language;
170
171
  if (model)
@@ -202,7 +203,6 @@ export class Conversation {
202
203
  recording.held.push(data.slice(0));
203
204
  else if (recording.socket === this.generation && this.socket) {
204
205
  this.socket.sendBinary(data);
205
- this.saidAt = Date.now();
206
206
  }
207
207
  return true;
208
208
  }
@@ -217,7 +217,7 @@ export class Conversation {
217
217
  if (failed) {
218
218
  return Promise.resolve(options.transcribeOnly
219
219
  ? { status: "failed", frame: failed }
220
- : dropped("refused", failed.detail));
220
+ : dropped("refused", failed.message));
221
221
  }
222
222
  if (recording.dropped || this.closed) {
223
223
  return Promise.resolve(dropped(recording.dropped ?? "closed"));
@@ -249,7 +249,7 @@ export class Conversation {
249
249
  mute() {
250
250
  this.socket?.send({ type: "mute" });
251
251
  }
252
- /** Liveness only: answered with `pong`, and never counted as activity. Not needed — the SDK's watchdog listens for the edge's own. */
252
+ /** Liveness only: answered with `pong`, and never counted as activity. Not needed — the SDK pings after 30 s of silence itself. */
253
253
  ping() {
254
254
  this.socket?.send({ type: "ping" });
255
255
  }
@@ -322,15 +322,17 @@ export class Conversation {
322
322
  this.finish(0, "The socket could not be opened.", true, error);
323
323
  return;
324
324
  }
325
- this.heardAt = this.saidAt = Date.now();
325
+ this.heardAt = Date.now();
326
326
  this.socketReady = false;
327
327
  this.deadline = setTimeout(() => this.drop(generation, "The connection never became ready."), READY_DEADLINE_MS);
328
328
  this.watchdog = setInterval(() => {
329
- const quiet = Date.now() - Math.max(this.heardAt, this.saidAt);
330
- if (quiet > 2 * KEEPALIVE_S * 1000) {
329
+ const quiet = Date.now() - this.heardAt;
330
+ if (quiet > 2 * KEEPALIVE_SECONDS * 1000) {
331
331
  this.drop(generation, "Nothing came from the edge for a minute.");
332
332
  }
333
- }, KEEPALIVE_S * 500);
333
+ else if (quiet >= KEEPALIVE_SECONDS * 1000)
334
+ this.ping();
335
+ }, KEEPALIVE_SECONDS * 500);
334
336
  }
335
337
  /** A socket given up on from this side: lost as a dropped network is (1006). */
336
338
  drop(generation, reason) {
@@ -586,7 +588,7 @@ export class Conversation {
586
588
  return false;
587
589
  }
588
590
  error(frame) {
589
- if (frame.id) {
591
+ if (frame.recording_id) {
590
592
  this.refuse(frame);
591
593
  return;
592
594
  }
@@ -597,14 +599,14 @@ export class Conversation {
597
599
  const pending = this.inFlight;
598
600
  if (pending && !pending.started) {
599
601
  this.inFlight = null;
600
- pending.resolve(dropped("refused", frame.detail));
602
+ pending.resolve(dropped("refused", frame.message));
601
603
  this.update({ done: true });
602
604
  this.sync();
603
605
  }
604
606
  }
605
607
  /** An error naming a recording: it was refused, or failed. */
606
608
  refuse(frame) {
607
- const { id } = frame;
609
+ const id = frame.recording_id;
608
610
  const open = this.open;
609
611
  if (open && open.id === id) {
610
612
  open.failed = frame;
@@ -618,23 +620,23 @@ export class Conversation {
618
620
  const pending = this.inFlight;
619
621
  if (pending && pending.recording?.id === id && !pending.started) {
620
622
  this.inFlight = null;
621
- pending.resolve(dropped("refused", frame.detail));
623
+ pending.resolve(dropped("refused", frame.message));
622
624
  this.update({ done: true });
623
625
  }
624
626
  this.emit({ caption: null });
625
627
  this.sync();
626
628
  }
627
629
  transcript(frame) {
628
- // By its id; the oldest, from an edge that echoes none.
629
- const recording = frame.id
630
- ? this.hearing.find((r) => r.id === frame.id)
630
+ // By its id; the oldest, when it names none.
631
+ const recording = frame.recording_id
632
+ ? this.hearing.find((r) => r.id === frame.recording_id)
631
633
  : this.hearing[0];
632
634
  if (recording) {
633
635
  this.hearing = this.hearing.filter((r) => r !== recording);
634
636
  recording.settle?.({
635
637
  status: "done",
636
638
  text: frame.text,
637
- seconds: frame.seconds,
639
+ audioSeconds: frame.audio_seconds,
638
640
  frame,
639
641
  });
640
642
  }
@@ -691,7 +693,6 @@ export class Conversation {
691
693
  this.inFlight = next;
692
694
  next.sent += 1;
693
695
  this.socket.send(next.frame);
694
- this.saidAt = Date.now();
695
696
  this.emit({ turn: FRESH });
696
697
  }
697
698
  this.emit({ queued: this.queue.length });
@@ -717,7 +718,6 @@ export class Conversation {
717
718
  }
718
719
  said(recording) {
719
720
  recording.socket = this.generation;
720
- this.saidAt = Date.now();
721
721
  }
722
722
  /** A recording's words are still to come: a partial is a caption. */
723
723
  listening() {
package/dist/errors.d.ts CHANGED
@@ -4,43 +4,51 @@
4
4
  * caller acts on differently.
5
5
  *
6
6
  * The API answers its errors in one envelope —
7
- * `{"error":{"type","message","request_id","details"?}}`
8
- * (`apps/api/src/http/errors.ts`) — and `type` is the thing to switch on.
9
- * Two bodies are not that envelope, and are read here too:
10
- * - **the 400 the document declares** on every route with a body or a path
11
- * parameter, `{status_code, detail, extra}`;
12
- * - **Cloudflare's own refusal**, `error code: 1010` as plain text with no
13
- * `X-Request-Id`: the request never reached the API, so there is no id
14
- * to report.
7
+ * `{"error":{"code","message","request_id","issues"}}`
8
+ * (`apps/api/src/http/errors.ts`) — and `code` is the thing to switch on.
9
+ * One body is not that envelope, and is read here too: **Cloudflare's own
10
+ * refusal**, `error code: 1010` as plain text with no `X-Request-Id` — the
11
+ * request never reached the API, so there is no id to report.
15
12
  */
13
+ import type { ErrorCode, ValidationIssue } from "./gen/index.js";
14
+ export type { ErrorCode, ValidationIssue };
16
15
  /** What one error says, as the SDK read it. */
17
16
  export interface PlatformErrorInit {
18
17
  status: number;
19
- type: string;
18
+ code: PlatformErrorCode;
20
19
  message: string;
21
20
  requestId: string | null;
22
- details?: unknown;
21
+ issues?: ValidationIssue[];
23
22
  retryAfter?: number | null;
24
23
  cause?: unknown;
25
24
  }
25
+ /**
26
+ * The API's codes, and the SDK's own for an answer that was not the API's:
27
+ * `cloudflare_error`, `http_error` (an error body that was not the
28
+ * envelope), `invalid_response` (a success that was not JSON),
29
+ * `connection_error` and `timeout` — and `client_error`, no request at
30
+ * all: the app's own code (its `getToken`) threw, as `@lumavox/react`
31
+ * reports it.
32
+ */
33
+ export type PlatformErrorCode = ErrorCode | "cloudflare_error" | "http_error" | "invalid_response" | "connection_error" | "timeout" | "client_error";
26
34
  export declare class PlatformError extends Error {
27
35
  name: string;
28
36
  /** The HTTP status; `0` when no response came back. */
29
37
  readonly status: number;
30
- /** The API's machine-readable category: `validation_error`, `rate_limit_error`, … */
31
- readonly type: string;
38
+ /** What went wrong: the API's `code` — `validation_failed`, `rate_limited`, … */
39
+ readonly code: PlatformErrorCode;
32
40
  /**
33
41
  * The request's `X-Request-Id` — the one the SDK sent, which the API
34
42
  * keeps. `null` only when Cloudflare refused it before the API saw it.
35
43
  */
36
44
  readonly requestId: string | null;
37
- /** Field-level problems on a 422; the 400's `extra`; otherwise `undefined`. */
38
- readonly details: unknown;
45
+ /** Where a request failed validation; empty otherwise. */
46
+ readonly issues: ValidationIssue[];
39
47
  /** Seconds to wait before trying again, from `Retry-After`; `null` without one. */
40
48
  readonly retryAfter: number | null;
41
49
  constructor(init: PlatformErrorInit);
42
50
  }
43
- /** 400 or 422: the request is malformed or failed validation; `details` says where. */
51
+ /** 400 or 422: the request is malformed or failed validation; `issues` says where. */
44
52
  export declare class ValidationError extends PlatformError {
45
53
  name: string;
46
54
  }
@@ -60,7 +68,7 @@ export declare class PermissionError extends PlatformError {
60
68
  export declare class NotFoundError extends PlatformError {
61
69
  name: string;
62
70
  }
63
- /** 409: a plan's maximum, or an id already taken. */
71
+ /** 409: a plan's maximum, an id already taken, or a change since it was read. */
64
72
  export declare class ConflictError extends PlatformError {
65
73
  name: string;
66
74
  }
package/dist/errors.js CHANGED
@@ -1,18 +1,3 @@
1
- /**
2
- * Every failure the SDK throws: a `PlatformError`, so one `catch` with
3
- * `instanceof PlatformError` sees them all, and a subclass per status a
4
- * caller acts on differently.
5
- *
6
- * The API answers its errors in one envelope —
7
- * `{"error":{"type","message","request_id","details"?}}`
8
- * (`apps/api/src/http/errors.ts`) — and `type` is the thing to switch on.
9
- * Two bodies are not that envelope, and are read here too:
10
- * - **the 400 the document declares** on every route with a body or a path
11
- * parameter, `{status_code, detail, extra}`;
12
- * - **Cloudflare's own refusal**, `error code: 1010` as plain text with no
13
- * `X-Request-Id`: the request never reached the API, so there is no id
14
- * to report.
15
- */
16
1
  export class PlatformError extends Error {
17
2
  constructor(init) {
18
3
  // `init` is the options too: `Error` reads only its `cause`.
@@ -20,13 +5,13 @@ export class PlatformError extends Error {
20
5
  // Written out, not `new.target.name`: a customer's minifier renames classes.
21
6
  this.name = "PlatformError";
22
7
  this.status = init.status;
23
- this.type = init.type;
8
+ this.code = init.code;
24
9
  this.requestId = init.requestId;
25
- this.details = init.details;
10
+ this.issues = init.issues ?? [];
26
11
  this.retryAfter = init.retryAfter ?? null;
27
12
  }
28
13
  }
29
- /** 400 or 422: the request is malformed or failed validation; `details` says where. */
14
+ /** 400 or 422: the request is malformed or failed validation; `issues` says where. */
30
15
  export class ValidationError extends PlatformError {
31
16
  constructor() {
32
17
  super(...arguments);
@@ -61,7 +46,7 @@ export class NotFoundError extends PlatformError {
61
46
  this.name = "NotFoundError";
62
47
  }
63
48
  }
64
- /** 409: a plan's maximum, or an id already taken. */
49
+ /** 409: a plan's maximum, an id already taken, or a change since it was read. */
65
50
  export class ConflictError extends PlatformError {
66
51
  constructor() {
67
52
  super(...arguments);
@@ -132,34 +117,29 @@ export function errorFromResponse(status, headers, text, sentRequestId) {
132
117
  body = undefined;
133
118
  }
134
119
  const e = isObject(body) && isObject(body.error) ? body.error : null;
135
- const code = /^error code: (\d+)/.exec(text.trim())?.[1];
120
+ const cloudflare = /^error code: (\d+)/.exec(text.trim())?.[1];
136
121
  const snippet = text.trim().slice(0, 200);
137
122
  const said = e
138
123
  ? {
139
- type: typeof e.type === "string" ? e.type : "unknown_error",
124
+ code: typeof e.code === "string"
125
+ ? e.code
126
+ : "http_error",
140
127
  message: typeof e.message === "string" ? e.message : http,
141
128
  requestId: typeof e.request_id === "string" ? e.request_id : requestId,
142
- details: e.details ?? undefined,
129
+ issues: Array.isArray(e.issues) ? e.issues : [],
143
130
  }
144
- : isObject(body) && typeof body.detail === "string"
131
+ : cloudflare && answered === null
145
132
  ? {
146
- type: "invalid_request",
147
- message: body.detail,
148
- requestId,
149
- details: body.extra ?? undefined,
133
+ code: "cloudflare_error",
134
+ message: `Cloudflare refused the request with error code ${cloudflare} ` +
135
+ "before it reached the API.",
136
+ requestId: null,
150
137
  }
151
- : code && answered === null
152
- ? {
153
- type: "cloudflare_error",
154
- message: `Cloudflare refused the request with error code ${code} ` +
155
- "before it reached the API.",
156
- requestId: null,
157
- }
158
- : {
159
- type: "http_error",
160
- message: snippet ? `${http}: ${snippet}` : http,
161
- requestId,
162
- };
138
+ : {
139
+ code: "http_error",
140
+ message: snippet ? `${http}: ${snippet}` : http,
141
+ requestId,
142
+ };
163
143
  return new (classOf(status))({
164
144
  ...said,
165
145
  status,