@rimelabs/sdk 0.1.0-alpha.20 → 0.1.0-alpha.22

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.
@@ -1,6 +1,8 @@
1
1
  import { RealtimeConnection } from "./transport.js";
2
2
  import * as t from "./types.js";
3
3
  import * as p from "./protocol.js";
4
+ /** @internal Read the policy captured before queuing the response event. */
5
+ export declare function responseInterruptsSpeech(session: RealtimeSession, response: t.ResponseRef): boolean;
4
6
  /** @internal Recognize only failures created by intentional session closure. */
5
7
  export declare function isSessionCloseError(error: unknown): boolean;
6
8
  /** One conversation. Consume events concurrently with session operations. */
@@ -27,6 +29,7 @@ export declare class RealtimeSession {
27
29
  private readonly toolLock;
28
30
  private readonly pending;
29
31
  private readonly responses;
32
+ private readonly interruptionPolicies;
30
33
  private latest;
31
34
  private readonly tasks;
32
35
  private converter;
@@ -35,7 +38,7 @@ export declare class RealtimeSession {
35
38
  /** @internal */
36
39
  initialize(settings: p.SessionSettings, signal: AbortSignal): Promise<void>;
37
40
  get info(): t.SessionInfo;
38
- get events(): AsyncIterableIterator<t.SessionEvent>;
41
+ get events(): AsyncIterableIterator<t.SessionEvent<t.NormalizedRealtimeFault>>;
39
42
  private readEvents;
40
43
  private id;
41
44
  private check;
@@ -49,12 +52,25 @@ export declare class RealtimeSession {
49
52
  private runRequest;
50
53
  private abandon;
51
54
  private create;
55
+ /** Apply supplied settings and wait for acknowledgment. Tools replace the
56
+ * complete list. Replies already started keep their original settings.
57
+ * Cancellation after submission does not undo an accepted update. */
58
+ update(options?: t.RealtimeUpdateOptions): Promise<t.SessionInfo>;
52
59
  sendText(value: string, options?: t.RealtimeOperationOptions): Promise<t.ResponseRef>;
53
60
  requestReply(options?: t.ReplyOptions): Promise<t.ResponseRef>;
61
+ /** Read the items the next reply would see, in server order. */
62
+ getHistory(options?: t.RealtimeOperationOptions): Promise<readonly t.HistoryItem[]>;
63
+ /** Hide an item from future replies. A call and its result are hidden together. */
64
+ deleteItem(itemId: string, options?: t.RealtimeOperationOptions): Promise<void>;
65
+ /** Add history without a reply. The server owns placement and identity checks. */
66
+ createItem(item: t.HistoryItem, options?: t.HistoryCreateOptions): Promise<t.ItemRef>;
54
67
  addMessage(role: "user" | "assistant", value: string, options?: t.RealtimeOperationOptions): Promise<t.ItemRef>;
55
68
  submitToolResult(call: t.ToolCallRef, output: string, options?: t.RealtimeOperationOptions): Promise<void>;
56
69
  continueReply(parent: t.ResponseRef, options?: t.RealtimeOperationOptions): Promise<t.ResponseRef>;
57
70
  cancel(response: t.ResponseRef, options?: t.RealtimeOperationOptions): Promise<void>;
71
+ /** Replaces the labels later replies carry; a reply already started keeps
72
+ * its own. Resolves once Prism confirms the change. */
73
+ setResponseMetadata(metadata: t.Labels, options?: t.RealtimeOperationOptions): Promise<void>;
58
74
  clearAudio(options?: t.RealtimeOperationOptions): Promise<void>;
59
75
  sendAudio(chunk: t.AudioChunk, options?: t.RealtimeOperationOptions): Promise<void>;
60
76
  reportPlayback(report: t.PlaybackReport, options?: t.RealtimeOperationOptions): Promise<void>;
@@ -10,6 +10,8 @@ import { formatOf } from "./audio.js";
10
10
  import { InputConverter } from "../pcm.js";
11
11
  import * as t from "./types.js";
12
12
  import * as p from "./protocol.js";
13
+ import { historyId, itemSettings } from "./history.js";
14
+ import { InterruptionPolicies, toolSettings } from "./settings.js";
13
15
  const NOT_READY = new Set([
14
16
  "typed_turn_busy",
15
17
  "instructions_busy",
@@ -17,6 +19,11 @@ const NOT_READY = new Set([
17
19
  ]);
18
20
  const LIMIT = 128;
19
21
  const closeErrors = new WeakSet();
22
+ const responseInterruptions = new WeakMap();
23
+ /** @internal Read the policy captured before queuing the response event. */
24
+ export function responseInterruptsSpeech(session, response) {
25
+ return responseInterruptions.get(response) ?? session.info.interruptOnSpeech;
26
+ }
20
27
  /** @internal Recognize only failures created by intentional session closure. */
21
28
  export function isSessionCloseError(error) {
22
29
  return error instanceof Error && closeErrors.has(error);
@@ -70,6 +77,7 @@ export class RealtimeSession {
70
77
  toolLock = new Mutex();
71
78
  pending = new Map();
72
79
  responses = new Map();
80
+ interruptionPolicies = new InterruptionPolicies();
73
81
  latest = null;
74
82
  tasks = new Set();
75
83
  converter = null;
@@ -106,16 +114,7 @@ export class RealtimeSession {
106
114
  deadline.dispose();
107
115
  }
108
116
  const event = await this.request(p.requests.update, { session: settings }, signal);
109
- const view = event.session;
110
- this.sessionInfo = Object.freeze({
111
- sessionId: this.id(),
112
- model: "prism",
113
- voice: view.voice ?? "",
114
- interruptOnSpeech: view.interruptOnSpeech ?? settings.turn_detection.interrupt_response,
115
- toolResultTimeoutS: view.toolResultTimeoutS ?? null,
116
- toolContinuationTimeoutS: view.toolContinuationTimeoutS ?? null,
117
- turnDetection: view.turnDetection,
118
- });
117
+ this.sessionInfo = event.info;
119
118
  }
120
119
  get info() {
121
120
  if (!this.sessionInfo)
@@ -266,7 +265,23 @@ export class RealtimeSession {
266
265
  const kind = request.kind;
267
266
  const eventId = `evt_${randomUUID().replaceAll("-", "")}`;
268
267
  const result = new Deferred();
269
- this.pending.set(eventId, { request, result, target });
268
+ this.pending.set(eventId, {
269
+ request,
270
+ result,
271
+ target,
272
+ requiresItemId: request === p.requests.historyItem ||
273
+ request === p.requests.historyResult,
274
+ expectedItemId: body.item?.id,
275
+ toolOutputDigest: request.kind === "conversation.item.create" &&
276
+ body.item?.type === "function_call_output"
277
+ ? createHash("sha256")
278
+ .update(body.item.output)
279
+ .digest("hex")
280
+ : undefined,
281
+ interruptOnSpeech: request.kind === "session.update"
282
+ ? body.session.turn_detection?.interrupt_response
283
+ : undefined,
284
+ });
270
285
  const deadline = scope([this.lifetime.signal], this.timeouts.requestS, new RimeTimeoutError(`${kind}: no acknowledgment; outcome unknown`, eventId));
271
286
  let submitted = false;
272
287
  // Cancellation can prevent a write. After submission, keep matching its outcome.
@@ -357,6 +372,35 @@ export class RealtimeSession {
357
372
  release();
358
373
  }
359
374
  }
375
+ /** Apply supplied settings and wait for acknowledgment. Tools replace the
376
+ * complete list. Replies already started keep their original settings.
377
+ * Cancellation after submission does not undo an accepted update. */
378
+ async update(options = {}) {
379
+ const settings = {};
380
+ if (options.truncation !== undefined)
381
+ settings.truncation = p.truncationSettings(options.truncation);
382
+ for (const name of ["instructions", "voice"]) {
383
+ const value = options[name];
384
+ if (value !== undefined) {
385
+ if (typeof value !== "string")
386
+ throw new RimeInputError(`${name} must be a string`);
387
+ settings[name] = value;
388
+ }
389
+ }
390
+ if (options.tools !== undefined)
391
+ settings.tools = toolSettings(options.tools);
392
+ if (options.interruptOnSpeech !== undefined) {
393
+ if (typeof options.interruptOnSpeech !== "boolean")
394
+ throw new RimeInputError("interruptOnSpeech must be a boolean");
395
+ settings.turn_detection = {
396
+ interrupt_response: options.interruptOnSpeech,
397
+ };
398
+ }
399
+ return this.operation(options, async (signal) => {
400
+ const event = await this.request(p.requests.update, { session: settings }, signal);
401
+ return event.info;
402
+ });
403
+ }
360
404
  async sendText(value, options = {}) {
361
405
  text(value);
362
406
  return this.operation(options, (signal) => this.create({ prism_input_text: value, metadata: { prism_cause: "user_text" } }, signal, p.requests.text));
@@ -377,18 +421,52 @@ export class RealtimeSession {
377
421
  }
378
422
  return this.operation(options, (signal) => this.create(body, signal));
379
423
  }
380
- async addMessage(role, value, options = {}) {
381
- if (role !== "user" && role !== "assistant")
382
- throw new RimeInputError("History role must be user or assistant");
383
- text(value);
424
+ /** Read the items the next reply would see, in server order. */
425
+ async getHistory(options = {}) {
426
+ return this.operation(options, async (signal) => (await this.request(p.requests.history, {}, signal)).items);
427
+ }
428
+ /** Hide an item from future replies. A call and its result are hidden together. */
429
+ async deleteItem(itemId, options = {}) {
430
+ if (typeof itemId !== "string" || !itemId)
431
+ throw new RimeInputError("itemId must be nonempty");
432
+ historyId(itemId);
384
433
  return this.operation(options, async (signal) => {
385
- const event = await this.request(p.requests.item, { item: { type: "message", role, text: value } }, signal);
434
+ await this.request(p.requests.delete, { item_id: itemId }, signal, itemId);
435
+ });
436
+ }
437
+ /** Add history without a reply. The server owns placement and identity checks. */
438
+ async createItem(item, options = {}) {
439
+ const value = itemSettings(item);
440
+ const body = { item: value };
441
+ if (options.previousItemId !== undefined) {
442
+ if (typeof options.previousItemId !== "string" ||
443
+ !options.previousItemId ||
444
+ item.type === "function_call_output")
445
+ throw new RimeInputError("invalid previousItemId for history item");
446
+ body.previous_item_id = historyId(options.previousItemId);
447
+ }
448
+ return this.operation(options, async (signal) => {
449
+ const event = value.type === "function_call_output"
450
+ ? await this.request(p.requests.historyResult, body, signal, value.call_id)
451
+ : await this.request(p.requests.historyItem, body, signal);
452
+ if (!event.itemId ||
453
+ (value.id !== undefined && event.itemId !== value.id)) {
454
+ const error = new RimeStreamError("history acknowledgment names an invalid item");
455
+ this.fail(error);
456
+ throw error;
457
+ }
386
458
  return Object.freeze({
387
459
  sessionId: this.info.sessionId,
388
460
  itemId: event.itemId,
389
461
  });
390
462
  });
391
463
  }
464
+ async addMessage(role, value, options = {}) {
465
+ if (role !== "user" && role !== "assistant")
466
+ throw new RimeInputError("History role must be user or assistant");
467
+ text(value);
468
+ return this.createItem({ type: "message", role, text: value }, { signal: options.signal });
469
+ }
392
470
  async submitToolResult(call, output, options = {}) {
393
471
  const ref = { ...call };
394
472
  this.response(ref);
@@ -459,6 +537,14 @@ export class RealtimeSession {
459
537
  await this.request(p.requests.cancel, { response_id: state.ref.responseId }, signal, state.ref.responseId);
460
538
  }));
461
539
  }
540
+ /** Replaces the labels later replies carry; a reply already started keeps
541
+ * its own. Resolves once Prism confirms the change. */
542
+ async setResponseMetadata(metadata, options = {}) {
543
+ const labels = p.responseMetadata(metadata);
544
+ return this.operation(options, async (signal) => {
545
+ await this.request(p.requests.update, { session: { prism_response_metadata: labels } }, signal);
546
+ });
547
+ }
462
548
  async clearAudio(options = {}) {
463
549
  return this.operation(options, (signal) => this.locked(this.audioLock, signal, async () => {
464
550
  await this.request(p.requests.clear, {}, signal);
@@ -569,6 +655,8 @@ export class RealtimeSession {
569
655
  const { payload, requestId: echo } = event;
570
656
  // Check echoed acknowledgments before changing response or item state.
571
657
  if (payload.kind === "session.updated" ||
658
+ payload.kind === "history.listed" ||
659
+ payload.kind === "item.deleted" ||
572
660
  payload.kind === "item.created" ||
573
661
  payload.kind === "tool.result.created" ||
574
662
  payload.kind === "response.started") {
@@ -576,6 +664,13 @@ export class RealtimeSession {
576
664
  if (pending &&
577
665
  !(payload.kind === "item.created" && pending.request.allowsItemCreated))
578
666
  pending.request.check(payload);
667
+ if (pending?.requiresItemId &&
668
+ ((payload.kind !== "item.created" &&
669
+ payload.kind !== "tool.result.created") ||
670
+ !payload.itemId ||
671
+ (pending.expectedItemId !== undefined &&
672
+ pending.expectedItemId !== payload.itemId)))
673
+ throw new RimeStreamError("history acknowledgment names an invalid item");
579
674
  if (pending &&
580
675
  payload.kind === "tool.result.created" &&
581
676
  pending.target !== payload.callId)
@@ -588,10 +683,53 @@ export class RealtimeSession {
588
683
  this.created.resolve(payload.session);
589
684
  }
590
685
  return;
591
- case "session.updated":
592
- case "tool.result.created":
686
+ case "session.updated": {
687
+ const view = payload.session;
688
+ const pending = this.pending.get(echo ?? "");
689
+ if (!pending)
690
+ return;
691
+ this.sessionInfo = Object.freeze({
692
+ sessionId: this.id(),
693
+ model: "prism",
694
+ voice: view.voice,
695
+ interruptOnSpeech: view.interruptOnSpeech ??
696
+ pending?.interruptOnSpeech ??
697
+ this.sessionInfo?.interruptOnSpeech ??
698
+ true,
699
+ toolResultTimeoutS: view.toolResultTimeoutS,
700
+ toolContinuationTimeoutS: view.toolContinuationTimeoutS,
701
+ turnDetection: view.turnDetection,
702
+ settingsRevision: view.settingsRevision,
703
+ truncation: view.truncation,
704
+ requestId: view.requestId,
705
+ responseMetadata: view.responseMetadata,
706
+ });
707
+ this.interruptionPolicies.record(view.settingsRevision, this.sessionInfo.interruptOnSpeech);
708
+ this.settle(echo, { ...payload, info: this.sessionInfo });
709
+ return;
710
+ }
711
+ case "history.listed":
712
+ this.settle(echo, payload);
713
+ return;
714
+ case "item.deleted": {
715
+ const pending = this.pending.get(echo ?? "");
716
+ if (pending && pending.target !== payload.itemId)
717
+ throw new RimeStreamError("deletion acknowledgment names another item");
593
718
  this.settle(echo, payload);
594
719
  return;
720
+ }
721
+ case "tool.result.created": {
722
+ const pending = this.pending.get(echo ?? "");
723
+ if (pending?.toolOutputDigest !== undefined)
724
+ for (const response of this.responses.values()) {
725
+ if (response.calls.get(payload.callId) === null) {
726
+ response.calls.set(payload.callId, pending.toolOutputDigest);
727
+ response.changed.set();
728
+ }
729
+ }
730
+ this.settle(echo, payload);
731
+ return;
732
+ }
595
733
  case "item.created": {
596
734
  const pending = this.pending.get(echo ?? "");
597
735
  if (pending?.request.allowsItemCreated)
@@ -630,6 +768,7 @@ export class RealtimeSession {
630
768
  const ref = payload.response;
631
769
  if (this.responses.has(ref.responseId))
632
770
  throw new RimeStreamError("Duplicate response.created");
771
+ responseInterruptions.set(ref, this.interruptionPolicies.resolve(payload.settingsRevision));
633
772
  this.responses.set(ref.responseId, new ResponseState(ref));
634
773
  for (const state of this.responses.values())
635
774
  state.changed.set();
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from "./types.js";
2
+ import type { SessionSettings } from "./protocol.js";
3
+ export declare function toolSettings(tools: readonly ToolDefinition[]): SessionSettings["tools"];
4
+ /** Bounded policy changes used to resolve each reply's settings revision. */
5
+ export declare class InterruptionPolicies {
6
+ private changes;
7
+ private current;
8
+ private lastRevision;
9
+ record(revision: number | null, interrupt: boolean): void;
10
+ resolve(revision: number | null | undefined): boolean;
11
+ }
@@ -0,0 +1,142 @@
1
+ import { RimeInputError, RimeResourceLimitError } from "../errors.js";
2
+ function toolString(value) {
3
+ // Unicode mode matches lone surrogates without rejecting valid UTF-16 pairs.
4
+ if (/[\uD800-\uDFFF]/u.test(value))
5
+ throw new RimeInputError("Tool strings must be valid UTF-8");
6
+ return value;
7
+ }
8
+ /** Reject values that JSON.stringify would silently drop or change. */
9
+ function copyJson(value) {
10
+ if (typeof value === "string")
11
+ return toolString(value);
12
+ if (value === null || typeof value === "boolean")
13
+ return value;
14
+ if (typeof value === "number" && Number.isFinite(value))
15
+ return value;
16
+ if (Array.isArray(value))
17
+ return Array.from(value, copyJson);
18
+ if (value &&
19
+ typeof value === "object" &&
20
+ [Object.prototype, null].includes(Object.getPrototypeOf(value)))
21
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
22
+ toolString(key),
23
+ copyJson(item),
24
+ ]));
25
+ throw new RimeInputError("Tool schemas must contain finite JSON values");
26
+ }
27
+ function fillerSettings(value) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value))
29
+ throw new RimeInputError("prismFiller must be a filler object");
30
+ const kind = value.type;
31
+ if (!["none", "text", "auto"].includes(kind))
32
+ throw new RimeInputError("Prism filler type must be none, text, or auto");
33
+ const field = kind === "text" ? "text" : kind === "auto" ? "instructions" : null;
34
+ if (Object.entries(value).some(([key, item]) => item !== undefined && key !== "type" && key !== field))
35
+ throw new RimeInputError("Prism filler has fields not allowed for its type");
36
+ const text = field === null ? undefined : value[field];
37
+ if (kind === "text" || text !== undefined) {
38
+ // Rust str::trim uses Unicode White_Space, which differs from JS trim.
39
+ if (typeof text !== "string" ||
40
+ !/\P{White_Space}/u.test(text) ||
41
+ [...text].length > 500)
42
+ throw new RimeInputError("Prism filler text must be nonblank and at most 500 characters");
43
+ // Unicode mode matches lone surrogates but preserves valid UTF-16 pairs.
44
+ if (/[\uD800-\uDFFF]/u.test(text))
45
+ throw new RimeInputError("Prism filler text must be valid UTF-8");
46
+ }
47
+ if (kind === "text")
48
+ return { type: "text", text: text };
49
+ if (kind === "auto")
50
+ return text === undefined
51
+ ? { type: "auto" }
52
+ : { type: "auto", instructions: text };
53
+ return { type: "none" };
54
+ }
55
+ export function toolSettings(tools) {
56
+ if (!Array.isArray(tools))
57
+ throw new RimeInputError("tools must be an array");
58
+ const names = new Set();
59
+ return tools.map((tool) => {
60
+ if (!tool ||
61
+ typeof tool !== "object" ||
62
+ typeof tool.name !== "string" ||
63
+ !/\P{White_Space}/u.test(tool.name) ||
64
+ [...tool.name].length > 4000 ||
65
+ names.has(tool.name))
66
+ throw new RimeInputError("Tool names must be nonblank, unique, and at most 4000 characters");
67
+ toolString(tool.name);
68
+ names.add(tool.name);
69
+ if (!tool.parameters ||
70
+ Array.isArray(tool.parameters) ||
71
+ typeof tool.parameters !== "object" ||
72
+ (tool.description !== undefined && typeof tool.description !== "string"))
73
+ throw new RimeInputError("Tool parameters must be a JSON object and description must be a string");
74
+ let parameters;
75
+ try {
76
+ parameters = copyJson(tool.parameters);
77
+ }
78
+ catch (error) {
79
+ if (error instanceof RimeInputError)
80
+ throw error;
81
+ throw new RimeInputError("Tool schemas must contain finite JSON values");
82
+ }
83
+ if (parameters === null ||
84
+ typeof parameters !== "object" ||
85
+ Array.isArray(parameters))
86
+ throw new RimeInputError("Tool parameters must be an object");
87
+ return {
88
+ type: "function",
89
+ ...(tool.prismFiller === undefined
90
+ ? {}
91
+ : { prism_filler: fillerSettings(tool.prismFiller) }),
92
+ function: {
93
+ name: tool.name,
94
+ description: toolString(tool.description ?? ""),
95
+ parameters,
96
+ },
97
+ };
98
+ });
99
+ }
100
+ /** Bounded policy changes used to resolve each reply's settings revision. */
101
+ export class InterruptionPolicies {
102
+ changes = [];
103
+ current = true;
104
+ lastRevision = null;
105
+ record(revision, interrupt) {
106
+ const previous = this.changes.at(-1);
107
+ if (!previous) {
108
+ this.changes.push({ revision: revision ?? 0, interrupt });
109
+ }
110
+ else if (revision === null) {
111
+ if (interrupt !== this.current && !previous.unknown) {
112
+ // Earlier published revisions stay known; later ones are ambiguous
113
+ // until an acknowledgment supplies the new policy's revision.
114
+ this.changes.push({
115
+ revision: this.lastRevision === null ? 0 : this.lastRevision + 1,
116
+ interrupt,
117
+ unknown: true,
118
+ });
119
+ }
120
+ }
121
+ else if (previous.unknown || interrupt !== this.current) {
122
+ this.changes.push({ revision, interrupt });
123
+ }
124
+ if (revision !== null)
125
+ this.lastRevision = revision;
126
+ if (this.changes.length > 128)
127
+ this.changes.shift();
128
+ this.current = interrupt;
129
+ }
130
+ resolve(revision) {
131
+ if (revision == null || this.changes.length === 0)
132
+ return this.current;
133
+ for (let i = this.changes.length - 1; i >= 0; i--) {
134
+ if (this.changes[i].revision <= revision) {
135
+ if (this.changes[i].unknown)
136
+ throw new RimeResourceLimitError("Reply settings revision crosses an unreported interruption policy change");
137
+ return this.changes[i].interrupt;
138
+ }
139
+ }
140
+ throw new RimeResourceLimitError("Reply settings revision is no longer retained");
141
+ }
142
+ }
@@ -9,7 +9,7 @@ export declare class RealtimeConnection {
9
9
  readonly ready: Promise<void>;
10
10
  onMessage: (raw: string) => void;
11
11
  onFailure: (error: RimeError) => void;
12
- constructor(endpoint: string, authorization: string);
12
+ constructor(endpoint: string, authorization: string, requestId?: string);
13
13
  private failure;
14
14
  send(encoded: string): Promise<void>;
15
15
  close(): Promise<void>;
@@ -11,9 +11,12 @@ export class RealtimeConnection {
11
11
  ready = this.opened.promise;
12
12
  onMessage = () => { };
13
13
  onFailure = () => { };
14
- constructor(endpoint, authorization) {
14
+ constructor(endpoint, authorization, requestId) {
15
15
  this.socket = new WebSocket(endpoint, {
16
- headers: { Authorization: authorization },
16
+ headers: {
17
+ Authorization: authorization,
18
+ ...(requestId === undefined ? {} : { "x-request-id": requestId }),
19
+ },
17
20
  followRedirects: false,
18
21
  perMessageDeflate: false,
19
22
  maxPayload: 1024 * 1024,