@rimelabs/sdk 0.1.0-alpha.21 → 0.1.0-alpha.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.0-alpha.23](https://github.com/rimelabs/rime-sdk/compare/typescript-v0.1.0-alpha.22...typescript-v0.1.0-alpha.23) (2026-10-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * preserve Prism handshake error categories and diagnostics ([#110](https://github.com/rimelabs/rime-sdk/issues/110)) ([75568b2](https://github.com/rimelabs/rime-sdk/commit/75568b278afddd265a0a4f2c26c716ccb6b29514))
9
+
10
+ ## [0.1.0-alpha.22](https://github.com/rimelabs/rime-sdk/compare/typescript-v0.1.0-alpha.21...typescript-v0.1.0-alpha.22) (2026-10-24)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * Rust SessionInfo struct literals must add truncation: None. The optional field reports the policy published by the server.
16
+ * Go Prism service faults now match ErrRealtime instead of ErrStream. Rust service faults now use ErrorKind::Realtime instead of Stream. Keep fault inspection for code, scope, and retry classification.
17
+
18
+ ### Features
19
+
20
+ * add Prism conversation history operations ([#108](https://github.com/rimelabs/rime-sdk/issues/108)) ([1f33051](https://github.com/rimelabs/rime-sdk/commit/1f330517163cd521bd03321a0728295f84da0316))
21
+ * add Prism history truncation controls ([#109](https://github.com/rimelabs/rime-sdk/issues/109)) ([b8b1aa1](https://github.com/rimelabs/rime-sdk/commit/b8b1aa172e69df650bfeda274bb91329f7e5bc52))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * distinguish Prism faults and correct SDK release guidance ([#102](https://github.com/rimelabs/rime-sdk/issues/102)) ([b152abb](https://github.com/rimelabs/rime-sdk/commit/b152abb1ac4e5dabb92e747c04a6bc70aa72b60e))
27
+
3
28
  ## [0.1.0-alpha.21](https://github.com/rimelabs/rime-sdk/compare/typescript-v0.1.0-alpha.20...typescript-v0.1.0-alpha.21) (2026-10-23)
4
29
 
5
30
 
package/README.md CHANGED
@@ -28,6 +28,12 @@ STT use the SDK and Node.js built-ins.
28
28
 
29
29
  ## Prism: send recorded speech
30
30
 
31
+ If Prism refuses the connection with HTTP 429, `realtime.connect()` throws
32
+ `RimeResourceLimitError`. HTTP 503 throws `RimeUnavailableError`. Handshake
33
+ errors include the HTTP status, the server request ID when supplied, and a
34
+ short server explanation when available. Missing or invalid error bodies use
35
+ a default message. The SDK does not retry the connection automatically.
36
+
31
37
  With `RIME_API_KEY` set, the SDK connects to
32
38
  `wss://prism.api.rime.ai/v1/realtime` and uses the server's default voice.
33
39
  Pass `endpoint` or `voice` to select another deployment or voice.
@@ -4,7 +4,7 @@ import { cancellation, scope } from "./async.js";
4
4
  import { RealtimeSession } from "./session.js";
5
5
  import { RealtimeConnection } from "./transport.js";
6
6
  import { toolSettings } from "./settings.js";
7
- import { responseMetadata } from "./protocol.js";
7
+ import { responseMetadata, truncationSettings } from "./protocol.js";
8
8
  const DEFAULT_ENDPOINT = "wss://prism.api.rime.ai/v1/realtime";
9
9
  function endpoint(value) {
10
10
  let url;
@@ -60,6 +60,8 @@ export class Realtime {
60
60
  turn_detection: { interrupt_response: options.interruptOnSpeech ?? true },
61
61
  tools: toolSettings(options.tools ?? []),
62
62
  };
63
+ if (options.truncation !== undefined)
64
+ settings.truncation = truncationSettings(options.truncation);
63
65
  if (options.turnDetection !== undefined) {
64
66
  const detection = options.turnDetection;
65
67
  if (!detection ||
@@ -0,0 +1,4 @@
1
+ import type { HistoryItem } from "./types.js";
2
+ export declare function itemSettings(item: HistoryItem): Record<string, string>;
3
+ export declare function historyId(value: string): string;
4
+ export declare function listedItem(value: unknown): HistoryItem;
@@ -0,0 +1,87 @@
1
+ import { RimeInputError, RimeStreamError } from "../errors.js";
2
+ function utf8(value) {
3
+ return typeof value === "string" && !/[\uD800-\uDFFF]/u.test(value);
4
+ }
5
+ export function itemSettings(item) {
6
+ if (!item || typeof item !== "object")
7
+ throw new RimeInputError("history item must be an object");
8
+ const value = { type: item.type };
9
+ if (item.id !== undefined) {
10
+ if (!utf8(item.id) || [...item.id].length < 1 || [...item.id].length > 32)
11
+ throw new RimeInputError("item.id must contain 1 to 32 characters");
12
+ value.id = item.id;
13
+ }
14
+ switch (item.type) {
15
+ case "message":
16
+ if (!["user", "assistant", "system"].includes(item.role) ||
17
+ !utf8(item.text) ||
18
+ [...item.text].length > 4000)
19
+ throw new RimeInputError("invalid history message");
20
+ value.role = item.role;
21
+ value.text = item.text;
22
+ break;
23
+ case "function_call":
24
+ value.call_id = item.callId;
25
+ value.name = item.name;
26
+ value.arguments = item.arguments;
27
+ if (!item.callId || !item.name)
28
+ throw new RimeInputError("past calls require callId and name");
29
+ break;
30
+ case "function_call_output":
31
+ value.call_id = item.callId;
32
+ value.output = item.output;
33
+ if (!item.callId)
34
+ throw new RimeInputError("tool results require callId");
35
+ break;
36
+ default:
37
+ throw new RimeInputError("invalid history item type");
38
+ }
39
+ if (!Object.values(value).every(utf8))
40
+ throw new RimeInputError("history fields must be valid UTF-8 strings");
41
+ return value;
42
+ }
43
+ export function historyId(value) {
44
+ if (!utf8(value) || !value)
45
+ throw new RimeInputError("history ID must be nonempty valid UTF-8");
46
+ return value;
47
+ }
48
+ export function listedItem(value) {
49
+ if (!value || typeof value !== "object" || Array.isArray(value))
50
+ throw new RimeStreamError("invalid history item");
51
+ const o = value;
52
+ const field = (name) => {
53
+ if (typeof o[name] !== "string")
54
+ throw new RimeStreamError("invalid history field " + name);
55
+ if ((name === "call_id" || name === "name") && !o[name])
56
+ throw new RimeStreamError("invalid history identity");
57
+ return o[name];
58
+ };
59
+ const id = field("id");
60
+ if (!id)
61
+ throw new RimeStreamError("invalid history item id");
62
+ switch (o.type) {
63
+ case "message": {
64
+ const role = field("role");
65
+ if (role !== "user" && role !== "assistant" && role !== "system")
66
+ throw new RimeStreamError("invalid history role");
67
+ return Object.freeze({ type: o.type, id, role, text: field("text") });
68
+ }
69
+ case "function_call":
70
+ return Object.freeze({
71
+ type: o.type,
72
+ id,
73
+ callId: field("call_id"),
74
+ name: field("name"),
75
+ arguments: field("arguments"),
76
+ });
77
+ case "function_call_output":
78
+ return Object.freeze({
79
+ type: o.type,
80
+ id,
81
+ callId: field("call_id"),
82
+ output: field("output"),
83
+ });
84
+ default:
85
+ throw new RimeStreamError("invalid history item type");
86
+ }
87
+ }
@@ -1,5 +1,9 @@
1
1
  import * as t from "./types.js";
2
2
  export interface SessionSettings {
3
+ truncation?: "auto" | "disabled" | {
4
+ type: "retention_ratio";
5
+ retention_ratio: number;
6
+ };
3
7
  modalities: ("text" | "audio")[];
4
8
  input_audio_format: "pcm16";
5
9
  turn_detection: {
@@ -23,6 +27,7 @@ export interface SessionSettings {
23
27
  /** A copy of the client's labels for every reply: keys to strings. */
24
28
  export declare function responseMetadata(value: unknown): t.Labels;
25
29
  export interface SessionPatch {
30
+ truncation?: SessionSettings["truncation"];
26
31
  voice?: string;
27
32
  instructions?: string;
28
33
  tools?: SessionSettings["tools"];
@@ -31,6 +36,7 @@ export interface SessionPatch {
31
36
  };
32
37
  }
33
38
  export interface SessionView {
39
+ truncation: t.Truncation | null;
34
40
  settingsRevision: number | null;
35
41
  id: string;
36
42
  requestId: string | null;
@@ -55,9 +61,18 @@ export interface ItemCreated {
55
61
  itemId: string;
56
62
  }
57
63
  export interface ToolResultCreated {
64
+ itemId: string | null;
58
65
  kind: "tool.result.created";
59
66
  callId: string;
60
67
  }
68
+ export interface HistoryListed {
69
+ kind: "history.listed";
70
+ items: readonly t.HistoryItem[];
71
+ }
72
+ export interface ItemDeleted {
73
+ kind: "item.deleted";
74
+ itemId: string;
75
+ }
61
76
  export interface AudioCleared {
62
77
  kind: "audio.cleared";
63
78
  }
@@ -69,14 +84,14 @@ export interface ItemTruncated {
69
84
  export interface ResponseEnded extends t.ResponseEnded {
70
85
  drainToken: string | null;
71
86
  }
72
- export type Acknowledgment = SessionUpdated | ItemCreated | ToolResultCreated | AudioCleared | ItemTruncated | t.ResponseStarted | ResponseEnded;
87
+ export type Acknowledgment = SessionUpdated | HistoryListed | ItemDeleted | ItemCreated | ToolResultCreated | AudioCleared | ItemTruncated | t.ResponseStarted | ResponseEnded;
73
88
  export type ServerPayload = SessionCreated | Acknowledgment | t.InputEvent<t.NormalizedRealtimeFault> | t.MessageStarted | t.TextDelta | t.TextDone | t.AudioDelta | t.AudioDone | t.ToolCall | t.FaultEvent<t.NormalizedRealtimeFault>;
74
89
  export interface ServerEvent {
75
90
  eventId: string;
76
91
  requestId: string | null;
77
92
  payload: ServerPayload;
78
93
  }
79
- export type RequestKind = "session.update" | "conversation.item.create" | "response.create" | "response.cancel" | "input_audio_buffer.clear" | "conversation.item.truncate";
94
+ export type RequestKind = "session.update" | "conversation.item.create" | "prism.conversation.get" | "conversation.item.delete" | "response.create" | "response.cancel" | "input_audio_buffer.clear" | "conversation.item.truncate";
80
95
  /** A request carries its result type and checks it before exposing the result. */
81
96
  export declare class Request<T extends Acknowledgment> {
82
97
  readonly kind: RequestKind;
@@ -87,6 +102,10 @@ export declare class Request<T extends Acknowledgment> {
87
102
  check(event: Acknowledgment): void;
88
103
  }
89
104
  export declare const requests: {
105
+ historyItem: Request<ItemCreated>;
106
+ historyResult: Request<ToolResultCreated>;
107
+ history: Request<HistoryListed>;
108
+ delete: Request<ItemDeleted>;
90
109
  update: Request<SessionUpdated>;
91
110
  item: Request<ItemCreated>;
92
111
  toolResult: Request<ToolResultCreated>;
@@ -96,4 +115,5 @@ export declare const requests: {
96
115
  clear: Request<AudioCleared>;
97
116
  truncate: Request<ItemTruncated>;
98
117
  };
118
+ export declare function truncationSettings(value: t.Truncation): NonNullable<SessionSettings["truncation"]>;
99
119
  export declare function decode(raw: string, sessionId: string | null): ServerEvent | null;
@@ -2,6 +2,7 @@
2
2
  /** Decode only the protocol fields the SDK uses. Unknown events and fields stay open. */
3
3
  import { RimeInputError, RimeStreamError } from "../errors.js";
4
4
  import { normalizeFault } from "./faults.js";
5
+ import { listedItem } from "./history.js";
5
6
  /** A copy of the client's labels for every reply: keys to strings. */
6
7
  export function responseMetadata(value) {
7
8
  if (!value ||
@@ -31,6 +32,10 @@ export class Request {
31
32
  }
32
33
  }
33
34
  export const requests = {
35
+ historyItem: new Request("conversation.item.create", (e) => e.kind === "item.created"),
36
+ historyResult: new Request("conversation.item.create", (e) => e.kind === "tool.result.created"),
37
+ history: new Request("prism.conversation.get", (e) => e.kind === "history.listed"),
38
+ delete: new Request("conversation.item.delete", (e) => e.kind === "item.deleted"),
34
39
  update: new Request("session.update", (e) => e.kind === "session.updated"),
35
40
  item: new Request("conversation.item.create", (e) => e.kind === "item.created"),
36
41
  toolResult: new Request("conversation.item.create", (e) => e.kind === "tool.result.created"),
@@ -79,6 +84,29 @@ function duration(value, field) {
79
84
  return invalid(field);
80
85
  return value;
81
86
  }
87
+ export function truncationSettings(value) {
88
+ if (value === "auto" || value === "disabled")
89
+ return value;
90
+ if (value &&
91
+ typeof value === "object" &&
92
+ !Array.isArray(value) &&
93
+ Object.keys(value).every((key) => key === "retentionRatio") &&
94
+ typeof value.retentionRatio === "number" &&
95
+ value.retentionRatio > 0 &&
96
+ value.retentionRatio <= 1)
97
+ return { type: "retention_ratio", retention_ratio: value.retentionRatio };
98
+ throw new RimeInputError("truncation must be auto, disabled, or a retentionRatio above zero and at most one");
99
+ }
100
+ function truncation(value) {
101
+ if (value === "auto" || value === "disabled")
102
+ return value;
103
+ const o = object(value, "session.truncation");
104
+ if (o.type !== "retention_ratio" ||
105
+ typeof o.retention_ratio !== "number" ||
106
+ !(o.retention_ratio > 0 && o.retention_ratio <= 1))
107
+ return invalid("session.truncation");
108
+ return Object.freeze({ retentionRatio: o.retention_ratio });
109
+ }
82
110
  function session(value) {
83
111
  const view = object(value, "session");
84
112
  const detection = view.turn_detection === undefined
@@ -116,6 +144,7 @@ function session(value) {
116
144
  invalid("session.prism_effective_interrupt_response");
117
145
  return {
118
146
  settingsRevision: revision(view),
147
+ truncation: view.truncation === undefined ? null : truncation(view.truncation),
119
148
  id: id(view.id, "session.id"),
120
149
  requestId: optionalString(view.request_id, "session.request_id"),
121
150
  responseMetadata: view.prism_response_metadata === undefined
@@ -221,15 +250,29 @@ function decodeValue(value, sessionId) {
221
250
  if (item.type === "function_call_output")
222
251
  payload = {
223
252
  kind: "tool.result.created",
253
+ itemId: optionalString(item.id, "item.id"),
224
254
  callId: id(item.call_id, "item.call_id"),
225
255
  };
226
256
  else {
227
- if (item.type !== undefined && item.type !== "message")
257
+ if (item.type !== undefined &&
258
+ item.type !== "message" &&
259
+ item.type !== "function_call")
228
260
  invalid("item.type");
229
261
  payload = { kind: "item.created", itemId: id(item.id, "item.id") };
230
262
  }
231
263
  break;
232
264
  }
265
+ case "prism.conversation":
266
+ if (!Array.isArray(event.items))
267
+ return invalid("items");
268
+ payload = {
269
+ kind: "history.listed",
270
+ items: Object.freeze(event.items.map(listedItem)),
271
+ };
272
+ break;
273
+ case "conversation.item.deleted":
274
+ payload = { kind: "item.deleted", itemId: id(event.item_id, "item_id") };
275
+ break;
233
276
  case "input_audio_buffer.cleared":
234
277
  payload = { kind: "audio.cleared" };
235
278
  break;
@@ -58,6 +58,12 @@ export declare class RealtimeSession {
58
58
  update(options?: t.RealtimeUpdateOptions): Promise<t.SessionInfo>;
59
59
  sendText(value: string, options?: t.RealtimeOperationOptions): Promise<t.ResponseRef>;
60
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>;
61
67
  addMessage(role: "user" | "assistant", value: string, options?: t.RealtimeOperationOptions): Promise<t.ItemRef>;
62
68
  submitToolResult(call: t.ToolCallRef, output: string, options?: t.RealtimeOperationOptions): Promise<void>;
63
69
  continueReply(parent: t.ResponseRef, options?: t.RealtimeOperationOptions): Promise<t.ResponseRef>;
@@ -10,6 +10,7 @@ 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";
13
14
  import { InterruptionPolicies, toolSettings } from "./settings.js";
14
15
  const NOT_READY = new Set([
15
16
  "typed_turn_busy",
@@ -268,6 +269,15 @@ export class RealtimeSession {
268
269
  request,
269
270
  result,
270
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,
271
281
  interruptOnSpeech: request.kind === "session.update"
272
282
  ? body.session.turn_detection?.interrupt_response
273
283
  : undefined,
@@ -367,6 +377,8 @@ export class RealtimeSession {
367
377
  * Cancellation after submission does not undo an accepted update. */
368
378
  async update(options = {}) {
369
379
  const settings = {};
380
+ if (options.truncation !== undefined)
381
+ settings.truncation = p.truncationSettings(options.truncation);
370
382
  for (const name of ["instructions", "voice"]) {
371
383
  const value = options[name];
372
384
  if (value !== undefined) {
@@ -409,18 +421,52 @@ export class RealtimeSession {
409
421
  }
410
422
  return this.operation(options, (signal) => this.create(body, signal));
411
423
  }
412
- async addMessage(role, value, options = {}) {
413
- if (role !== "user" && role !== "assistant")
414
- throw new RimeInputError("History role must be user or assistant");
415
- 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);
433
+ return this.operation(options, async (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
+ }
416
448
  return this.operation(options, async (signal) => {
417
- const event = await this.request(p.requests.item, { item: { type: "message", role, text: value } }, 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
+ }
418
458
  return Object.freeze({
419
459
  sessionId: this.info.sessionId,
420
460
  itemId: event.itemId,
421
461
  });
422
462
  });
423
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
+ }
424
470
  async submitToolResult(call, output, options = {}) {
425
471
  const ref = { ...call };
426
472
  this.response(ref);
@@ -609,6 +655,8 @@ export class RealtimeSession {
609
655
  const { payload, requestId: echo } = event;
610
656
  // Check echoed acknowledgments before changing response or item state.
611
657
  if (payload.kind === "session.updated" ||
658
+ payload.kind === "history.listed" ||
659
+ payload.kind === "item.deleted" ||
612
660
  payload.kind === "item.created" ||
613
661
  payload.kind === "tool.result.created" ||
614
662
  payload.kind === "response.started") {
@@ -616,6 +664,13 @@ export class RealtimeSession {
616
664
  if (pending &&
617
665
  !(payload.kind === "item.created" && pending.request.allowsItemCreated))
618
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");
619
674
  if (pending &&
620
675
  payload.kind === "tool.result.created" &&
621
676
  pending.target !== payload.callId)
@@ -645,6 +700,7 @@ export class RealtimeSession {
645
700
  toolContinuationTimeoutS: view.toolContinuationTimeoutS,
646
701
  turnDetection: view.turnDetection,
647
702
  settingsRevision: view.settingsRevision,
703
+ truncation: view.truncation,
648
704
  requestId: view.requestId,
649
705
  responseMetadata: view.responseMetadata,
650
706
  });
@@ -652,9 +708,28 @@ export class RealtimeSession {
652
708
  this.settle(echo, { ...payload, info: this.sessionInfo });
653
709
  return;
654
710
  }
655
- case "tool.result.created":
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");
656
718
  this.settle(echo, payload);
657
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
+ }
658
733
  case "item.created": {
659
734
  const pending = this.pending.get(echo ?? "");
660
735
  if (pending?.request.allowsItemCreated)
@@ -6,6 +6,8 @@ export declare class RealtimeConnection {
6
6
  private readonly ended;
7
7
  private closing;
8
8
  private connected;
9
+ private failed;
10
+ private refusal;
9
11
  readonly ready: Promise<void>;
10
12
  onMessage: (raw: string) => void;
11
13
  onFailure: (error: RimeError) => void;
@@ -1,6 +1,42 @@
1
1
  import WebSocket from "ws";
2
- import { RimeAuthenticationError, RimeStreamError, RimeUnavailableError, } from "../errors.js";
2
+ import { RimeAuthenticationError, RimeResourceLimitError, RimeStreamError, RimeUnavailableError, } from "../errors.js";
3
3
  import { Deferred } from "./async.js";
4
+ function handshakeError(status, body, requestId) {
5
+ let errorType = RimeUnavailableError;
6
+ let reason = "Realtime endpoint rejected the connection";
7
+ if ([401, 403].includes(status)) {
8
+ errorType = RimeAuthenticationError;
9
+ reason = "Realtime endpoint rejected credentials";
10
+ }
11
+ else if (status === 429) {
12
+ errorType = RimeResourceLimitError;
13
+ reason = "Realtime connection or rate limit reached";
14
+ }
15
+ else if (status === 503) {
16
+ reason = "Realtime endpoint is unavailable";
17
+ }
18
+ let message = `${reason} (HTTP ${status})`;
19
+ if (body.length > 0 && body.length < 1024) {
20
+ try {
21
+ let detail = new TextDecoder("utf-8", { fatal: true })
22
+ .decode(body)
23
+ .trim();
24
+ if (typeof detail === "string" && /^[{[]/.test(detail))
25
+ detail = JSON.parse(detail)?.error?.message;
26
+ if (typeof detail === "string" &&
27
+ !detail.trimStart().startsWith("<") &&
28
+ !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(detail)) {
29
+ detail = detail.trim().replace(/\s+/gu, " ");
30
+ if (detail)
31
+ message += `: ${detail}`;
32
+ }
33
+ }
34
+ catch {
35
+ // Invalid response bodies do not change the HTTP error category.
36
+ }
37
+ }
38
+ return new errorType(message, requestId);
39
+ }
4
40
  /** Private transport. The public declarations do not require ws types. */
5
41
  export class RealtimeConnection {
6
42
  socket;
@@ -8,6 +44,8 @@ export class RealtimeConnection {
8
44
  ended = new Deferred();
9
45
  closing = null;
10
46
  connected = false;
47
+ failed = false;
48
+ refusal;
11
49
  ready = this.opened.promise;
12
50
  onMessage = () => { };
13
51
  onFailure = () => { };
@@ -26,17 +64,39 @@ export class RealtimeConnection {
26
64
  this.opened.resolve();
27
65
  });
28
66
  this.socket.on("unexpected-response", (request, response) => {
29
- const error = [401, 403].includes(response.statusCode ?? 0)
30
- ? new RimeAuthenticationError("Realtime endpoint rejected credentials")
31
- : new RimeUnavailableError("Realtime endpoint rejected the connection");
32
- this.failure(error);
33
- response.destroy();
34
- request.destroy();
35
- this.socket.terminate();
67
+ const chunks = [];
68
+ let size = 0;
69
+ let finished = false;
70
+ const finish = (complete = false) => {
71
+ if (finished)
72
+ return;
73
+ finished = true;
74
+ clearTimeout(timer);
75
+ this.refusal = undefined;
76
+ const id = response.headers["x-request-id"];
77
+ this.failure(handshakeError(response.statusCode ?? 0, complete ? Buffer.concat(chunks) : Buffer.alloc(0), typeof id === "string" ? id : null));
78
+ response.destroy();
79
+ request.destroy();
80
+ this.socket.terminate();
81
+ };
82
+ const timer = setTimeout(finish, 1000);
83
+ this.refusal = finish;
84
+ response.on("data", (chunk) => {
85
+ size += chunk.length;
86
+ if (size >= 1024)
87
+ finish();
88
+ else
89
+ chunks.push(chunk);
90
+ });
91
+ response.on("end", () => finish(true));
92
+ response.on("error", () => finish());
93
+ response.on("aborted", () => finish());
36
94
  });
37
- this.socket.on("error", () => this.failure(!this.connected
38
- ? new RimeUnavailableError("Realtime handshake failed")
39
- : new RimeStreamError("Invalid event or lost realtime connection")));
95
+ this.socket.on("error", () => this.refusal
96
+ ? this.refusal()
97
+ : this.failure(!this.connected
98
+ ? new RimeUnavailableError("Realtime handshake failed")
99
+ : new RimeStreamError("Invalid event or lost realtime connection")));
40
100
  this.socket.on("message", (data, binary) => {
41
101
  if (binary) {
42
102
  this.failure(new RimeStreamError("Expected a JSON text event"));
@@ -45,11 +105,15 @@ export class RealtimeConnection {
45
105
  this.onMessage(data.toString());
46
106
  });
47
107
  this.socket.on("close", () => {
108
+ this.refusal?.();
48
109
  this.ended.resolve();
49
110
  this.failure(new RimeStreamError("Realtime connection closed; reconnect is not automatic"));
50
111
  });
51
112
  }
52
113
  failure(error) {
114
+ if (this.failed)
115
+ return;
116
+ this.failed = true;
53
117
  this.opened.reject(error);
54
118
  this.onFailure(error);
55
119
  }
@@ -66,6 +130,7 @@ export class RealtimeConnection {
66
130
  close() {
67
131
  if (!this.closing) {
68
132
  this.closing = (async () => {
133
+ this.refusal?.();
69
134
  if (this.socket.readyState === WebSocket.CLOSED)
70
135
  return;
71
136
  const timer = setTimeout(() => this.socket.terminate(), 2000);
@@ -40,6 +40,10 @@ export interface ToolDefinition {
40
40
  readonly description?: string;
41
41
  readonly prismFiller?: PrismFiller;
42
42
  }
43
+ /** History policy when the context fills. The server uses auto when omitted. */
44
+ export type Truncation = "auto" | "disabled" | {
45
+ readonly retentionRatio: number;
46
+ };
43
47
  export type TurnEagerness = "low" | "medium" | "high" | "auto";
44
48
  /** Omitted fields use the server's turn timing defaults. */
45
49
  export interface TurnDetection {
@@ -54,6 +58,7 @@ export interface TurnDetectionInfo {
54
58
  readonly endOfTurnTimeoutMs: number | null;
55
59
  }
56
60
  export interface RealtimeConnectOptions extends RealtimeOperationOptions {
61
+ readonly truncation?: Truncation;
57
62
  /** Defaults to wss://prism.api.rime.ai/v1/realtime. */
58
63
  readonly endpoint?: string;
59
64
  readonly model?: "prism";
@@ -64,8 +69,9 @@ export interface RealtimeConnectOptions extends RealtimeOperationOptions {
64
69
  readonly turnDetection?: TurnDetection;
65
70
  readonly timeouts?: RealtimeTimeouts;
66
71
  /** Sent as the upgrade's `x-request-id`. It must be nonblank printable ASCII, or
67
- * `connect` throws `RimeInputError`. Prism uses it if it is at most 128
68
- * characters, else makes its own; `info.requestId` reports the one it used.
72
+ * `connect` throws `RimeInputError`. Prism replaces IDs longer than 128 characters.
73
+ * A gateway can replace shorter IDs too. `info.requestId` reports the ID Prism
74
+ * actually used.
69
75
  * Omitted or `null` sends none. */
70
76
  readonly requestId?: string | null;
71
77
  /** Labels every reply's `response.started` carries in `metadata`, such as
@@ -75,6 +81,7 @@ export interface RealtimeConnectOptions extends RealtimeOperationOptions {
75
81
  }
76
82
  /** Omitted fields keep their values. Empty instructions or tools clear them. */
77
83
  export interface RealtimeUpdateOptions extends RealtimeOperationOptions {
84
+ readonly truncation?: Truncation;
78
85
  readonly instructions?: string;
79
86
  readonly tools?: readonly ToolDefinition[];
80
87
  readonly voice?: string;
@@ -88,6 +95,28 @@ export interface AudioChunk {
88
95
  readonly data: Uint8Array;
89
96
  readonly format?: PCMFormat;
90
97
  }
98
+ /** An item without audio. IDs are optional on input and present in history results. */
99
+ export type HistoryItem = {
100
+ readonly type: "message";
101
+ readonly id?: string;
102
+ readonly role: "user" | "assistant" | "system";
103
+ readonly text: string;
104
+ } | {
105
+ readonly type: "function_call";
106
+ readonly id?: string;
107
+ readonly callId: string;
108
+ readonly name: string;
109
+ readonly arguments: string;
110
+ } | {
111
+ readonly type: "function_call_output";
112
+ readonly id?: string;
113
+ readonly callId: string;
114
+ readonly output: string;
115
+ };
116
+ export interface HistoryCreateOptions extends RealtimeOperationOptions {
117
+ /** Omit to append, root to insert first, or an item ID to follow its whole turn. */
118
+ readonly previousItemId?: string;
119
+ }
91
120
  export interface ResponseRef {
92
121
  readonly sessionId: string;
93
122
  readonly responseId: string;
@@ -106,6 +135,8 @@ export interface OutputRef {
106
135
  readonly contentIndex: number;
107
136
  }
108
137
  export interface SessionInfo {
138
+ /** Null when an older server does not publish its history policy. */
139
+ readonly truncation?: Truncation | null;
109
140
  /** Settings revision published by the server; null when unavailable.
110
141
  * Optional on caller-created values for compatibility. */
111
142
  readonly settingsRevision?: number | null;
package/docs/realtime.md CHANGED
@@ -169,7 +169,7 @@ Use `session.setResponseMetadata` to replace reply labels.
169
169
  | `turnDetection` | No | Omitted settings use the server's turn timing defaults. |
170
170
  | `timeouts` | No | `{ connectS: 10, readyS: 30, requestS: 10 }`. Each field is optional. Units are seconds. |
171
171
  | `model` | No | `"prism"`, the only accepted value. |
172
- | `requestId` | No | Your id for the session. Sent as `x-request-id`. It must be nonblank printable ASCII, or the SDK throws `RimeInputError`. Prism uses it if it is at most 128 characters, else makes its own. |
172
+ | `requestId` | No | Sent as `x-request-id`. It must be nonblank printable ASCII, or the SDK throws `RimeInputError`. Prism replaces IDs longer than 128 characters. A gateway can replace shorter IDs too. Read `session.info.requestId` for the ID Prism actually used. |
173
173
  | `responseMetadata` | No | Labels every reply carries in `response.started.metadata`, such as `{ step: "3" }`: up to 16 strings, keys up to 64 characters and values up to 512, no key starting with `prism_`. Prism refuses labels that break these limits, or keys starting with `prism_`, with `RimeRealtimeError` code `invalid_session`. |
174
174
  | `signal` | No | An `AbortSignal` that cancels connection setup. It does not cancel an established session. |
175
175
 
@@ -276,6 +276,9 @@ Every method returns a promise. Arguments without `?` are required.
276
276
  | `sendAudio(chunk, options?)` | Sends microphone audio with transport backpressure. |
277
277
  | `requestReply(options?)` | Requests a reply without new user input; returns `ResponseRef`. Options can include `instruction` and `toolCall`. |
278
278
  | `addMessage(role, text, options?)` | Adds user or assistant history without a reply; returns `ItemRef` after acknowledgment. |
279
+ | `getHistory(options?)` | Returns the ordered `readonly HistoryItem[]` that the next reply would see. |
280
+ | `createItem(item, options?)` | Adds a message, past tool call, or tool result without a reply; returns `ItemRef` after acknowledgment. Options can include `previousItemId`. |
281
+ | `deleteItem(itemId, options?)` | Hides an item from later replies and waits for acknowledgment. |
279
282
  | `submitToolResult(call, output, options?)` | Records a string result and waits for acknowledgment. |
280
283
  | `continueReply(parent, options?)` | Waits for parent completion and all tool result acknowledgments, then returns a new `ResponseRef`. |
281
284
  | `cancel(response, options?)` | Requests generation cancellation and waits for a terminal response or refusal. |
@@ -285,12 +288,17 @@ Every method returns a promise. Arguments without `?` are required.
285
288
  | `close()` | Closes the session and releases its resources. |
286
289
 
287
290
  Operation options accept `signal?: AbortSignal`. `requestReply` accepts its
288
- instruction, tool call, and signal in one object. Methods other than `update`,
289
- `sendText`, `requestReply`, `addMessage`, and `continueReply` return `Promise<void>`.
290
-
291
- Text must be nonblank and contain at most 4,000 Unicode characters. History
292
- roles are `"user"` and `"assistant"`. A reply instruction applies to one response;
293
- it does not replace session instructions.
291
+ instruction, tool call, and signal in one object. `update` returns
292
+ `Promise<SessionInfo>`. `sendText`, `requestReply`, and `continueReply` return
293
+ `Promise<ResponseRef>`. `addMessage` and `createItem` return `Promise<ItemRef>`.
294
+ `getHistory` returns `Promise<readonly HistoryItem[]>`. The other methods return
295
+ `Promise<void>`.
296
+
297
+ `sendText` and `addMessage` require nonblank text of at most 4,000 Unicode
298
+ characters. `addMessage` accepts `"user"` and `"assistant"` roles. `createItem`
299
+ also accepts `"system"` messages and allows blank message text, with the same
300
+ length limit. A reply instruction applies to one response; it does not replace
301
+ session instructions.
294
302
 
295
303
  A returned `ResponseRef` means generation started. Read `response.ended` for the
296
304
  terminal status. Generation can finish before cancellation takes effect, so a
@@ -502,8 +510,7 @@ text or audio, which the application must handle before another attempt.
502
510
 
503
511
  For `context_length_exceeded`, the server message contains token counts.
504
512
  Unchanged attempts will fail until the conversation is shorter. Start a new
505
- session with shorter context. The protocol also supports history deletion, but
506
- this SDK does not yet expose `conversation.item.delete`.
513
+ session with shorter context. Use the history deletion method to remove items before retrying.
507
514
 
508
515
  Malformed fields in known events and mismatched request acknowledgments close
509
516
  the session with `RimeStreamError`. Unknown event kinds and extra fields remain
@@ -535,3 +542,48 @@ revisions can be ambiguous. The SDK preserves earlier known settings and raises
535
542
  a resource limit error for an ambiguous reply instead of guessing. A later
536
543
  acknowledgment with a revision establishes a new known setting. Replies without
537
544
  a revision use the current acknowledged setting.
545
+
546
+ ## Conversation history operations
547
+
548
+ Available in the next SDK release. The Prism server must support history operations.
549
+ Read history to obtain the items the next reply would see, in server order.
550
+ Create messages with user, assistant, or system roles, past function calls, and
551
+ function results. Creating an item does not start a reply or execute a tool.
552
+ Message text is limited to 4000 Unicode characters. Optional client item IDs have
553
+ 1 to 32 characters. Tool arguments and results preserve the supplied strings.
554
+
555
+ Omit the insertion anchor to append, use `root` to insert first, or give an item
556
+ ID to insert after its whole turn. Results go after their calls and cannot take
557
+ an anchor. Add a past call before adding its result.
558
+
559
+ Deletion hides items from subsequent replies and keeps the conversation record.
560
+ Deleting a call or its result hides both. An active reply keeps its original
561
+ context. A deleted active call still needs a result and continuation through the
562
+ existing tool APIs. Deleting an already deleted item succeeds; an unknown ID fails.
563
+ History excludes replies still running and their inputs until those replies end.
564
+ Requests work without typed-turn readiness. A canceled submitted request can still
565
+ take effect; the SDK tracks its acknowledgment and does not replay it.
566
+
567
+ ```text
568
+ const item = await session.createItem({ type: "message", role: "system", text: "Transferred to billing." }, { previousItemId: "root" });
569
+ const items = await session.getHistory();
570
+ await session.deleteItem(item.itemId);
571
+ ```
572
+
573
+ ## History truncation
574
+
575
+ Available in the next SDK release. Set `truncation` at connection setup or during
576
+ an existing session. Omit it to preserve compatibility with older Prism servers.
577
+ The server must support truncation before you send this setting.
578
+
579
+ `auto` drops the oldest turns and retains 0.8 of the available history capacity.
580
+ `disabled` keeps all history and reports `context_length_exceeded` when it no
581
+ longer fits. A retention ratio above zero and at most one selects how much history
582
+ capacity to retain. Instructions and tools remain. This setting does not delete
583
+ items from the conversation record. Session information reports the acknowledged
584
+ policy in `session.info.truncation`, or `null` when an older server does not
585
+ publish it.
586
+
587
+ ```text
588
+ await session.update({ truncation: { retentionRatio: 0.9 } });
589
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelabs/sdk",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.23",
4
4
  "description": "Rime streaming TTS, STT and Prism realtime SDK for Node.js",
5
5
  "license": "MIT",
6
6
  "type": "module",