@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 +11 -7
- package/dist/client.d.ts +35 -23
- package/dist/client.js +35 -25
- package/dist/conversation.d.ts +10 -8
- package/dist/conversation.js +23 -23
- package/dist/errors.d.ts +24 -16
- package/dist/errors.js +19 -39
- package/dist/events.d.ts +28 -22
- package/dist/gen/index.d.ts +1 -1
- package/dist/gen/types.gen.d.ts +211 -260
- package/dist/protocol.gen.d.ts +7 -4
- package/dist/protocol.gen.js +7 -4
- package/dist/socket.js +6 -8
- package/dist/token.d.ts +1 -1
- package/dist/transport.js +3 -3
- package/package.json +1 -1
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 }`.
|
|
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
|
-
`
|
|
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**:
|
|
81
|
-
and dialled
|
|
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,
|
|
11
|
-
* `
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
57
|
-
|
|
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,
|
|
63
|
-
list(params?: SessionListParams, options?: RequestOptions): AsyncGenerator<SessionInfo, void, undefined>;
|
|
64
|
-
/** One page of them. */
|
|
65
|
-
page(params?: SessionListParams, options?: RequestOptions): Promise<
|
|
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 `
|
|
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
|
|
71
|
-
delete(id: string, options?: RequestOptions): Promise<
|
|
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
|
-
/**
|
|
79
|
-
list(options?: RequestOptions):
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
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
|
|
93
|
-
list(params?: RelationListParams, options?: RequestOptions):
|
|
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,
|
|
11
|
-
* `
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
89
|
+
let cursor;
|
|
83
90
|
do {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
} while (
|
|
96
|
+
cursor = messages.next_cursor;
|
|
97
|
+
} while (cursor);
|
|
90
98
|
},
|
|
91
|
-
delete: (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) =>
|
|
98
|
-
|
|
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) =>
|
|
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 ??
|
package/dist/conversation.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/conversation.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { wireBytes } from "./pcm.js";
|
|
2
|
-
import {
|
|
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,
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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() -
|
|
330
|
-
if (quiet > 2 *
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
629
|
-
const recording = frame.
|
|
630
|
-
? this.hearing.find((r) => r.id === frame.
|
|
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
|
-
|
|
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":{"
|
|
8
|
-
* (`apps/api/src/http/errors.ts`) — and `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
18
|
+
code: PlatformErrorCode;
|
|
20
19
|
message: string;
|
|
21
20
|
requestId: string | null;
|
|
22
|
-
|
|
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
|
-
/**
|
|
31
|
-
readonly
|
|
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
|
-
/**
|
|
38
|
-
readonly
|
|
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; `
|
|
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,
|
|
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.
|
|
8
|
+
this.code = init.code;
|
|
24
9
|
this.requestId = init.requestId;
|
|
25
|
-
this.
|
|
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; `
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
issues: Array.isArray(e.issues) ? e.issues : [],
|
|
143
130
|
}
|
|
144
|
-
:
|
|
131
|
+
: cloudflare && answered === null
|
|
145
132
|
? {
|
|
146
|
-
|
|
147
|
-
message:
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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,
|