@lumavox/client 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lumavox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # @lumavox/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Talk to Lumavox agents from an end user's device — a browser, a React
4
+ Native app, Node — with **their ephemeral key**, never your API key: a
5
+ conversation on the agent's socket that keeps itself connected, and the
6
+ agent's REST routes for sessions, memory and relationships. **Zero
7
+ dependencies.**
8
+
9
+ ```sh
10
+ npm install @lumavox/client
11
+ ```
12
+
13
+ ```ts
14
+ import { createClient } from "@lumavox/client";
15
+
16
+ const lumavox = createClient({
17
+ // Your server mints the key with your API key (POST /v1/ephemeral-keys).
18
+ getToken: async () => (await fetch("/api/lumavox-key")).text(),
19
+ });
20
+
21
+ const session = await lumavox.sessions.create({ agent_id: "agt_..." });
22
+
23
+ const conversation = lumavox.connect(session.id, {
24
+ onText: ({ text }) => render(text),
25
+ onDone: ({ message }) => save(message),
26
+ });
27
+
28
+ const outcome = await conversation.send("What should I see on my first morning?");
29
+ // { status: "done", frame } | { status: "interrupted", frame } | { status: "dropped", reason, message }
30
+ ```
31
+
32
+ React and React Native: `@lumavox/react`, hooks over this.
33
+
34
+ The guide, with every example: the docs' **SDKs** tab, *Client SDK*.
35
+
36
+ ## The client
37
+
38
+ ```ts
39
+ createClient({
40
+ getToken, // ({ reason, status?, code? }) => key; throw to stop
41
+ baseUrl: "https://api.lumavox.ai", // the default
42
+ socketUrl, // baseUrl as wss:, plus /v1/agent/stream, by default
43
+ timeoutMs: 30_000, // per attempt
44
+ maxRetries: 2,
45
+ fetch, // the runtime's own by default
46
+ WebSocket, // the runtime's own by default; `ws` on Node before 22
47
+ });
48
+ ```
49
+
50
+ **The key** is asked for once, kept, and replaced once when the platform
51
+ refuses it — an HTTP 401, or the socket closed with 4002 to 4005 — however
52
+ many calls were refused with it: every mint spends credit up front. Never
53
+ for a 402, 403 or 429. `clearToken()` at sign-out.
54
+
55
+ | Resource | Methods |
56
+ |---|---|
57
+ | `sessions` | `create`, `list` (every one), `page`, `get`, `history` (the whole transcript, newest first), `delete`, `compact`, `extract` |
58
+ | `facts` | `list`, `page`, `delete`, `deleteAll` |
59
+ | `relations` | `list`, `page` |
60
+
61
+ Bodies and answers are the API's own JSON, as the API reference shows them;
62
+ the SDK's options go in a last argument: `{ timeoutMs, signal, maxRetries,
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
67
+ `PlatformError` and its subclasses — `AuthenticationError`,
68
+ `PermissionError`, `NotFoundError`, `RateLimitError`, … — with `status`,
69
+ `code`, `requestId`, `issues` and `retryAfter`, as `@lumavox/server`'s.
70
+
71
+ ## A conversation
72
+
73
+ `connect(sessionId, handlers, options)` answers at once. It asks for the
74
+ key, dials, authenticates, and from then on:
75
+
76
+ - **queues** what you send behind the agent's greeting, a running turn, a
77
+ reconnect or a quiet spell; `send` answers how the turn ended and never
78
+ rejects;
79
+ - **reconnects**: a new key once for 4002–4005, 5 s backoff for a rate or
80
+ connection limit, 0.5 s for anything else, ±20 % jitter, final after
81
+ five in a row — and **rests** after the edge hangs up on a quiet
82
+ connection (4007), dialling again on the next `send`;
83
+ - **watches**: 60 s with nothing from the edge is a dead socket, dropped
84
+ and dialled again;
85
+ - publishes `snapshot` — `state`, `ready`, the `turn` (its text, chunks,
86
+ mood, tool, knowledge), `caption`, `relation`, `queued` — with
87
+ `subscribe(fn)`.
88
+
89
+ | Handler | Frame |
90
+ |---|---|
91
+ | `onReady` `onTurnStart` `onHeard` `onHeardPartial` `onKnowledge` `onMood` `onThought` `onText` `onChunk` `onToolStart` `onToolEnd` `onUsage` `onMuted` `onAudioStart` `onAudioEnd` `onTranscript` `onDone` `onInterrupted` `onError` | the wire's frame, as it is |
92
+ | `onAudio` | `{ pcm, chunk, turn, sample_rate, channels }`: raw 16-bit PCM, never copied |
93
+ | `onRelation` | the relationship, each change once |
94
+ | `onState` | `connecting` `ready` `busy` `reconnecting` `dormant` `closed` |
95
+ | `onDisconnect` | every socket that closed, recovered from or not: `{ code, reason, ready }` |
96
+ | `onClose` | `{ code, reason, final }`, only when it will not reconnect |
97
+ | `onWarning` | a gap in the frames, one it could not read, audio past 10 MB |
98
+ | `onEvent` | every frame |
99
+
100
+ With no `onAudio` handler every turn asks for no speech, so nothing pays
101
+ for audio nobody hears; `connect`'s `{ speech }` overrides that.
102
+
103
+ ### Speaking
104
+
105
+ ```ts
106
+ conversation.startAudio({ rate: 16_000 }); // one of RECORDING_RATES
107
+ conversation.sendAudio(pcm); // Int16Array or ArrayBuffer of 16-bit LE PCM
108
+ const turn = await conversation.endAudio(); // a spoken turn
109
+ // …or the words alone, for this recording, however others finish:
110
+ const words = await conversation.endAudio({ transcribeOnly: true });
111
+ ```
112
+
113
+ The SDK captures and plays nothing itself: a recorder hands it samples, a
114
+ player takes `onAudio`'s. `floatToPcm16`, `pcm16ToFloat`, `resample`,
115
+ `concatPcm` and `pcmSeconds` convert between them and the wire;
116
+ `AgentAudio` is the interface `@lumavox/react` drives.
117
+
118
+ ## Runtimes
119
+
120
+ Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
121
+ as base64, a cost per audio frame no SDK can remove), Node 22 and later.
122
+
123
+ MIT, copyright Lumavox.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What an app plugs in to make a conversation audible and to speak in it:
3
+ * a player and a recorder. **Types only in v1**: the SDK captures and
4
+ * plays nothing itself, so it adds no audio library to a customer's app —
5
+ * the console's Web Audio player and recorder are the first implementation
6
+ * (`apps/console/src/components/audio/agent-audio.ts`), and
7
+ * `@lumavox/react`'s `useAgent` drives one: it plays every audio frame,
8
+ * stops on a cancel, a mute or a new turn, and records at the rate the
9
+ * recorder was granted. Voice activity detection stays the app's.
10
+ */
11
+ export interface AgentAudio {
12
+ /** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
13
+ play(pcm: ArrayBuffer, frame: {
14
+ sample_rate: number;
15
+ channels: number;
16
+ chunk: number;
17
+ turn: string;
18
+ }): void;
19
+ /** Drop everything queued and stop: a cancel, a mute, a barge-in. */
20
+ stop(): void;
21
+ /** Called when the queue has played out: the agent stopped speaking. Answers the unsubscribe. */
22
+ onDrained(fn: () => void): () => void;
23
+ /**
24
+ * Open the microphone and call `onPcm` with 16-bit little-endian PCM as
25
+ * it is captured. Answers the rate it was granted, which must be one of
26
+ * `RECORDING_RATES`: a recorder that cannot have its asked rate
27
+ * resamples (`resample`) or refuses.
28
+ */
29
+ startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
30
+ rate?: number;
31
+ }): Promise<{
32
+ rate: number;
33
+ }>;
34
+ /** Close the microphone; the last samples are delivered before it settles. */
35
+ stopCapture(): Promise<void>;
36
+ /** Free everything: the conversation is over. */
37
+ dispose(): void;
38
+ }
package/dist/audio.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `createClient`: an end user's ephemeral key (`getToken`), one base URL,
3
+ * the agent's REST routes — sessions, facts, relations, the ten
4
+ * `/v1/agent/*` operations (`test/coverage.test.ts` holds the document to
5
+ * them) — and `connect`, the agent's socket (`conversation.ts`).
6
+ *
7
+ * - **Shaped as the server SDK is** (`sdks/server`): bodies and answers in
8
+ * the wire's snake_case, camelCase methods, the SDK's options
9
+ * (`timeoutMs`, `signal`, `maxRetries`, `requestId`) last; `list()` every
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.
14
+ * - **A key is asked for once, and replaced once**: a call refused with 401
15
+ * asks for a new key and is sent again; a second refusal, on the fresh
16
+ * key, is the caller's (`token.ts`). **Never on 402, 403 or 429**: on this
17
+ * API 403 is a key bound to another agent and 429 a window, and a new key
18
+ * there would spend credit to dodge a limit.
19
+ * - **`end_user_id` is never sent**: the key names the person.
20
+ * - **Nothing is read from the environment**, and the runtime's own
21
+ * `fetch` and `WebSocket` are the defaults, looked up when used: Node 22
22
+ * and later, browsers, React Native.
23
+ */
24
+ import { type ConnectOptions, Conversation, type ConversationHandlers } from "./conversation.js";
25
+ import type { CompactionInfo, CompactRequest, FactInfo, FactPage, FactsDeleted, MessageInfo, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage } from "./gen/index.js";
26
+ import type { WebSocketConstructor } from "./socket.js";
27
+ import { type GetToken } from "./token.js";
28
+ import { type Fetch, type RequestOptions } from "./transport.js";
29
+ export declare const DEFAULT_BASE_URL = "https://api.lumavox.ai";
30
+ export interface ClientOptions {
31
+ /**
32
+ * The end user's ephemeral key, from your server — which mints it with
33
+ * your API key (`POST /v1/ephemeral-keys`). Called once, and again only
34
+ * when the platform refuses the key it gave; throw to stop.
35
+ */
36
+ getToken: GetToken;
37
+ /** `https://api.lumavox.ai` by default; staging is `https://stg-api.lumavox.ai`. */
38
+ baseUrl?: string;
39
+ /** The agent's socket: `baseUrl` as `wss:`, plus `/v1/agent/stream`, by default. */
40
+ socketUrl?: string;
41
+ /** Per attempt, in milliseconds: 30 s by default. */
42
+ timeoutMs?: number;
43
+ /** Retries of a call that can be sent again safely: 2 by default. */
44
+ maxRetries?: number;
45
+ /** The `fetch` to call: the runtime's own by default. */
46
+ fetch?: Fetch;
47
+ /** The `WebSocket` class: the runtime's own by default; `ws` on an older Node. */
48
+ WebSocket?: WebSocketConstructor;
49
+ }
50
+ export interface SessionListParams {
51
+ /** How many a page holds, up to 200. */
52
+ limit?: number;
53
+ /** `page()` only: the `next_cursor` of the page before. */
54
+ cursor?: string;
55
+ }
56
+ export interface SessionGetParams {
57
+ /** Messages, counting back from the newest: 50 by default, up to 200. */
58
+ limit?: number;
59
+ /** The older messages: `messages.next_cursor` of the page before. */
60
+ cursor?: string;
61
+ }
62
+ export interface Sessions {
63
+ /** Open a conversation with an agent — or resume the person's last, by default (`resume`). */
64
+ create(body: SessionCreateRequest, options?: RequestOptions): Promise<SessionDetail>;
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. */
70
+ get(id: string, params?: SessionGetParams, options?: RequestOptions): Promise<SessionDetail>;
71
+ /** Its whole transcript, newest message first, paging back by `messages.next_cursor`. */
72
+ history(id: string, options?: RequestOptions): AsyncGenerator<MessageInfo, void, undefined>;
73
+ /** Delete it: `true`, or `false` when it is already gone. */
74
+ delete(id: string, options?: RequestOptions): Promise<boolean>;
75
+ /** Summarise it now; `extract_facts` also stores what is worth remembering. */
76
+ compact(id: string, body?: CompactRequest, options?: RequestOptions): Promise<CompactionInfo>;
77
+ /** Compact it and extract facts, in one model call. */
78
+ extract(id: string, options?: RequestOptions): Promise<CompactionInfo>;
79
+ }
80
+ export interface Facts {
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>;
93
+ }
94
+ export interface RelationListParams {
95
+ /** One agent's row alone. */
96
+ agent_id?: string;
97
+ /** How many a page holds: 50 by default, up to 200. */
98
+ limit?: number;
99
+ /** `page()` only: the `next_cursor` of the page before. */
100
+ cursor?: string;
101
+ }
102
+ export interface Relations {
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>;
107
+ }
108
+ export interface Client {
109
+ readonly sessions: Sessions;
110
+ readonly facts: Facts;
111
+ readonly relations: Relations;
112
+ /**
113
+ * Join conversation `sessionId` on the agent's socket. Answers at once:
114
+ * the conversation dials, authenticates and keeps itself connected, and
115
+ * its handlers and `snapshot` say how it goes.
116
+ */
117
+ connect(sessionId: string, handlers?: ConversationHandlers, options?: ConnectOptions): Conversation;
118
+ /** Forget the key — at sign-out. The next call asks `getToken` again. */
119
+ clearToken(): void;
120
+ }
121
+ export declare function createClient(options: ClientOptions): Client;
package/dist/client.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `createClient`: an end user's ephemeral key (`getToken`), one base URL,
3
+ * the agent's REST routes — sessions, facts, relations, the ten
4
+ * `/v1/agent/*` operations (`test/coverage.test.ts` holds the document to
5
+ * them) — and `connect`, the agent's socket (`conversation.ts`).
6
+ *
7
+ * - **Shaped as the server SDK is** (`sdks/server`): bodies and answers in
8
+ * the wire's snake_case, camelCase methods, the SDK's options
9
+ * (`timeoutMs`, `signal`, `maxRetries`, `requestId`) last; `list()` every
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.
14
+ * - **A key is asked for once, and replaced once**: a call refused with 401
15
+ * asks for a new key and is sent again; a second refusal, on the fresh
16
+ * key, is the caller's (`token.ts`). **Never on 402, 403 or 429**: on this
17
+ * API 403 is a key bound to another agent and 429 a window, and a new key
18
+ * there would spend credit to dodge a limit.
19
+ * - **`end_user_id` is never sent**: the key names the person.
20
+ * - **Nothing is read from the environment**, and the runtime's own
21
+ * `fetch` and `WebSocket` are the defaults, looked up when used: Node 22
22
+ * and later, browsers, React Native.
23
+ */
24
+ import { Conversation, } from "./conversation.js";
25
+ import { AuthenticationError } from "./errors.js";
26
+ import { Tokens } from "./token.js";
27
+ import { orNull, segment, Transport, } from "./transport.js";
28
+ export const DEFAULT_BASE_URL = "https://api.lumavox.ai";
29
+ /** A page as large as the route allows: fewer calls for a long list. */
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);
42
+ export function createClient(options) {
43
+ if (typeof options?.getToken !== "function") {
44
+ throw new TypeError("createClient needs getToken: a function answering the end user's ephemeral key.");
45
+ }
46
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
47
+ const socketBase = options.socketUrl ?? `${baseUrl.replace(/^http/, "ws")}/v1/agent/stream`;
48
+ const own = options.fetch;
49
+ const transport = new Transport({
50
+ apiKey: "",
51
+ baseUrl,
52
+ timeoutMs: options.timeoutMs ?? 30000,
53
+ maxRetries: options.maxRetries ?? 2,
54
+ fetch: (url, init) => (own ?? fetch)(url, init),
55
+ });
56
+ const tokens = new Tokens(options.getToken);
57
+ /** A call with the key, and with a new one once if it is refused. */
58
+ async function send(call, request) {
59
+ const key = await tokens.current();
60
+ try {
61
+ return (await transport.request({ ...call, token: key }, request))
62
+ .data;
63
+ }
64
+ catch (error) {
65
+ if (!(error instanceof AuthenticationError))
66
+ throw error;
67
+ const fresh = await tokens.replace(key, {
68
+ status: 401,
69
+ code: error.code,
70
+ });
71
+ return (await transport.request({ ...call, token: fresh }, request))
72
+ .data;
73
+ }
74
+ }
75
+ const sessionPath = (id, rest = "") => `/v1/agent/sessions/${segment(id)}${rest}`;
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);
81
+ const get = (id, params = {}, request) => send({ method: "GET", path: sessionPath(id), query: { ...params } }, request);
82
+ return {
83
+ sessions: {
84
+ create: (body, request) => send({ method: "POST", path: "/v1/agent/sessions", body }, request),
85
+ list: (params = {}, request) => pages((cursor) => page({ limit: PAGE, ...params, cursor }, request)),
86
+ page,
87
+ get,
88
+ async *history(id, request) {
89
+ let cursor;
90
+ do {
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];
95
+ }
96
+ cursor = messages.next_cursor;
97
+ } while (cursor);
98
+ },
99
+ delete: (id, request) => done(send({ method: "DELETE", path: sessionPath(id) }, request)),
100
+ // The document requires a body; none is `{}`, the agent's own setting.
101
+ compact: (id, body = {}, request) => send({ method: "POST", path: sessionPath(id, "/compact"), body }, request),
102
+ extract: (id, request) => send({ method: "POST", path: sessionPath(id, "/extract") }, request),
103
+ },
104
+ facts: {
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)),
108
+ deleteAll: (request) => send({ method: "DELETE", path: "/v1/agent/facts" }, request),
109
+ },
110
+ relations: {
111
+ list: (params = {}, request) => pages((cursor) => relationPage({ limit: PAGE, ...params, cursor }, request)),
112
+ page: relationPage,
113
+ },
114
+ connect(sessionId, handlers = {}, connectOptions = {}) {
115
+ const WebSocket = options.WebSocket ??
116
+ globalThis
117
+ .WebSocket;
118
+ if (!WebSocket) {
119
+ throw new TypeError("This runtime has no WebSocket: pass createClient({ WebSocket }), from the ws package on Node before 22.");
120
+ }
121
+ return new Conversation({
122
+ sessionId,
123
+ socketUrl: socketBase,
124
+ tokens,
125
+ WebSocket,
126
+ handlers,
127
+ options: connectOptions,
128
+ });
129
+ },
130
+ clearToken: () => tokens.clear(),
131
+ };
132
+ }