@lumavox/client 0.1.1 → 0.2.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
 
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,60 @@ 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
+ /**
82
+ * Everything remembered about the person, oldest first — the newest 100
83
+ * for now, in one page (left-overs #109); read on by cursor the day the
84
+ * route pages.
85
+ */
86
+ list(options?: RequestOptions): AsyncGenerator<FactInfo, void, undefined>;
87
+ /** One page of them, as the route answers it. */
88
+ page(options?: RequestOptions): Promise<FactPage>;
89
+ /** Forget one: `true`, or `false` when it is already gone. */
90
+ delete(id: string, options?: RequestOptions): Promise<boolean>;
91
+ /** Forget everything about the person: how many facts went. Their conversations stay. */
92
+ deleteAll(options?: RequestOptions): Promise<FactsDeleted>;
84
93
  }
85
94
  export interface RelationListParams {
86
95
  /** One agent's row alone. */
87
96
  agent_id?: string;
88
- /** Up to 50 by default; clamped to the route's cap, which `total` shows. */
97
+ /** How many a page holds: 50 by default, up to 200. */
89
98
  limit?: number;
99
+ /** `page()` only: the `next_cursor` of the page before. */
100
+ cursor?: string;
90
101
  }
91
102
  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>;
103
+ /** How the agents the person talked to feel about them, most recently active first. */
104
+ list(params?: Omit<RelationListParams, "cursor">, options?: RequestOptions): AsyncGenerator<RelationInfo, void, undefined>;
105
+ /** One page of them, with the `next_cursor` of the next. */
106
+ page(params?: RelationListParams, options?: RequestOptions): Promise<RelationPage>;
94
107
  }
95
108
  export interface Client {
96
109
  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) => read("/v1/agent/facts", { 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)),
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 ??
@@ -96,7 +96,7 @@ export type TurnOutcome = {
96
96
  export type TranscriptOutcome = {
97
97
  status: "done";
98
98
  text: string;
99
- seconds: number;
99
+ audioSeconds: number;
100
100
  frame: TranscriptFrame;
101
101
  }
102
102
  /** The edge refused or failed it: a rate it does not take, no audio, the provider. */
@@ -108,7 +108,8 @@ export type TranscriptOutcome = {
108
108
  export interface Dropped {
109
109
  status: "dropped";
110
110
  reason: "closed" | "cancelled" | "refused";
111
- detail?: string;
111
+ /** The edge's words for a refusal; `null` otherwise. */
112
+ message: string | null;
112
113
  }
113
114
  /** Why a conversation ended without reconnecting. */
114
115
  export interface CloseInfo {
@@ -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;
@@ -164,7 +166,7 @@ export class Conversation {
164
166
  recording.dropped = "closed";
165
167
  return id;
166
168
  }
167
- const frame = this.turnFrame({ type: "user_audio.start", id, rate }, options);
169
+ const frame = this.turnFrame({ type: "user_audio.start", recording_id: id, rate }, options);
168
170
  if (language)
169
171
  frame.language = language;
170
172
  if (model)
@@ -217,7 +219,7 @@ export class Conversation {
217
219
  if (failed) {
218
220
  return Promise.resolve(options.transcribeOnly
219
221
  ? { status: "failed", frame: failed }
220
- : dropped("refused", failed.detail));
222
+ : dropped("refused", failed.message));
221
223
  }
222
224
  if (recording.dropped || this.closed) {
223
225
  return Promise.resolve(dropped(recording.dropped ?? "closed"));
@@ -327,10 +329,10 @@ export class Conversation {
327
329
  this.deadline = setTimeout(() => this.drop(generation, "The connection never became ready."), READY_DEADLINE_MS);
328
330
  this.watchdog = setInterval(() => {
329
331
  const quiet = Date.now() - Math.max(this.heardAt, this.saidAt);
330
- if (quiet > 2 * KEEPALIVE_S * 1000) {
332
+ if (quiet > 2 * KEEPALIVE_SECONDS * 1000) {
331
333
  this.drop(generation, "Nothing came from the edge for a minute.");
332
334
  }
333
- }, KEEPALIVE_S * 500);
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
  }
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,
package/dist/events.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Every frame the edge sends, as it sends it: the wire's own field names,
3
3
  * snake_case included, so the docs' WebSocket page and these types read
4
- * the same.
4
+ * the same — and every field always there, `null` where it does not apply.
5
5
  *
6
6
  * **Hand-written, and held twice.** `ServerFrames` is keyed by the event
7
7
  * names `protocol.gen.ts` takes from the edge's vocabulary, so an event the
@@ -9,7 +9,7 @@
9
9
  * test (`apps/edge/test/sdk.test.ts`) holds every field of every frame the
10
10
  * real conversation object sends to these, both ways.
11
11
  */
12
- import type { RelationInfo } from "./gen/index.js";
12
+ import type { ErrorCode, RelationInfo } from "./gen/index.js";
13
13
  import type { ServerEventName } from "./protocol.gen.js";
14
14
  interface Frame<T extends ServerEventName> {
15
15
  type: T;
@@ -51,7 +51,8 @@ export interface TurnStartFrame extends Frame<"turn.start">, InTurn {
51
51
  /** A spoken turn's transcript, before the reply. */
52
52
  export interface HeardFrame extends Frame<"heard">, InTurn {
53
53
  text: string;
54
- seconds: number;
54
+ /** How long the recording was. */
55
+ audio_seconds: number;
55
56
  }
56
57
  /** The open recording as heard so far: a caption, replaced by the next. */
57
58
  export interface HeardPartialFrame extends Frame<"heard.partial"> {
@@ -60,10 +61,11 @@ export interface HeardPartialFrame extends Frame<"heard.partial"> {
60
61
  /** A `transcribeOnly` recording's words: part of no turn. */
61
62
  export interface TranscriptFrame extends Frame<"transcript"> {
62
63
  text: string;
63
- seconds: number;
64
+ /** How long the recording was. */
65
+ audio_seconds: number;
64
66
  request_id: string;
65
67
  /** The recording's id, which the SDK names every recording with. */
66
- id?: string;
68
+ recording_id: string | null;
67
69
  }
68
70
  export interface MoodFrame extends Frame<"mood">, InTurn {
69
71
  mood: string;
@@ -109,7 +111,7 @@ export interface AudioStartFrame extends Frame<"audio.start">, InTurn, AudioForm
109
111
  export interface AudioEndFrame extends Frame<"audio.end">, InTurn {
110
112
  chunk: number;
111
113
  bytes: number;
112
- audio_s: number;
114
+ audio_seconds: number;
113
115
  }
114
116
  /** After `mute`, once the last audio window has closed. */
115
117
  export interface MutedFrame extends Frame<"muted">, InTurn {
@@ -150,10 +152,8 @@ export interface TurnDoneFrame extends Frame<"turn.done">, InTurn {
150
152
  output_tokens: number | null;
151
153
  mood: string | null;
152
154
  mood_intensity: number | null;
153
- /** Always `null`: a relationship that moved arrives as its own frame. */
154
- relation: null;
155
- /** `true` when the reply was cut short. */
156
- truncated: true | null;
155
+ /** Whether the reply was cut short. */
156
+ truncated: boolean;
157
157
  chunks_spoken: number;
158
158
  heard: string | null;
159
159
  heard_seconds: number | null;
@@ -164,22 +164,27 @@ export interface InterruptedFrame extends Frame<"interrupted">, InTurn {
164
164
  message: string;
165
165
  mood: string | null;
166
166
  mood_intensity: number | null;
167
- detail: string | null;
167
+ /** Why it stopped: `cancel`, or the failure its `error` named. */
168
+ reason: string | null;
168
169
  }
169
170
  /**
170
171
  * Something went wrong. It does not end the connection, and inside a turn
171
- * it does not end the turn. `detail` alone: a frame refused; `stage:
172
- * "speech"`: the reply could not be spoken; `status`: the turn or a
173
- * `transcribeOnly` recording failed.
172
+ * it does not end the turn. `code` is the REST API's (`conflict` for a
173
+ * turn already running, `insufficient_credits`, `provider_failed`, …);
174
+ * `stage: "speech"` when only the speaking failed.
174
175
  */
175
176
  export interface ErrorFrame extends Frame<"error"> {
176
- detail: string;
177
- turn?: string;
178
- status?: number;
179
- stage?: string;
180
- request_id?: string;
177
+ code: ErrorCode;
178
+ message: string;
179
+ /** The turn it ended or interrupted. */
180
+ turn: string | null;
181
+ stage: "speech" | null;
182
+ /** The reply's chunk that could not be spoken. */
183
+ chunk: number | null;
184
+ /** A `transcribeOnly` recording's request. */
185
+ request_id: string | null;
181
186
  /** The recording it refused or failed. */
182
- id?: string;
187
+ recording_id: string | null;
183
188
  }
184
189
  /** The answer to `ping`, and sent unprompted after 30 s of client silence. */
185
190
  export interface PongFrame extends Frame<"pong"> {