castle-web-sdk 0.4.34 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,9 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
24
24
  - [Pass](#pass)
25
25
  - [Snapshot](#snapshot)
26
26
  - [Portal](#portal)
27
+ - [Decks](#decks)
27
28
  - [Haptics](#haptics)
29
+ - [Agent](#agent)
28
30
  - [Lifecycle](#lifecycle)
29
31
  - [Multiplayer](#multiplayer)
30
32
  - [Setup](#setup)
@@ -616,6 +618,38 @@ if (info.status === "ok") {
616
618
 
617
619
  Answers are kept per deck for the session, so asking again is cheap.
618
620
 
621
+ ## Decks
622
+
623
+ `Decks` lists decks the player can point a portal at: their own published
624
+ decks, and the decks they saved. A deck can offer these as a picker rather
625
+ than asking for a pasted link.
626
+ Decks is limited to Castle admins for now; for every other player both calls
627
+ reject with a `PERMISSION_DENIED` error.
628
+
629
+ ### `Decks.mine(options?): Promise<DecksListResult>`
630
+
631
+ The player's own decks, newest first, leaving out private ones.
632
+
633
+ ### `Decks.saved(options?): Promise<DecksListResult>`
634
+
635
+ The decks the player bookmarked, then the decks in their playlists, each
636
+ once.
637
+
638
+ Both take `{ limit }`, from 1 through 50 (default 20), and both leave out
639
+ decks whose creator turned portals off. The result is
640
+ `{ status: 'ok', decks }`, where each deck is
641
+ `{ deckId, title, creator: { userId, username } | null, coverUrl }` — the
642
+ same shape as `Portal.info`. `coverUrl` is a `data:` url, since a deck may
643
+ not load remote images; it is null when the deck has no cover. A player
644
+ who is not signed in gets an empty list.
645
+
646
+ ```js
647
+ const { decks } = await Decks.mine({ limit: 12 });
648
+ for (const deck of decks) {
649
+ addPickerTile(deck.title, deck.coverUrl, () => placePortal(deck.deckId));
650
+ }
651
+ ```
652
+
619
653
  ## Haptics
620
654
 
621
655
  `Haptics` plays a short device vibration ("buzz") for tactile feedback —
@@ -651,6 +685,103 @@ Haptics respect the player's settings — if a player has muted haptics,
651
685
  `play` does nothing and resolves `'unavailable'`. Rapid repeated calls may
652
686
  be coalesced, so it's safe to call on frequent events.
653
687
 
688
+ ## Agent
689
+
690
+ `Agent` asks a language model for one chat completion. The host sends it
691
+ through Castle's oracle on the player's own Castle account, so the deck
692
+ holds no key. Agent.chat is limited to Castle admins for now; every other
693
+ player gets `'refused'`.
694
+
695
+ ### `Agent.chat(params): Promise<AgentChatResult>`
696
+
697
+ `params`:
698
+
699
+ - `messages` — the conversation, in the OpenAI chat format
700
+ (`{ role, content }`, plus `tool_calls` on an assistant message and
701
+ `tool_call_id` on a `'tool'` message).
702
+ - `tools` — optional function tools, in the OpenAI function-tool schema.
703
+ - `toolChoice` — optional: `'auto'` (the default), `'none'`, `'required'`,
704
+ or `{ type: 'function', function: { name } }` to force one tool.
705
+ - `model` — optional OpenRouter model id. Defaults to
706
+ `'meta/muse-spark-1.3'`.
707
+ - `maxTokens` — optional cap on the reply's length.
708
+ - `reasoning` — optional `{ effort: 'minimal' | 'low' | 'medium' | 'high' }`,
709
+ how hard the model thinks before it answers. Lower effort answers sooner.
710
+ Left out, the model uses its own default. Muse can't turn reasoning off;
711
+ `'minimal'` is its least.
712
+ - `onDelta` — optional callback for the reply as it streams in. It gets
713
+ `{ type: 'text', text }` and `{ type: 'reasoning', text }` pieces, and
714
+ `{ type: 'toolCall', toolCall }` once for each tool call, when its
715
+ arguments are complete. Pieces come in the order the model wrote them.
716
+ - `signal` — optional `AbortSignal`. Aborting it stops the model and
717
+ resolves the call with `'cancelled'` at once.
718
+
719
+ The promise resolves with the whole message when the model finishes. A deck
720
+ runs any tool calls itself, appends the message and one `'tool'` message per
721
+ call, and calls Agent.chat again, until a reply has no tool calls:
722
+
723
+ ```js
724
+ import { Agent } from "castle-web-sdk";
725
+
726
+ const tools = [
727
+ {
728
+ type: "function",
729
+ function: {
730
+ name: "roll_dice",
731
+ description: "Roll six-sided dice.",
732
+ parameters: {
733
+ type: "object",
734
+ properties: { count: { type: "integer" } },
735
+ required: ["count"],
736
+ },
737
+ },
738
+ },
739
+ ];
740
+ const messages = [{ role: "user", content: "Roll two dice and add them." }];
741
+ const stop = new AbortController();
742
+
743
+ for (let round = 0; round < 8; round++) {
744
+ const result = await Agent.chat({
745
+ messages,
746
+ tools,
747
+ signal: stop.signal,
748
+ onDelta: (delta) => {
749
+ if (delta.type === "text") {
750
+ showText(delta.text);
751
+ }
752
+ },
753
+ });
754
+ if (result.status !== "ok") {
755
+ break;
756
+ }
757
+ messages.push(result.message);
758
+ const calls = result.message.tool_calls ?? [];
759
+ if (calls.length === 0) {
760
+ break;
761
+ }
762
+ for (const call of calls) {
763
+ const { count } = JSON.parse(call.function.arguments);
764
+ const faces = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6));
765
+ messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(faces) });
766
+ }
767
+ }
768
+ ```
769
+
770
+ `AgentChatResult` has a `status`:
771
+
772
+ - `'ok'` — `message` is the assistant's reply: `{ role, content,
773
+ tool_calls? }`, with `content` null when the reply is only tool calls.
774
+ `reasoning` is the model's reasoning summary when it gave one, for
775
+ display; it is not part of `message`. `finishReason` is `'stop'`,
776
+ `'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
777
+ `completion_tokens` and `total_tokens`.
778
+ - `'refused'` — the player is not a Castle admin. The model was not called.
779
+ - `'unavailable'` — this host has no route to the oracle.
780
+ - `'cancelled'` — the call's `signal` was aborted.
781
+ - `'error'` — the oracle or the model failed; `reason` says how.
782
+
783
+ Every status but `'ok'` carries a `reason`.
784
+
654
785
  ## Lifecycle
655
786
 
656
787
  `Lifecycle` tells the host when the deck has painted its first frame, so
@@ -0,0 +1,6 @@
1
+ import type { AgentChatParams, AgentChatResult } from "./commands";
2
+ export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, } from "./commands";
3
+ export interface CastleAgentApi {
4
+ chat(params: AgentChatParams): Promise<AgentChatResult>;
5
+ }
6
+ export declare const Agent: CastleAgentApi;
package/dist/agent.js ADDED
@@ -0,0 +1,70 @@
1
+ // Agent — one chat completion from a language model, run by the host through
2
+ // Castle's oracle with the player's own Castle account. Messages and tools
3
+ // follow the OpenAI chat completions format. The reply streams: `onDelta` gets
4
+ // text and reasoning as they arrive and each tool call once its arguments are
5
+ // complete, and the promise resolves with the whole message. A deck runs any
6
+ // tool calls itself and calls Agent.chat again with the results.
7
+ //
8
+ // Agent.chat is limited to Castle admins for now: every other player gets
9
+ // `refused`. A host with no route to the oracle answers `unavailable`.
10
+ import { CastleError } from "./errors";
11
+ import { hostRequest } from "./transport";
12
+ async function chat(params) {
13
+ validate(params);
14
+ const onDelta = params.onDelta;
15
+ try {
16
+ return await hostRequest("agent.chat", {
17
+ messages: params.messages,
18
+ tools: params.tools,
19
+ toolChoice: params.toolChoice,
20
+ model: params.model,
21
+ maxTokens: params.maxTokens,
22
+ reasoning: params.reasoning,
23
+ }, {
24
+ onStream: onDelta ? (event) => deliverDelta(onDelta, event) : undefined,
25
+ signal: params.signal,
26
+ });
27
+ }
28
+ catch (error) {
29
+ if (error instanceof CastleError && error.code === "CANCELLED") {
30
+ return { status: "cancelled", reason: "Agent.chat was cancelled." };
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
36
+ function validate(params) {
37
+ if (!params || typeof params !== "object") {
38
+ invalid("Agent.chat requires { messages }.");
39
+ }
40
+ if (!Array.isArray(params.messages) || params.messages.length === 0) {
41
+ invalid("Agent.chat messages must be a non-empty array.");
42
+ }
43
+ if (params.tools !== undefined && !Array.isArray(params.tools)) {
44
+ invalid("Agent.chat tools must be an array.");
45
+ }
46
+ if (params.model !== undefined && (typeof params.model !== "string" || params.model === "")) {
47
+ invalid("Agent.chat model must be a model id.");
48
+ }
49
+ if (params.maxTokens !== undefined &&
50
+ (!Number.isSafeInteger(params.maxTokens) || params.maxTokens < 1)) {
51
+ invalid("Agent.chat maxTokens must be a positive integer.");
52
+ }
53
+ if (params.reasoning !== undefined && !REASONING_EFFORTS.includes(params.reasoning?.effort)) {
54
+ invalid("Agent.chat reasoning must be { effort: 'minimal' | 'low' | 'medium' | 'high' }.");
55
+ }
56
+ if (params.onDelta !== undefined && typeof params.onDelta !== "function") {
57
+ invalid("Agent.chat onDelta must be a function.");
58
+ }
59
+ }
60
+ // Keepalives only restart the wait in transport.ts; the deck never sees them.
61
+ function deliverDelta(onDelta, event) {
62
+ const type = event?.type;
63
+ if (type === "text" || type === "reasoning" || type === "toolCall") {
64
+ onDelta(event);
65
+ }
66
+ }
67
+ function invalid(message) {
68
+ throw new CastleError({ code: "INVALID_ARGUMENT", message, operation: "Agent.chat" });
69
+ }
70
+ export const Agent = Object.freeze({ chat });
package/dist/castle.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export { isEdit } from "./context";
2
+ export { Agent } from "./agent";
3
+ export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, CastleAgentApi, } from "./agent";
2
4
  export { Characters } from "./characters";
3
5
  export type { CastleCharactersApi, CharacterHandle, CharacterHistoryOptions, CharacterSendInput, } from "./characters";
6
+ export { Decks } from "./decks";
7
+ export type { CastleDecksApi, DeckSummary, DecksListParams, DecksListResult } from "./decks";
4
8
  export { CastleError } from "./errors";
5
9
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
6
10
  export { Haptics } from "./haptics";
package/dist/castle.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Castle Web SDK
2
2
  export { isEdit } from "./context";
3
+ export { Agent } from "./agent";
3
4
  export { Characters } from "./characters";
5
+ export { Decks } from "./decks";
4
6
  export { CastleError } from "./errors";
5
7
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
6
8
  export { Haptics } from "./haptics";
@@ -142,7 +142,97 @@ export interface CharacterHistory {
142
142
  operations: CharacterOperation[];
143
143
  cursor: string | null;
144
144
  }
145
+ export interface AgentToolCall {
146
+ id: string;
147
+ type: "function";
148
+ function: {
149
+ name: string;
150
+ arguments: string;
151
+ };
152
+ }
153
+ export interface AgentMessage {
154
+ role: "system" | "user" | "assistant" | "tool";
155
+ content: string | Json[] | null;
156
+ tool_calls?: AgentToolCall[];
157
+ tool_call_id?: string;
158
+ name?: string;
159
+ }
160
+ export interface AgentTool {
161
+ type: "function";
162
+ function: {
163
+ name: string;
164
+ description?: string;
165
+ parameters?: Json;
166
+ };
167
+ }
168
+ export type AgentToolChoice = "auto" | "none" | "required" | {
169
+ type: "function";
170
+ function: {
171
+ name: string;
172
+ };
173
+ };
174
+ export type AgentChatDelta = {
175
+ type: "text";
176
+ text: string;
177
+ } | {
178
+ type: "reasoning";
179
+ text: string;
180
+ } | {
181
+ type: "toolCall";
182
+ toolCall: AgentToolCall;
183
+ };
184
+ export type AgentChatStreamEvent = AgentChatDelta | {
185
+ type: "keepalive";
186
+ };
187
+ export interface AgentReasoning {
188
+ effort: "minimal" | "low" | "medium" | "high";
189
+ }
190
+ export interface AgentChatParams {
191
+ messages: AgentMessage[];
192
+ tools?: AgentTool[];
193
+ toolChoice?: AgentToolChoice;
194
+ model?: string;
195
+ maxTokens?: number;
196
+ reasoning?: AgentReasoning;
197
+ onDelta?: (delta: AgentChatDelta) => void;
198
+ signal?: AbortSignal;
199
+ }
200
+ export type AgentChatRequest = Omit<AgentChatParams, "onDelta" | "signal">;
201
+ export interface AgentUsage {
202
+ prompt_tokens: number;
203
+ completion_tokens: number;
204
+ total_tokens: number;
205
+ }
206
+ export type AgentChatResult = {
207
+ status: "ok";
208
+ message: AgentMessage;
209
+ reasoning?: string;
210
+ finishReason: string | null;
211
+ usage?: AgentUsage;
212
+ } | {
213
+ status: "unavailable" | "refused" | "error" | "cancelled";
214
+ reason: string;
215
+ };
216
+ export interface DeckSummary {
217
+ deckId: string;
218
+ title: string;
219
+ creator: {
220
+ userId: string;
221
+ username: string;
222
+ } | null;
223
+ coverUrl: string | null;
224
+ }
225
+ export interface DecksListParams {
226
+ limit?: number | null;
227
+ }
228
+ export interface DecksListResult {
229
+ status: "ok";
230
+ decks: DeckSummary[];
231
+ }
145
232
  export interface CommandParams {
233
+ "agent.chat": AgentChatRequest;
234
+ "decks.mine": DecksListParams;
235
+ "decks.saved": DecksListParams;
146
236
  "deckStorage.load": Record<string, never>;
147
237
  "deckStorage.update": {
148
238
  updates: StorageUpdate[];
@@ -279,6 +369,9 @@ export interface CommandParams {
279
369
  "lifecycle.restoreState": Record<string, never>;
280
370
  }
281
371
  export interface CommandResult {
372
+ "agent.chat": AgentChatResult;
373
+ "decks.mine": DecksListResult;
374
+ "decks.saved": DecksListResult;
282
375
  "deckStorage.load": {
283
376
  blob: StorageBlob;
284
377
  };
@@ -365,6 +458,8 @@ export interface UserPayload {
365
458
  color: string | null;
366
459
  }
367
460
  export type CommandName = keyof CommandParams;
461
+ export declare const COMMAND_TIMEOUTS_MS: Partial<Record<CommandName, number>>;
462
+ export declare const STREAM_IDLE_TIMEOUT_MS = 60000;
368
463
  export interface SerializedCommandError {
369
464
  code: string;
370
465
  message: string;
@@ -385,6 +480,17 @@ export interface CommandResponseEnvelope {
385
480
  error?: SerializedCommandError;
386
481
  }
387
482
  export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
483
+ export interface CommandStreamEnvelope {
484
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
485
+ requestId: string;
486
+ stream: unknown;
487
+ }
488
+ export declare function isStreamEnvelope(value: unknown): value is CommandStreamEnvelope;
489
+ export interface CommandCancelEnvelope {
490
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
491
+ requestId: string;
492
+ cancel: true;
493
+ }
388
494
  export type LifecycleEvent = "ready" | "unloadInterest";
389
495
  export interface LifecycleEnvelope {
390
496
  castleSdk: typeof CASTLE_SDK_PROTOCOL;
package/dist/commands.js CHANGED
@@ -6,6 +6,18 @@
6
6
  // Marker + protocol version. Doubles as a discriminator so host messages can't
7
7
  // be confused with embed.js `castlexyz:` strings or RN console messages.
8
8
  export const CASTLE_SDK_PROTOCOL = 1;
9
+ // How long the deck waits for a reply to these commands, in place of the
10
+ // data-command timeout. A deck list fetches every cover on the list. A host
11
+ // from before streaming reads a whole model reply before answering agent.chat.
12
+ export const COMMAND_TIMEOUTS_MS = {
13
+ "agent.chat": 180_000,
14
+ "decks.mine": 30_000,
15
+ "decks.saved": 30_000,
16
+ };
17
+ // Once a host streams a command, each stream envelope restarts the wait at this
18
+ // length instead. The oracle sends a heartbeat every 15 seconds, and the host
19
+ // passes each one on.
20
+ export const STREAM_IDLE_TIMEOUT_MS = 60_000;
9
21
  export function isResponseEnvelope(value) {
10
22
  if (typeof value !== "object" || value === null)
11
23
  return false;
@@ -14,6 +26,15 @@ export function isResponseEnvelope(value) {
14
26
  typeof record.requestId === "string" &&
15
27
  typeof record.ok === "boolean");
16
28
  }
29
+ export function isStreamEnvelope(value) {
30
+ if (typeof value !== "object" || value === null) {
31
+ return false;
32
+ }
33
+ const record = value;
34
+ return (record.castleSdk === CASTLE_SDK_PROTOCOL &&
35
+ typeof record.requestId === "string" &&
36
+ "stream" in record);
37
+ }
17
38
  export function unloadRequestId(value) {
18
39
  if (typeof value !== "object" || value === null)
19
40
  return null;
@@ -0,0 +1,7 @@
1
+ import type { DecksListParams, DecksListResult } from "./commands";
2
+ export type { DeckSummary, DecksListParams, DecksListResult } from "./commands";
3
+ export interface CastleDecksApi {
4
+ mine(options?: DecksListParams): Promise<DecksListResult>;
5
+ saved(options?: DecksListParams): Promise<DecksListResult>;
6
+ }
7
+ export declare const Decks: CastleDecksApi;
package/dist/decks.js ADDED
@@ -0,0 +1,31 @@
1
+ // Decks — the player's own published decks and the decks they saved, for
2
+ // picking where a portal leads. Each entry has the same shape as Portal.info:
3
+ // deckId, title, creator and a cover as a data: url.
4
+ //
5
+ // Both lists leave out decks a portal cannot reach: a private deck, or one
6
+ // whose creator turned portals off.
7
+ //
8
+ // Decks is limited to Castle admins for now; other players get a
9
+ // PERMISSION_DENIED error.
10
+ import { CastleError } from "./errors";
11
+ import { hostRequest } from "./transport";
12
+ function mine(options = {}) {
13
+ return hostRequest("decks.mine", { limit: listLimit(options.limit, "Decks.mine") });
14
+ }
15
+ function saved(options = {}) {
16
+ return hostRequest("decks.saved", { limit: listLimit(options.limit, "Decks.saved") });
17
+ }
18
+ function listLimit(value, operation) {
19
+ if (value === undefined || value === null) {
20
+ return null;
21
+ }
22
+ if (Number.isSafeInteger(value) && value >= 1 && value <= 50) {
23
+ return value;
24
+ }
25
+ throw new CastleError({
26
+ code: "INVALID_ARGUMENT",
27
+ message: `${operation} limit must be an integer from 1 through 50.`,
28
+ operation,
29
+ });
30
+ }
31
+ export const Decks = Object.freeze({ mine, saved });
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type CommandName, type CommandParams, type CommandResponseEnvelope } from "./commands";
1
+ import { type CommandName, type CommandParams, type CommandResponseEnvelope, type CommandStreamEnvelope } from "./commands";
2
2
  import { type TunedParam } from "./paramsRegistry";
3
3
  export declare const CARD_RATIO: number;
4
4
  interface LocalResponse {
@@ -43,7 +43,9 @@ export declare function renameFile(from: string, to: string): Promise<void>;
43
43
  export declare function deleteFile(path: string): Promise<void>;
44
44
  export declare function openFile(path: string): void;
45
45
  export declare function initCard(): HTMLDivElement;
46
- export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResponseEnvelope>;
46
+ export declare function sendLocalCommand<C extends CommandName>(requestId: string, command: C, params: CommandParams[C], listener: (message: CommandResponseEnvelope | CommandStreamEnvelope) => void): Promise<void>;
47
+ export declare function cancelLocalCommand(requestId: string): void;
48
+ export declare function releaseLocalCommand(requestId: string): void;
47
49
  /**
48
50
  * Notified `true` on every fresh dev-server socket and `false` when one is lost.
49
51
  * What lets a queued write know the moment retrying is worth it again.
package/dist/runtime.js CHANGED
@@ -13,10 +13,9 @@ const pendingRequests = new Map();
13
13
  // Local-dev command channel: the `castle-web serve` dev server is the host, so
14
14
  // SDK commands ride the same websocket runtime.ts already uses for
15
15
  // logs/screenshots/restart. Correlated by requestId, separate from the
16
- // screenshot/write_file request map above.
17
- const COMMAND_TIMEOUT_MS = 15000;
16
+ // screenshot/write_file request map above. transport.ts owns the timeouts.
18
17
  const SOCKET_WAIT_TIMEOUT_MS = 10000;
19
- const pendingCommands = new Map();
18
+ const localCommandListeners = new Map();
20
19
  const origLog = console.log;
21
20
  const origWarn = console.warn;
22
21
  const origError = console.error;
@@ -324,38 +323,42 @@ function sendLocalRequest(msg) {
324
323
  ws.send(JSON.stringify(request));
325
324
  });
326
325
  }
327
- // Send an SDK command to the dev server and resolve with the raw response
328
- // envelope (ok/data/error). transport.ts interprets it — error reconstruction
329
- // stays uniform across all three channels there. Waits for the socket to open
330
- // so a command issued during startup isn't dropped.
331
- export function sendLocalCommand(command, params) {
332
- const requestId = `cmd_${nextRequestId++}`;
333
- return new Promise((resolve, reject) => {
334
- const timeout = setTimeout(() => {
335
- pendingCommands.delete(requestId);
336
- reject(new Error(`Timed out waiting for command ${command}.`));
337
- }, COMMAND_TIMEOUT_MS);
338
- pendingCommands.set(requestId, (env) => {
339
- clearTimeout(timeout);
340
- pendingCommands.delete(requestId);
341
- resolve(env);
342
- });
343
- waitForSocket()
344
- .then((socket) => {
345
- socket.send(JSON.stringify({
346
- type: "castle_command",
347
- castleSdk: CASTLE_SDK_PROTOCOL,
348
- requestId,
349
- command,
350
- params,
351
- }));
352
- })
353
- .catch((error) => {
354
- clearTimeout(timeout);
355
- pendingCommands.delete(requestId);
356
- reject(error instanceof Error ? error : new Error(String(error)));
357
- });
358
- });
326
+ // Send an SDK command to the dev server. Its stream envelopes and its response
327
+ // go to `listener` as they arrive, and the listener is dropped after the
328
+ // response. transport.ts interprets them, so error reconstruction stays uniform
329
+ // across all three channels there. Waits for the socket to open so a command
330
+ // issued during startup isn't dropped; rejects if it never opens.
331
+ export async function sendLocalCommand(requestId, command, params, listener) {
332
+ localCommandListeners.set(requestId, listener);
333
+ try {
334
+ const socket = await waitForSocket();
335
+ // Cancelled or timed out while the socket was opening.
336
+ if (!localCommandListeners.has(requestId)) {
337
+ return;
338
+ }
339
+ socket.send(JSON.stringify({
340
+ type: "castle_command",
341
+ castleSdk: CASTLE_SDK_PROTOCOL,
342
+ requestId,
343
+ command,
344
+ params,
345
+ }));
346
+ }
347
+ catch (error) {
348
+ localCommandListeners.delete(requestId);
349
+ throw error;
350
+ }
351
+ }
352
+ // Ask the dev server to stop a streaming command, and stop listening for it.
353
+ export function cancelLocalCommand(requestId) {
354
+ localCommandListeners.delete(requestId);
355
+ if (ws && ws.readyState === WebSocket.OPEN) {
356
+ ws.send(JSON.stringify({ type: "castle_command_cancel", castleSdk: CASTLE_SDK_PROTOCOL, requestId }));
357
+ }
358
+ }
359
+ // Stop listening for a command the deck has given up on.
360
+ export function releaseLocalCommand(requestId) {
361
+ localCommandListeners.delete(requestId);
359
362
  }
360
363
  function waitForSocket() {
361
364
  if (ws && ws.readyState === WebSocket.OPEN)
@@ -374,10 +377,15 @@ function waitForSocket() {
374
377
  }, 100);
375
378
  });
376
379
  }
377
- function resolveLocalCommand(msg) {
378
- const pending = pendingCommands.get(msg.requestId);
379
- if (pending)
380
- pending(msg);
380
+ function deliverLocalCommand(msg) {
381
+ const listener = localCommandListeners.get(msg.requestId);
382
+ if (!listener) {
383
+ return;
384
+ }
385
+ if (!("stream" in msg)) {
386
+ localCommandListeners.delete(msg.requestId);
387
+ }
388
+ listener(msg);
381
389
  }
382
390
  function resolveLocalRequest(msg) {
383
391
  if (!msg.requestId)
@@ -919,7 +927,10 @@ function handleLocalMessage(msg) {
919
927
  resolveLocalRequest(msg);
920
928
  }
921
929
  else if (msg.type === "castle_command_response") {
922
- resolveLocalCommand(msg);
930
+ deliverLocalCommand(msg);
931
+ }
932
+ else if (msg.type === "castle_command_stream") {
933
+ deliverLocalCommand(msg);
923
934
  }
924
935
  }
925
936
  // Files-changed listeners, registered by consumers (the kit engine, editors).
@@ -106,6 +106,48 @@ export interface EvaluateCharacterInformationInput {
106
106
  operationId: string;
107
107
  maxDeliveries?: number;
108
108
  }
109
+ export type CharacterStoryOpportunity = {
110
+ type: "approach" | "room_entry" | "world_action";
111
+ id: string;
112
+ };
113
+ export interface EvaluateCharacterBeatsInput {
114
+ operationId: string;
115
+ opportunityId: string;
116
+ opportunity: CharacterStoryOpportunity;
117
+ presentCharacterIds: string[];
118
+ }
119
+ export interface CharacterBeatSelection {
120
+ deliveryId: string;
121
+ beatId: string;
122
+ characterId: string;
123
+ openingText: string;
124
+ }
125
+ export interface CharacterBeatEvaluationResult {
126
+ operationId: string;
127
+ opportunityId: string;
128
+ opportunityCount: number;
129
+ gameTime: number | null;
130
+ selection: CharacterBeatSelection | null;
131
+ }
132
+ export type CharacterBeatEvaluationOperation = Omit<CharacterOperation, "result"> & {
133
+ result: CharacterBeatEvaluationResult | null;
134
+ };
135
+ export interface CompleteScheduledCharacterBeatInput {
136
+ operationId: string;
137
+ deliveryId: string;
138
+ outcomeId: string;
139
+ }
140
+ export interface CharacterScheduledBeatCompletionResult {
141
+ completionId: string;
142
+ deliveryId: string;
143
+ beatId: string;
144
+ characterId: string;
145
+ outcomeId: string;
146
+ completedAt: string;
147
+ }
148
+ export type CharacterScheduledBeatCompletionOperation = Omit<CharacterOperation, "result"> & {
149
+ result: CharacterScheduledBeatCompletionResult | null;
150
+ };
109
151
  export interface CharacterInformationEvaluationResult {
110
152
  generation: number;
111
153
  evaluatedAt: string;
@@ -153,6 +195,8 @@ export interface ServerPlaythroughCharacters {
153
195
  deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
154
196
  completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
155
197
  evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
198
+ evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
199
+ completeScheduledBeat(input: CompleteScheduledCharacterBeatInput): Promise<CharacterScheduledBeatCompletionOperation>;
156
200
  status(operationId: string): Promise<CharacterOperation>;
157
201
  history(options?: ServerCharacterHistoryOptions): Promise<CharacterHistory>;
158
202
  }
@@ -159,6 +159,25 @@ function createPlaythrough(call, subject, generation) {
159
159
  ...(maxDeliveries === undefined ? {} : { maxDeliveries }),
160
160
  });
161
161
  },
162
+ async evaluateBeats(input) {
163
+ return call("characterEvaluateBeats", {
164
+ subject,
165
+ generation,
166
+ operationId: assertOperationId(input?.operationId),
167
+ opportunityId: assertOpportunityId(input?.opportunityId),
168
+ opportunity: assertStoryOpportunity(input?.opportunity),
169
+ presentCharacterIds: assertPresentCharacterIds(input?.presentCharacterIds),
170
+ });
171
+ },
172
+ async completeScheduledBeat(input) {
173
+ return call("characterCompleteScheduledBeat", {
174
+ subject,
175
+ generation,
176
+ operationId: assertOperationId(input?.operationId),
177
+ deliveryId: assertOpportunityId(input?.deliveryId, "deliveryId"),
178
+ outcomeId: assertNarrativeId(input?.outcomeId, "outcomeId"),
179
+ });
180
+ },
162
181
  async status(operationId) {
163
182
  return call("characterStatus", {
164
183
  subject,
@@ -343,6 +362,41 @@ function assertOperationId(value) {
343
362
  }
344
363
  return value;
345
364
  }
365
+ function assertOpportunityId(value, field = "opportunityId") {
366
+ if (typeof value !== "string" ||
367
+ value.length === 0 ||
368
+ value.length > MAX_OPERATION_ID_CHARACTERS ||
369
+ value.includes("\0") ||
370
+ hasUnpairedSurrogate(value) ||
371
+ utf8Length(value) > MAX_OPERATION_ID_BYTES) {
372
+ throw characterArgumentError(`${field} must be valid text of at most ${MAX_OPERATION_ID_CHARACTERS} characters and ${MAX_OPERATION_ID_BYTES} bytes.`);
373
+ }
374
+ return value;
375
+ }
376
+ function assertStoryOpportunity(value) {
377
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
378
+ throw characterArgumentError("opportunity must contain a type and id.");
379
+ }
380
+ const opportunity = value;
381
+ if (Object.keys(opportunity).some((key) => key !== "type" && key !== "id") ||
382
+ !["approach", "room_entry", "world_action"].includes(String(opportunity.type))) {
383
+ throw characterArgumentError("opportunity type is invalid.");
384
+ }
385
+ return {
386
+ type: opportunity.type,
387
+ id: assertNarrativeId(opportunity.id, "opportunity.id"),
388
+ };
389
+ }
390
+ function assertPresentCharacterIds(value) {
391
+ if (!Array.isArray(value) || value.length > 16) {
392
+ throw characterArgumentError("presentCharacterIds must contain at most 16 character ids.");
393
+ }
394
+ const ids = value.map((item) => assertNarrativeId(item, "presentCharacterIds"));
395
+ if (new Set(ids).size !== ids.length) {
396
+ throw characterArgumentError("presentCharacterIds must not contain duplicates.");
397
+ }
398
+ return ids.sort();
399
+ }
346
400
  function assertNarrativeId(value, field) {
347
401
  if (typeof value !== "string" ||
348
402
  value.length === 0 ||
@@ -1,5 +1,9 @@
1
1
  import { type CommandName, type CommandParams, type CommandResult, type LifecycleEvent } from "./commands";
2
2
  type PostChannel = "mobile" | "web";
3
+ export interface HostStreamOptions {
4
+ onStream?: (event: unknown) => void;
5
+ signal?: AbortSignal;
6
+ }
3
7
  interface ReactNativeWebViewBridge {
4
8
  postMessage: (message: string) => void;
5
9
  }
@@ -11,7 +15,7 @@ declare global {
11
15
  };
12
16
  }
13
17
  }
14
- export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
18
+ export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C], stream?: HostStreamOptions): Promise<CommandResult[C]>;
15
19
  export declare function getCommandChannel(): PostChannel | "local";
16
20
  export declare function hostNotify(event: LifecycleEvent): void;
17
21
  export declare function postUnloadDone(unloadId: string, state?: string): void;
package/dist/transport.js CHANGED
@@ -7,10 +7,14 @@
7
7
  // - web → window.parent.postMessage; responses via 'message' events
8
8
  // - local → the castle-web serve dev server, over runtime.ts's websocket
9
9
  // Error reconstruction is uniform here so callers always get a CastleError.
10
- import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, unloadRequestId, } from "./commands";
10
+ //
11
+ // A streaming command (agent.chat) gets stream envelopes on its requestId before
12
+ // its response. Each one goes to the caller's onStream and restarts the wait,
13
+ // and the caller's AbortSignal sends the host a cancel envelope.
14
+ import { CASTLE_SDK_PROTOCOL, COMMAND_TIMEOUTS_MS, STREAM_IDLE_TIMEOUT_MS, isResponseEnvelope, isStreamEnvelope, unloadRequestId, } from "./commands";
11
15
  import { getCastleEmbed } from "./context";
12
16
  import { CastleError } from "./errors";
13
- import { sendLocalCommand } from "./runtime";
17
+ import { cancelLocalCommand, releaseLocalCommand, sendLocalCommand } from "./runtime";
14
18
  const REQUEST_TIMEOUT_MS = 15000;
15
19
  // Interactive platform commands hold the screen while the player interacts with
16
20
  // a host-native sheet (e.g. a pass purchase), so the flat data-command timeout
@@ -20,16 +24,16 @@ const INTERACTIVE_COMMANDS = new Set([
20
24
  "snapshot.offer",
21
25
  "portal.open",
22
26
  ]);
27
+ // Request ids carry a per-page prefix so that a host serving several decks at
28
+ // once can key a cancel by requestId alone.
29
+ const requestIdPrefix = `csdk_${Math.random().toString(36).slice(2, 10)}_`;
23
30
  let nextRequestId = 1;
24
31
  const pending = new Map();
25
32
  let listenersInstalled = false;
26
33
  let unloadHandler = null;
27
- export async function hostRequest(command, params) {
34
+ export async function hostRequest(command, params, stream = {}) {
28
35
  try {
29
- const channel = resolveChannel();
30
- const env = channel === "local"
31
- ? await sendLocalCommand(command, params)
32
- : await postCommand(channel, command, params);
36
+ const env = await sendCommand(resolveChannel(), command, params, stream);
33
37
  return interpretResponse(command, env);
34
38
  }
35
39
  catch (error) {
@@ -103,20 +107,84 @@ function resolveChannel() {
103
107
  // local dev server (top-level page served by `castle-web serve`).
104
108
  return window.parent && window.parent !== window ? "web" : "local";
105
109
  }
106
- function postCommand(channel, command, params) {
107
- installHostListener();
108
- const requestId = `csdk_${nextRequestId++}`;
110
+ function sendCommand(channel, command, params, stream) {
111
+ const requestId = `${requestIdPrefix}${nextRequestId++}`;
109
112
  return new Promise((resolve, reject) => {
110
- const timeout = INTERACTIVE_COMMANDS.has(command)
111
- ? undefined
112
- : setTimeout(() => {
113
- pending.delete(requestId);
114
- reject(new Error(`Castle host did not respond to ${command}.`));
115
- }, REQUEST_TIMEOUT_MS);
116
- pending.set(requestId, { resolve, reject, timeout });
113
+ if (stream.signal?.aborted) {
114
+ reject(cancelledError(command));
115
+ return;
116
+ }
117
+ const entry = { command, channel, resolve, reject, onStream: stream.onStream };
118
+ pending.set(requestId, entry);
119
+ restartTimeout(requestId, entry, COMMAND_TIMEOUTS_MS[command] ?? REQUEST_TIMEOUT_MS);
120
+ watchAbort(requestId, entry, stream.signal);
121
+ if (channel === "local") {
122
+ sendLocalCommand(requestId, command, params, receive).catch((error) => {
123
+ forget(requestId);
124
+ reject(error instanceof Error ? error : new Error(String(error)));
125
+ });
126
+ return;
127
+ }
128
+ installHostListener();
117
129
  sendEnvelope(channel, { castleSdk: CASTLE_SDK_PROTOCOL, requestId, command, params });
118
130
  });
119
131
  }
132
+ // Interactive commands have no timeout; they resolve only when the host replies.
133
+ function restartTimeout(requestId, entry, ms) {
134
+ if (entry.timeout) {
135
+ clearTimeout(entry.timeout);
136
+ }
137
+ if (INTERACTIVE_COMMANDS.has(entry.command)) {
138
+ return;
139
+ }
140
+ entry.timeout = setTimeout(() => {
141
+ forget(requestId);
142
+ entry.reject(new Error(`Castle host did not respond to ${entry.command}.`));
143
+ }, ms);
144
+ }
145
+ function watchAbort(requestId, entry, signal) {
146
+ if (!signal) {
147
+ return;
148
+ }
149
+ const onAbort = () => {
150
+ forget(requestId);
151
+ if (entry.channel === "local") {
152
+ cancelLocalCommand(requestId);
153
+ }
154
+ else {
155
+ const envelope = {
156
+ castleSdk: CASTLE_SDK_PROTOCOL,
157
+ requestId,
158
+ cancel: true,
159
+ };
160
+ sendEnvelope(entry.channel, envelope);
161
+ }
162
+ entry.reject(cancelledError(entry.command));
163
+ };
164
+ signal.addEventListener("abort", onAbort, { once: true });
165
+ entry.stopWatchingAbort = () => signal.removeEventListener("abort", onAbort);
166
+ }
167
+ function forget(requestId) {
168
+ const entry = pending.get(requestId);
169
+ if (!entry) {
170
+ return;
171
+ }
172
+ pending.delete(requestId);
173
+ if (entry.timeout) {
174
+ clearTimeout(entry.timeout);
175
+ }
176
+ entry.stopWatchingAbort?.();
177
+ if (entry.channel === "local") {
178
+ releaseLocalCommand(requestId);
179
+ }
180
+ }
181
+ function cancelledError(command) {
182
+ return new CastleError({
183
+ code: "CANCELLED",
184
+ message: `${command} was cancelled.`,
185
+ operation: command,
186
+ });
187
+ }
120
188
  function sendEnvelope(channel, envelope) {
121
189
  const json = JSON.stringify(envelope);
122
190
  if (channel === "mobile") {
@@ -146,21 +214,36 @@ function receive(message) {
146
214
  settle(message);
147
215
  return;
148
216
  }
217
+ if (isStreamEnvelope(message)) {
218
+ deliverStream(message.requestId, message.stream);
219
+ return;
220
+ }
149
221
  const unloadId = unloadRequestId(message);
150
222
  if (unloadId !== null)
151
223
  unloadHandler?.(unloadId);
152
224
  }
153
225
  function settle(message) {
154
- if (!isResponseEnvelope(message))
155
- return;
156
226
  const entry = pending.get(message.requestId);
157
- if (!entry)
227
+ if (!entry) {
158
228
  return;
159
- if (entry.timeout)
160
- clearTimeout(entry.timeout);
161
- pending.delete(message.requestId);
229
+ }
230
+ forget(message.requestId);
162
231
  entry.resolve(message);
163
232
  }
233
+ // A throwing onStream is logged, and the stream goes on.
234
+ function deliverStream(requestId, event) {
235
+ const entry = pending.get(requestId);
236
+ if (!entry) {
237
+ return;
238
+ }
239
+ restartTimeout(requestId, entry, STREAM_IDLE_TIMEOUT_MS);
240
+ try {
241
+ entry.onStream?.(event);
242
+ }
243
+ catch (error) {
244
+ console.error(`Castle ${entry.command} stream handler threw:`, error);
245
+ }
246
+ }
164
247
  function interpretResponse(command, env) {
165
248
  if (env.ok)
166
249
  return env.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/castle-xyz/castle-experimental-web.git"