@pouchy_ai/world-sdk 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,560 @@
1
+ // @pouchy_ai/world-sdk — the SERVER-side client for Pouchy World.
2
+ //
3
+ // Node only, and deliberately so: this client holds a project Secret Key
4
+ // (`pchy_sk_…`) and a source signing secret (`pesk_…`). They never belong in a
5
+ // browser or a mobile app — a world turn is a backend-to-backend act, and the
6
+ // two credentials together are what prove it. Ship the results to your users
7
+ // through your own surface; ship the keys nowhere.
8
+ //
9
+ // What this covers, and nothing more: the world plane. Story packages, world
10
+ // definitions, world sessions, coordinated turns, trusted events, the turn
11
+ // read-back, world state, replay verification and script drafts. Agents,
12
+ // capabilities and keys stay with `@pouchy_ai/admin-sdk`; the end-user runtime
13
+ // stays with `@pouchy_ai/companion-sdk`.
14
+ //
15
+ // Zero dependencies (Node's own `fetch` and `node:crypto`), typed errors, and
16
+ // helpers for the two things every integrator gets wrong on the first try:
17
+ // SIGNING a request the way the server verifies it, and choosing turn ids that
18
+ // make a retry idempotent instead of a second beat.
19
+ import { createHash, createHmac, randomUUID } from 'node:crypto';
20
+ export const WORLD_SDK_VERSION = '0.1.0';
21
+ export const DEFAULT_BASE_URL = 'https://pouchy.ai/v1';
22
+ // ── errors ─────────────────────────────────────────────────────────────────
23
+ /** The refusal classes a world call can produce. `unknown` is deliberate: an
24
+ * unrecognized status is never quietly folded into a neighbour. */
25
+ export const WORLD_ERROR_CODES = [
26
+ 'unauthorized',
27
+ 'forbidden',
28
+ 'not_found',
29
+ 'conflict',
30
+ 'unprocessable',
31
+ 'rate_limited',
32
+ 'payload_too_large',
33
+ 'server_error',
34
+ 'network',
35
+ 'unknown'
36
+ ];
37
+ export class WorldApiError extends Error {
38
+ code;
39
+ status;
40
+ /** The server's own message, when it sent one. */
41
+ detail;
42
+ retryAfterSec;
43
+ constructor(input) {
44
+ super(input.message);
45
+ this.name = 'WorldApiError';
46
+ this.code = input.code;
47
+ this.status = input.status;
48
+ if (input.detail !== undefined)
49
+ this.detail = input.detail;
50
+ if (input.retryAfterSec !== undefined)
51
+ this.retryAfterSec = input.retryAfterSec;
52
+ }
53
+ /** Worth trying again with the SAME idempotency key. A 409 is not: it means
54
+ * the world disagreed with the request, and repeating it will disagree
55
+ * again. */
56
+ get retryable() {
57
+ return this.code === 'rate_limited' || this.code === 'server_error' || this.code === 'network';
58
+ }
59
+ }
60
+ /** Retry-After, both carriers and both forms (RFC 9110). A client that hands a
61
+ * caller a negative or non-finite backoff turns a polite 429 into a hot loop
62
+ * or a crash, so: the body's own seconds first, then the header as a
63
+ * delta-seconds number, then the HTTP-date form proxies and CDNs emit.
64
+ * Anything that is not a non-negative finite number is `undefined` — no
65
+ * guessing. */
66
+ function retryAfterFrom(res, body) {
67
+ for (const k of ['retryAfterSec', 'retryAfter']) {
68
+ const v = body?.[k];
69
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
70
+ return v;
71
+ }
72
+ // Optional-chained: `options.fetch` is a documented injection point, and a
73
+ // hand-rolled double that returns a bare { ok, status, text } must not turn
74
+ // a 4xx fixture into a TypeError.
75
+ const raw = res.headers?.get?.('Retry-After');
76
+ if (raw === null || raw === undefined || raw.trim() === '')
77
+ return undefined;
78
+ const header = Number(raw);
79
+ if (Number.isFinite(header) && header >= 0)
80
+ return header;
81
+ const at = Date.parse(raw);
82
+ if (!Number.isNaN(at))
83
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
84
+ return undefined;
85
+ }
86
+ function codeForStatus(status) {
87
+ switch (status) {
88
+ case 401:
89
+ return 'unauthorized';
90
+ case 403:
91
+ return 'forbidden';
92
+ case 404:
93
+ return 'not_found';
94
+ case 409:
95
+ return 'conflict';
96
+ case 413:
97
+ return 'payload_too_large';
98
+ case 422:
99
+ return 'unprocessable';
100
+ case 429:
101
+ return 'rate_limited';
102
+ default:
103
+ if (status >= 500)
104
+ return 'server_error';
105
+ return 'unknown';
106
+ }
107
+ }
108
+ // ── signing + idempotency helpers ──────────────────────────────────────────
109
+ export const SOURCE_SIGNATURE_HEADER = 'X-Pouchy-Source-Signature';
110
+ const SOURCE_SIGNATURE_SCHEME = 'POUCHY-SOURCE-V1';
111
+ /** Build the `X-Pouchy-Source-Signature` header for one request body.
112
+ *
113
+ * The canonical string is five newline-joined lines — scheme, unix seconds,
114
+ * the declared source, the event/turn id, and the sha256 of the EXACT body
115
+ * bytes. Sign at SEND time, every attempt: a legitimate retry of the same id
116
+ * days later carries a fresh timestamp and passes the ±5 minute skew, because
117
+ * the signature proves origin and never doubles as a dedupe key.
118
+ *
119
+ * Pass the same string you will actually send as the body. Serializing twice
120
+ * (once to sign, once to send) is the classic way to sign bytes you did not
121
+ * send. */
122
+ export function signSourceRequest(input) {
123
+ const tSec = Math.floor((input.nowMs ?? Date.now()) / 1000);
124
+ const bodySha = input.body ? createHash('sha256').update(input.body, 'utf8').digest('hex') : '-';
125
+ const canonical = [SOURCE_SIGNATURE_SCHEME, String(tSec), input.source, input.id, bodySha].join('\n');
126
+ const v1 = createHmac('sha256', input.secret).update(canonical, 'utf8').digest('hex');
127
+ return `t=${tSec},kid=${input.keyId},v1=${v1}`;
128
+ }
129
+ /** A fresh turn id for a NEW beat. Retrying a beat means re-sending the SAME
130
+ * id — that is what makes the retry free of a second commit, a second model
131
+ * call and a second message. Generate once, store it with whatever you are
132
+ * about to do, and reuse it until you get an answer. */
133
+ export function newTurnId(prefix = 'turn') {
134
+ return `${prefix}_${randomUUID().replace(/-/g, '')}`;
135
+ }
136
+ /** `evt:` is the platform's own namespace for turns derived from trusted
137
+ * events; the turns door refuses caller ids in it. */
138
+ export function isReservedTurnId(turnId) {
139
+ return turnId.startsWith('evt:');
140
+ }
141
+ /** What to DO about a stuck delivery, in one call. The three answers are the
142
+ * three verbs, in the order they should be tried: re-send what is still there,
143
+ * rebuild it from the record, or tell the reader the beat is gone. */
144
+ export function describeDeliveryResolution(row) {
145
+ const blocking = row.blocking;
146
+ switch (row.status) {
147
+ case 'delivered':
148
+ return { action: 'none', blocking: false, summary: 'delivered' };
149
+ case 'resolved_gap':
150
+ return {
151
+ action: 'none',
152
+ blocking: false,
153
+ summary: 'the reader was told this beat could not be delivered'
154
+ };
155
+ case 'unrecoverable':
156
+ return {
157
+ action: 'resolve_gap',
158
+ blocking,
159
+ summary: `cannot be rebuilt (${row.unrecoverableReason ?? 'unknown'}) — resolve the gap so the session continues`
160
+ };
161
+ case 'rehydrating':
162
+ return { action: 'wait', blocking, summary: 'a rebuild is in flight' };
163
+ case 'dead':
164
+ return row.payloadRedactedAt
165
+ ? {
166
+ action: 'rehydrate',
167
+ blocking,
168
+ summary: 'the line aged out; rebuild it from the committed record'
169
+ }
170
+ : {
171
+ action: 'requeue',
172
+ blocking,
173
+ summary: `dead after ${row.attempts} attempts — requeue once the cause is fixed`
174
+ };
175
+ default:
176
+ return { action: 'wait', blocking, summary: 'still in the queue' };
177
+ }
178
+ }
179
+ export function describeDelivery(row) {
180
+ if (row.status === 'delivered') {
181
+ return { state: 'landed', blocking: false, summary: 'delivered' };
182
+ }
183
+ if (row.status === 'dead') {
184
+ return {
185
+ state: 'stuck',
186
+ blocking: row.blocking,
187
+ summary: row.payloadRedactedAt
188
+ ? `dead after ${row.attempts} attempts; the line has aged out and cannot be requeued`
189
+ : `dead after ${row.attempts} attempts (${row.lastErrorClass ?? 'unknown'}) — requeue once the cause is fixed`
190
+ };
191
+ }
192
+ if (row.status === 'delivering') {
193
+ return { state: 'in_flight', blocking: row.blocking, summary: 'a worker holds it' };
194
+ }
195
+ return {
196
+ state: 'waiting',
197
+ blocking: row.blocking,
198
+ summary: `attempt ${row.attempts + 1}, next at ${new Date(row.nextAttemptAt).toISOString()}`
199
+ };
200
+ }
201
+ export function describeTurn(result) {
202
+ const worldMoved = result.executionStatus === 'committed';
203
+ const audienceHeard = result.deliveryStatus === 'delivered';
204
+ const shouldRetrySameTurn = result.completionStatus === 'conflict';
205
+ const needsDifferentRequest = result.completionStatus === 'rejected' || result.completionStatus === 'refused';
206
+ return {
207
+ worldMoved,
208
+ audienceHeard,
209
+ shouldRetrySameTurn,
210
+ needsDifferentRequest,
211
+ summary: `${result.completionStatus}: world ${worldMoved ? 'moved' : 'did not move'}` +
212
+ ` (rev ${result.beforeStateRevision}→${result.afterStateRevision}),` +
213
+ ` delivery ${result.deliveryStatus}`
214
+ };
215
+ }
216
+ /** True for every code except `narrative_conflict`. */
217
+ export function isDefectRejection(code) {
218
+ return code !== undefined && code !== 'narrative_conflict';
219
+ }
220
+ export class PouchyWorldClient {
221
+ projectId;
222
+ baseUrl;
223
+ doFetch;
224
+ adminToken;
225
+ secretKey;
226
+ signing;
227
+ timeoutMs;
228
+ constructor(options) {
229
+ if (!options.projectId)
230
+ throw new Error('projectId is required');
231
+ this.projectId = options.projectId;
232
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
233
+ this.doFetch = options.fetch ?? globalThis.fetch;
234
+ if (options.adminToken !== undefined)
235
+ this.adminToken = options.adminToken;
236
+ if (options.secretKey !== undefined)
237
+ this.secretKey = options.secretKey;
238
+ if (options.signing !== undefined)
239
+ this.signing = options.signing;
240
+ this.timeoutMs = options.timeoutMs ?? 300_000;
241
+ }
242
+ // ── control plane (owner token) ──────────────────────────────────────────
243
+ listStoryPackages() {
244
+ return this.owner('GET', `/projects/${this.projectId}/story-packages`);
245
+ }
246
+ createStoryPackage(content) {
247
+ return this.owner('POST', `/projects/${this.projectId}/story-packages`, content);
248
+ }
249
+ getStoryPackage(packageId) {
250
+ return this.owner('GET', `/projects/${this.projectId}/story-packages/${packageId}`);
251
+ }
252
+ /** Publish the next IMMUTABLE story revision. Idempotent on content: the
253
+ * same bytes return the existing revision rather than minting a twin. */
254
+ publishStoryPackage(packageId, content) {
255
+ return this.owner('PATCH', `/projects/${this.projectId}/story-packages/${packageId}`, content);
256
+ }
257
+ listWorlds() {
258
+ return this.owner('GET', `/projects/${this.projectId}/environments`);
259
+ }
260
+ createWorld(definition) {
261
+ return this.owner('POST', `/projects/${this.projectId}/environments`, definition);
262
+ }
263
+ getWorld(environmentId) {
264
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}`);
265
+ }
266
+ /** Publish the next world revision. Existing world INSTANCES keep the
267
+ * revision they were created on — a published change reaches new instances
268
+ * only, which is what keeps a running story from changing runtime or rules
269
+ * underneath its players. */
270
+ publishWorld(environmentId, definition) {
271
+ return this.owner('PATCH', `/projects/${this.projectId}/environments/${environmentId}`, definition);
272
+ }
273
+ getWorldState(environmentId, worldInstanceId) {
274
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/state`);
275
+ }
276
+ /** Read back a COMMITTED turn. The recovery path when a response was lost:
277
+ * it re-runs nothing, and a 404 means the turn never committed. */
278
+ getTurn(environmentId, worldInstanceId, turnId) {
279
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns/${encodeURIComponent(turnId)}`);
280
+ }
281
+ /** Verify that the materialized state is what the committed ledger says.
282
+ * Always a dry run — it reports, it never repairs. Pass the previous
283
+ * report's `cursor` to continue a run that came back `incomplete`. */
284
+ replayLedger(environmentId, worldInstanceId, options = {}) {
285
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/ledger/replay`, options);
286
+ }
287
+ /** Walk a replay to completion, page by page. Bounded by `maxPages` so a
288
+ * caller can never spin: an unfinished walk returns its last report, and
289
+ * the cursor is in it. */
290
+ async replayLedgerToEnd(environmentId, worldInstanceId, options = {}) {
291
+ const maxPages = Math.max(1, Math.min(options.maxPages ?? 20, 100));
292
+ let report = await this.replayLedger(environmentId, worldInstanceId);
293
+ for (let page = 1; page < maxPages && report.verdict === 'incomplete' && report.cursor; page++) {
294
+ report = await this.replayLedger(environmentId, worldInstanceId, { cursor: report.cursor });
295
+ }
296
+ return report;
297
+ }
298
+ /** The delivery queue for one instance (Batch 7). Statuses, timings, attempt
299
+ * counts, error classes, and which row is blocking its session — never the
300
+ * message bodies. */
301
+ listDeliveries(environmentId, worldInstanceId, filters = {}) {
302
+ const q = new URLSearchParams();
303
+ for (const [k, v] of Object.entries(filters))
304
+ if (v !== undefined)
305
+ q.set(k, String(v));
306
+ const suffix = q.toString() ? `?${q}` : '';
307
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries${suffix}`);
308
+ }
309
+ /** One delivery. `includePayload` returns the line itself and writes an audit
310
+ * row naming you — pass it deliberately, not by default. */
311
+ getDelivery(environmentId, worldInstanceId, deliveryId, options = {}) {
312
+ const suffix = options.includePayload ? '?includePayload=true' : '';
313
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries/${deliveryId}${suffix}`);
314
+ }
315
+ /** Run one bounded drain now — for the moment after a requeue. */
316
+ drainDeliveries(environmentId, worldInstanceId) {
317
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries/drain`, {});
318
+ }
319
+ /** Put a dead letter back in the queue. There is deliberately no payload
320
+ * parameter: the beat was committed by the coordinator, and supplying a
321
+ * different line here would be authoring world history through the delivery
322
+ * plane. Idempotent — an already-queued row is a no-op. */
323
+ requeueDelivery(environmentId, worldInstanceId, deliveryId) {
324
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries/${deliveryId}/requeue`, {});
325
+ }
326
+ /** Turn an APPROVED editorial draft into a versioned production hand-off
327
+ * (Batch 7). Idempotent on content — the same approved review exports to
328
+ * the same id forever, so replaying one is a no-op rather than a second
329
+ * script. `notify` also sends `world.script_approved` to the project's
330
+ * webhooks, carrying lineage and identifiers but never the script body. */
331
+ createApprovedExport(environmentId, worldInstanceId, draftId, editorialId, options = {}) {
332
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}/approved-export`, options.notify ? { notify: true } : {});
333
+ }
334
+ listApprovedExports(environmentId, worldInstanceId, draftId, editorialId) {
335
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}/approved-export`);
336
+ }
337
+ /** The next Story Package as a CANDIDATE — validated, and returned rather
338
+ * than published. Publishing it is a separate act by a person, through
339
+ * `createStoryPackage`. Only evidence-origin material becomes canon. */
340
+ deriveStoryPackageCandidate(environmentId, worldInstanceId, draftId, editorialId, exportId, options = {}) {
341
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}/approved-export/candidate?exportId=${encodeURIComponent(exportId)}`, options);
342
+ }
343
+ /** Rebuild a dead letter whose text was redacted, FROM THE RECORD (Batch
344
+ * 7.1). Takes no body — nobody types a replacement line and no model
345
+ * regenerates one. Accepted only if the rebuild re-derives the same delivery
346
+ * id and the same content digest captured before the text was destroyed;
347
+ * otherwise it refuses with a code and the row becomes `unrecoverable`,
348
+ * which still blocks the session. */
349
+ rehydrateDelivery(environmentId, worldInstanceId, deliveryId) {
350
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries/${deliveryId}/rehydrate`, {});
351
+ }
352
+ /** Account for a beat that cannot be recovered. The only exit from
353
+ * `unrecoverable`, and it takes NO text: the notice is generated
354
+ * server-side from a fixed catalogue and rendered in the reader's language.
355
+ * The original resolves when the notice LANDS, not when you call this. */
356
+ resolveDeliveryGap(environmentId, worldInstanceId, deliveryId) {
357
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries/${deliveryId}/resolve-gap`, {});
358
+ }
359
+ /** Operator metrics for one instance (Batch 6): delivery and turn families,
360
+ * kept apart because "the lines arrived" and "the world played well" are
361
+ * different questions. */
362
+ getWorldMetrics(environmentId, worldInstanceId) {
363
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/metrics`);
364
+ }
365
+ /** Score the committed history against a quality suite. Deterministic — no
366
+ * model judges the output, so a score is something you can regress. */
367
+ evaluateWorld(environmentId, worldInstanceId, options = {}) {
368
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/eval`, { suite: options.suite ?? 'drama', ...(options.replayVerdict ? { replayVerdict: options.replayVerdict } : {}) });
369
+ }
370
+ /** Ledger archival. The default action is `plan`, which writes nothing;
371
+ * `execute` copies and never deletes; `prune` is the only call in this
372
+ * client that destroys a row, and it refuses without `confirm` — and again
373
+ * unless the archive verifies and has outlived its retention. */
374
+ archiveLedger(environmentId, worldInstanceId, options = {}) {
375
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/ledger/archive`, { action: options.action ?? 'plan', ...(options.confirm ? { confirm: true } : {}) });
376
+ }
377
+ listScriptDrafts(environmentId, worldInstanceId) {
378
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts`);
379
+ }
380
+ /** Generate (or return) the draft for this instance's committed history.
381
+ * Idempotent on the ledger range: the same range is the same draft. */
382
+ createScriptDraft(environmentId, worldInstanceId) {
383
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts`, {});
384
+ }
385
+ getScriptDraft(environmentId, worldInstanceId, draftId) {
386
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}`);
387
+ }
388
+ /** Mark a draft reviewed. The reviewer is the SIGNED-IN human whose token
389
+ * this client carries — never a field in the body. */
390
+ reviewScriptDraft(environmentId, worldInstanceId, draftId, note) {
391
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/review`, note ? { note } : {});
392
+ }
393
+ /** The reviewed draft as standard JSON. Refuses with 409 until a human has
394
+ * reviewed it — that gate is the product, not an obstacle. */
395
+ exportScriptDraft(environmentId, worldInstanceId, draftId) {
396
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/export`);
397
+ }
398
+ /** The EDITORIAL layer over an evidence draft (Batch 6). The evidence draft
399
+ * is deterministic and never changes; this is a model's reading of it, and
400
+ * every line it claims came from the story was re-checked server-side. */
401
+ listEditorialDrafts(environmentId, worldInstanceId, draftId) {
402
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial`);
403
+ }
404
+ /** Generate (or return) an editorial reading. Idempotent per model + prompt
405
+ * version + input digest, so asking twice is not a second opinion. */
406
+ createEditorialDraft(environmentId, worldInstanceId, draftId, model) {
407
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial`, model ? { model } : {});
408
+ }
409
+ getEditorialDraft(environmentId, worldInstanceId, draftId, editorialId) {
410
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}`);
411
+ }
412
+ /** Move an editorial draft through its review lifecycle. `exported` is not
413
+ * settable here — `exportEditorialDraft` writes it. */
414
+ setEditorialStatus(environmentId, worldInstanceId, draftId, editorialId, status, note) {
415
+ return this.owner('PATCH', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}`, { status, ...(note ? { note } : {}) });
416
+ }
417
+ /** One human verdict on one scene. `edited` carries the editor's own text —
418
+ * the only way text in this layer changes after generation. */
419
+ decideEditorialScene(environmentId, worldInstanceId, draftId, editorialId, input) {
420
+ return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}/scenes`, input);
421
+ }
422
+ /** Export an APPROVED editorial draft. `preview: true` reads the same payload
423
+ * without stamping it exported, so a reviewer can see what would leave. */
424
+ exportEditorialDraft(environmentId, worldInstanceId, draftId, editorialId, options = {}) {
425
+ const path = `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/script-drafts/${draftId}/editorial/${editorialId}/export`;
426
+ return options.preview ? this.owner('GET', path) : this.owner('POST', path, {});
427
+ }
428
+ // ── machine lane (secret key + source signature) ─────────────────────────
429
+ /** Mint a world SESSION for one end user in one role. The returned token is
430
+ * what your own frontend uses with the companion SDK; it is scoped to that
431
+ * instance and role and carries no project credential. */
432
+ createWorldSession(input) {
433
+ const requestId = input.requestId ?? randomUUID();
434
+ const body = {
435
+ external_user_id: input.externalUserId,
436
+ world: {
437
+ environment: input.environment,
438
+ role: input.role,
439
+ request_id: requestId,
440
+ ...(input.worldInstance ? { world_instance: input.worldInstance } : {})
441
+ }
442
+ };
443
+ return this.signed(`/sessions`, body, requestId);
444
+ }
445
+ /** Drive ONE coordinated beat. `turnId` is the idempotency key: re-send the
446
+ * same one to retry, mint a new one for a new beat. */
447
+ runTurn(input) {
448
+ const turnId = input.turnId ?? newTurnId();
449
+ if (isReservedTurnId(turnId)) {
450
+ throw new Error('turnId prefix "evt:" is reserved for trusted world events');
451
+ }
452
+ const body = {
453
+ turnId,
454
+ trigger: {
455
+ kind: 'user',
456
+ text: input.text,
457
+ ...(input.sourceRoleId ? { sourceRoleId: input.sourceRoleId } : {})
458
+ },
459
+ ...(input.traceId ? { traceId: input.traceId } : {})
460
+ };
461
+ return this.signed(`/projects/${this.projectId}/environments/${input.environmentId}/instances/${input.worldInstanceId}/turns`, body, turnId);
462
+ }
463
+ /** Send a trusted EVENT into a world. On a `coordinated` world this becomes
464
+ * one coordinator turn; on an `actor` world it wakes each subscribed role.
465
+ * Either way `eventId` is the dedupe key — re-send it freely. */
466
+ sendEvent(input) {
467
+ const eventId = input.eventId ?? newTurnId('evt');
468
+ const body = {
469
+ name: input.name,
470
+ eventId,
471
+ schemaVersion: input.schemaVersion ?? 1,
472
+ data: input.data,
473
+ world: { environment: input.environment, instance: input.worldInstance },
474
+ ...(input.occurredAt !== undefined ? { occurredAt: input.occurredAt } : {})
475
+ };
476
+ return this.signed(`/projects/${this.projectId}/events`, body, eventId);
477
+ }
478
+ // ── transport ────────────────────────────────────────────────────────────
479
+ async owner(method, path, body) {
480
+ if (!this.adminToken) {
481
+ throw new Error(`${method} ${path} needs an owner token (adminToken)`);
482
+ }
483
+ return this.request(method, path, {
484
+ headers: { authorization: `Bearer ${this.adminToken}` },
485
+ ...(body !== undefined ? { raw: JSON.stringify(body) } : {})
486
+ });
487
+ }
488
+ /** The machine lane: Secret Key AND a source signature over the EXACT bytes
489
+ * being sent — the two proofs the world requires of a backend. */
490
+ async signed(path, body, idSlot) {
491
+ if (!this.secretKey)
492
+ throw new Error(`${path} needs a project Secret Key (secretKey)`);
493
+ if (!this.signing)
494
+ throw new Error(`${path} needs a source signing key (signing)`);
495
+ const raw = JSON.stringify(body);
496
+ return this.request('POST', path, {
497
+ raw,
498
+ headers: {
499
+ authorization: `Bearer ${this.secretKey}`,
500
+ [SOURCE_SIGNATURE_HEADER]: signSourceRequest({
501
+ source: this.signing.source,
502
+ id: idSlot,
503
+ body: raw,
504
+ keyId: this.signing.keyId,
505
+ secret: this.signing.secret
506
+ })
507
+ }
508
+ });
509
+ }
510
+ async request(method, path, init) {
511
+ const controller = new AbortController();
512
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
513
+ let response;
514
+ try {
515
+ response = await this.doFetch(`${this.baseUrl}${path}`, {
516
+ method,
517
+ headers: {
518
+ 'content-type': 'application/json',
519
+ 'user-agent': `pouchy-world-sdk/${WORLD_SDK_VERSION}`,
520
+ ...init.headers
521
+ },
522
+ ...(init.raw !== undefined ? { body: init.raw } : {}),
523
+ signal: controller.signal
524
+ });
525
+ }
526
+ catch (err) {
527
+ throw new WorldApiError({
528
+ code: 'network',
529
+ status: 0,
530
+ message: `${method} ${path} failed to reach Pouchy`,
531
+ detail: err instanceof Error ? err.message : undefined
532
+ });
533
+ }
534
+ finally {
535
+ clearTimeout(timer);
536
+ }
537
+ const text = await response.text();
538
+ let parsed;
539
+ try {
540
+ parsed = text ? JSON.parse(text) : {};
541
+ }
542
+ catch {
543
+ parsed = { error: text.slice(0, 300) };
544
+ }
545
+ if (!response.ok) {
546
+ const detail = parsed && typeof parsed === 'object' && 'error' in parsed
547
+ ? String(parsed.error)
548
+ : undefined;
549
+ const retryAfterSec = retryAfterFrom(response, parsed && typeof parsed === 'object' ? parsed : null);
550
+ throw new WorldApiError({
551
+ code: codeForStatus(response.status),
552
+ status: response.status,
553
+ message: `${method} ${path} → ${response.status}`,
554
+ ...(detail !== undefined ? { detail } : {}),
555
+ ...(retryAfterSec !== undefined ? { retryAfterSec } : {})
556
+ });
557
+ }
558
+ return parsed;
559
+ }
560
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@pouchy_ai/world-sdk",
3
+ "version": "0.8.0",
4
+ "description": "Server-side TypeScript client for Pouchy World — story packages, world definitions, world sessions, coordinated turns, trusted events, replay verification and script drafts. Node only: it holds a project Secret Key and a source signing key, which never belong in a browser or a mobile app.",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "homepage": "https://pouchy.ai/sdk",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/oviswang/Pouchy.git",
11
+ "directory": "packages/world-sdk"
12
+ },
13
+ "bugs": {
14
+ "email": "support@pouchy.ai"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "conformance.mjs",
28
+ "README.md",
29
+ "CHANGELOG.md",
30
+ "LICENSE"
31
+ ],
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "prepublishOnly": "npm run build"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.5.0"
42
+ },
43
+ "keywords": [
44
+ "pouchy",
45
+ "world",
46
+ "interactive-fiction",
47
+ "npc",
48
+ "sdk",
49
+ "agent-platform"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }