@pouchy_ai/world-sdk 0.9.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @pouchy_ai/world-sdk
2
2
 
3
+ ## 0.11.0
4
+
5
+ Additive: your backend can tell the world what it already knows. World API 1.8.
6
+
7
+ - `runTurn` and `sendEvent` both accept `proposedPatches` — PROVIDER-authored
8
+ state changes committed with the beat. The server has accepted them on the
9
+ turns door since Batch 5; the SDK never exposed the field, and the event lane
10
+ never carried one at all. So a fact your system holds ("the payment cleared",
11
+ "the shipment arrived", "the player really does have the key") could only WAKE
12
+ the characters and hope one of them proposed the right effect.
13
+ - They ride the coordinator's single commit alongside the roles' settled
14
+ effects, and are validated server-side against the closed StatePatch union and
15
+ the pinned story package — all-or-nothing, so a batch with one bad op commits
16
+ nothing. A provider cannot write a role's private notes.
17
+ - COORDINATED worlds only. An actor world wakes each role separately and has no
18
+ single commit for a deterministic write to ride, so sending patches to one is
19
+ **refused with 422** rather than silently dropped. That refusal is the feature:
20
+ a channel that accepts input and discards it is the failure mode this project
21
+ has recorded three times, and it always reads as success.
22
+ - `agent.event_reply` webhooks from a coordinated world now carry `turnId`,
23
+ `stateRevision` and `sequence` in the `world` block. Until now a consumer got
24
+ the instance and the role and nothing else — it could not join the reply back
25
+ to the ledger turn, could not tell whether the world had moved, and could not
26
+ order two lines of one beat, since webhook delivery is per-message and
27
+ promises no ordering. All three are additive.
28
+ - No existing method changed shape.
29
+
30
+ ## 0.10.0
31
+
32
+ Additive: an unattended backend can now read its own world. World API 1.7.
33
+
34
+ - New `adminKey` client option — a project admin key (`pak_…`), minted once from
35
+ the dashboard, long-lived and machine-held. When present, the world READS
36
+ (`getWorldState`, `listTurns`, `listTurnsSince`, `getTurn`, `getWorldMetrics`,
37
+ `listDeliveries`) go to a new `/v1/admin/environments/**` mirror instead of the
38
+ owner plane.
39
+ - Why this exists: `adminToken` is a Firebase ID token — roughly an hour of life,
40
+ minted by a browser sign-in, with no API-key path. A backend could DRIVE a turn
41
+ with its own credentials (Secret Key + provider signature) and then needed a
42
+ human at a browser to find out what the turn did. Resuming after a crash was
43
+ the same story. That was the single thing keeping a sixty-minute integration
44
+ out of reach, and it was a credential gap, not a missing capability.
45
+ - The mirror is reads only, and every route is GET. Driving a turn still needs
46
+ `secretKey` + `signing`: an admin key proves the PROJECT, never the Provider,
47
+ and the world's turn door requires both proofs. The delivery queue ACTIONS
48
+ (requeue, rehydrate, resolve-gap, drain), ledger archival, and the content loop
49
+ stay on `adminToken` — each decides something about a real reader's experience
50
+ of a story, or shortens the committed record.
51
+ - Both doors answer from one shared server-side read, so which credential you
52
+ hold does not change the answer.
53
+ - No existing method changed shape, and `adminToken` keeps working exactly as
54
+ before.
55
+
3
56
  ## 0.9.0
4
57
 
5
58
  Additive: the read-back becomes a real recovery path, and the timeline gets a
package/README.md CHANGED
@@ -50,6 +50,50 @@ const world = new PouchyWorldClient({
50
50
  **Reading** — `getWorldState`, `getTurn`, `listTurns`, `listTurnsSince`,
51
51
  `replayLedger`, `replayLedgerToEnd`.
52
52
 
53
+ **Which credential does what.** This matters more than it looks: one of them
54
+ expires within the hour.
55
+
56
+ | you hold | you can | you cannot |
57
+ |---|---|---|
58
+ | `secretKey` + `signing` | mint sessions, drive turns, send events | read anything back |
59
+ | `adminKey` (`pak_…`, long-lived) | read the world: overview, state, timeline, turn read-back, metrics, delivery queue | drive a turn, act on the queue, author |
60
+ | `adminToken` (Firebase ID token, ~1h) | everything above plus authoring and the content loop | outlive the hour |
61
+
62
+ A server holds `secretKey` + `signing` + `adminKey` and needs no browser login
63
+ anywhere in its deployment. `adminToken` is for a person, or for a script a
64
+ person is watching.
65
+
66
+ ```ts
67
+ const world = new PouchyWorldClient({
68
+ projectId: process.env.POUCHY_PROJECT_ID!,
69
+ secretKey: process.env.POUCHY_SECRET_KEY!, // drive
70
+ adminKey: process.env.POUCHY_ADMIN_KEY!, // read back — does not expire
71
+ signing: { source: 'drama-backend', keyId: …, secret: … }
72
+ });
73
+ ```
74
+
75
+ An admin key proves the PROJECT, never the Provider, so it cannot drive a turn —
76
+ the turn door requires a Secret Key *and* a signature over the exact bytes. And
77
+ the delivery queue ACTIONS stay on `adminToken` on purpose: requeue, rehydrate
78
+ and resolve-gap each decide what happens to a reader who is missing a beat, and
79
+ resolve-gap tells them it is never coming.
80
+
81
+ **Telling the world a fact you already hold.** Your system knows things the
82
+ model can only guess at. Send them as patches and the world records them,
83
+ instead of hoping a character proposes the right effect:
84
+
85
+ ```ts
86
+ await world.runTurn({
87
+ environmentId, worldInstanceId,
88
+ text: 'I hand over the coin pouch.',
89
+ proposedPatches: [{ op: 'set_flag', key: 'paid', value: true }]
90
+ });
91
+ ```
92
+
93
+ `sendEvent` takes the same field. Coordinated worlds only — an actor world has
94
+ no single commit for a deterministic write to ride, and sending patches to one
95
+ answers 422 rather than dropping them quietly.
96
+
53
97
  **Resuming after a crash.** `getTurn` returns the same fields the live result
54
98
  did — `nextOptions` included — so a recovered session can offer the audience
55
99
  the choices it was about to. To catch up on beats you missed entirely, store
@@ -150,6 +194,8 @@ and exits non-zero on the first structural failure.
150
194
 
151
195
  - `docs/world-quickstart-drama.md` — screenplay → world → new screenplay draft
152
196
  - `docs/world-quickstart-npc.md` — one town, three NPCs, one shared state
197
+ - `docs/world-quickstart-nextjs.md` — a Next.js app: one route handler, one page,
198
+ no browser login anywhere in the deployment
153
199
 
154
200
  ## License
155
201
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const WORLD_SDK_VERSION = "0.9.0";
1
+ export declare const WORLD_SDK_VERSION = "0.11.0";
2
2
  export declare const DEFAULT_BASE_URL = "https://pouchy.ai/v1";
3
3
  /** The refusal classes a world call can produce. `unknown` is deliberate: an
4
4
  * unrecognized status is never quietly folded into a neighbour. */
@@ -474,8 +474,23 @@ export interface WorldClientOptions {
474
474
  /** The project this client acts for. */
475
475
  projectId: string;
476
476
  /** An OWNER-plane credential (a signed-in admin's ID token) for the control
477
- * plane: story packages, world definitions, drafts, replay, reads. */
477
+ * plane: story packages, world definitions, drafts, replay, reads.
478
+ *
479
+ * Short-lived — a Firebase ID token, roughly an hour — and minted by a
480
+ * browser sign-in. Fine for a script a person is watching; wrong for a
481
+ * server. For a server, use `adminKey`. */
478
482
  adminToken?: string;
483
+ /** A project ADMIN key (`pak_…`), minted once from the dashboard.
484
+ *
485
+ * Long-lived and machine-held: this is what an unattended backend uses.
486
+ * When present, the world READS (overview, state, timeline, turn read-back,
487
+ * metrics, deliveries) go to the `/admin` mirror instead of the owner
488
+ * plane, and no browser login is involved anywhere in your deployment.
489
+ *
490
+ * It proves the PROJECT, never the Provider, so it cannot drive a turn —
491
+ * that still needs `secretKey` + `signing`. Authoring calls and the content
492
+ * loop still need `adminToken`; see the README for which is which. */
493
+ adminKey?: string;
479
494
  /** A project Secret Key (`pchy_sk_…`) for the MACHINE lane: world sessions,
480
495
  * turns and trusted events. It also carries the test/live axis. */
481
496
  secretKey?: string;
@@ -497,6 +512,7 @@ export declare class PouchyWorldClient {
497
512
  private readonly baseUrl;
498
513
  private readonly doFetch;
499
514
  private readonly adminToken?;
515
+ private readonly adminKey?;
500
516
  private readonly secretKey?;
501
517
  private readonly signing?;
502
518
  private readonly timeoutMs;
@@ -734,6 +750,19 @@ export declare class PouchyWorldClient {
734
750
  text: string;
735
751
  sourceRoleId?: string;
736
752
  traceId?: string;
753
+ /** PROVIDER-authored state changes, committed with this beat.
754
+ *
755
+ * Your backend already knows things the model can only guess at — the
756
+ * payment cleared, the shipment arrived, the player actually holds the
757
+ * key. Send them as patches and the world HOLDS them, rather than
758
+ * hoping a character proposes the right effect.
759
+ *
760
+ * They ride the coordinator's single commit alongside the roles'
761
+ * settled effects, and are validated server-side against the closed
762
+ * StatePatch union and the pinned story package — all-or-nothing, so a
763
+ * batch with one bad op commits nothing. A provider cannot write a
764
+ * role's private notes. */
765
+ proposedPatches?: readonly Record<string, unknown>[];
737
766
  }): Promise<WorldTurnResult>;
738
767
  /** Send a trusted EVENT into a world. On a `coordinated` world this becomes
739
768
  * one coordinator turn; on an `actor` world it wakes each subscribed role.
@@ -746,7 +775,22 @@ export declare class PouchyWorldClient {
746
775
  data: Record<string, unknown>;
747
776
  schemaVersion?: number;
748
777
  occurredAt?: number;
778
+ /** PROVIDER-authored state changes to commit with the beat this event
779
+ * produces. Same contract as `runTurn`'s.
780
+ *
781
+ * COORDINATED worlds only. An actor world wakes each role separately
782
+ * and has no single commit for a deterministic write to ride, so
783
+ * sending these to one is refused with 422 rather than silently
784
+ * dropped — you will hear about it, which is the point. */
785
+ proposedPatches?: readonly Record<string, unknown>[];
749
786
  }): Promise<Record<string, unknown>>;
787
+ /** A world READ. Prefers the machine lane when an admin key is present.
788
+ *
789
+ * `ownerPath` and `adminPath` address the same read through two doors; the
790
+ * server answers both from one shared function, so which door you came in
791
+ * by does not change the answer. The admin door drops `projectId` from the
792
+ * path because the key already names the project. */
793
+ private read;
750
794
  private owner;
751
795
  /** The machine lane: Secret Key AND a source signature over the EXACT bytes
752
796
  * being sent — the two proofs the world requires of a backend. */
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  // SIGNING a request the way the server verifies it, and choosing turn ids that
18
18
  // make a retry idempotent instead of a second beat.
19
19
  import { createHash, createHmac, randomUUID } from 'node:crypto';
20
- export const WORLD_SDK_VERSION = '0.9.0';
20
+ export const WORLD_SDK_VERSION = '0.11.0';
21
21
  export const DEFAULT_BASE_URL = 'https://pouchy.ai/v1';
22
22
  // ── errors ─────────────────────────────────────────────────────────────────
23
23
  /** The refusal classes a world call can produce. `unknown` is deliberate: an
@@ -222,6 +222,7 @@ export class PouchyWorldClient {
222
222
  baseUrl;
223
223
  doFetch;
224
224
  adminToken;
225
+ adminKey;
225
226
  secretKey;
226
227
  signing;
227
228
  timeoutMs;
@@ -233,6 +234,8 @@ export class PouchyWorldClient {
233
234
  this.doFetch = options.fetch ?? globalThis.fetch;
234
235
  if (options.adminToken !== undefined)
235
236
  this.adminToken = options.adminToken;
237
+ if (options.adminKey !== undefined)
238
+ this.adminKey = options.adminKey;
236
239
  if (options.secretKey !== undefined)
237
240
  this.secretKey = options.secretKey;
238
241
  if (options.signing !== undefined)
@@ -271,7 +274,7 @@ export class PouchyWorldClient {
271
274
  return this.owner('PATCH', `/projects/${this.projectId}/environments/${environmentId}`, definition);
272
275
  }
273
276
  getWorldState(environmentId, worldInstanceId) {
274
- return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/state`);
277
+ return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/state`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/state`);
275
278
  }
276
279
  /** Read back a COMMITTED turn. The recovery path when a response was lost:
277
280
  * it re-runs nothing, and a 404 means the turn never committed. */
@@ -282,7 +285,7 @@ export class PouchyWorldClient {
282
285
  * same choices it would have. Rows committed before that carry none of the
283
286
  * four turn-time facts: absent means UNKNOWN, never "none". */
284
287
  getTurn(environmentId, worldInstanceId, turnId) {
285
- return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns/${encodeURIComponent(turnId)}`);
288
+ return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns/${encodeURIComponent(turnId)}`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/turns/${encodeURIComponent(turnId)}`);
286
289
  }
287
290
  /** Verify that the materialized state is what the committed ledger says.
288
291
  * Always a dry run — it reports, it never repairs. Pass the previous
@@ -300,7 +303,7 @@ export class PouchyWorldClient {
300
303
  if (opts?.sinceSeq !== undefined)
301
304
  q.set('sinceSeq', String(opts.sinceSeq));
302
305
  const suffix = q.toString() ? `?${q}` : '';
303
- return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns${suffix}`);
306
+ return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/turns`, suffix);
304
307
  }
305
308
  /** Walk the timeline FORWARD from a revision already processed, following
306
309
  * the cursor to the end. The resume primitive: a backend that stored the
@@ -355,7 +358,7 @@ export class PouchyWorldClient {
355
358
  if (v !== undefined)
356
359
  q.set(k, String(v));
357
360
  const suffix = q.toString() ? `?${q}` : '';
358
- return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries${suffix}`);
361
+ return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/deliveries`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/deliveries`, suffix);
359
362
  }
360
363
  /** One delivery. `includePayload` returns the line itself and writes an audit
361
364
  * row naming you — pass it deliberately, not by default. */
@@ -411,7 +414,7 @@ export class PouchyWorldClient {
411
414
  * kept apart because "the lines arrived" and "the world played well" are
412
415
  * different questions. */
413
416
  getWorldMetrics(environmentId, worldInstanceId) {
414
- return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/metrics`);
417
+ return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/metrics`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/metrics`);
415
418
  }
416
419
  /** Score the committed history against a quality suite. Deterministic — no
417
420
  * model judges the output, so a score is something you can regress. */
@@ -505,7 +508,8 @@ export class PouchyWorldClient {
505
508
  trigger: {
506
509
  kind: 'user',
507
510
  text: input.text,
508
- ...(input.sourceRoleId ? { sourceRoleId: input.sourceRoleId } : {})
511
+ ...(input.sourceRoleId ? { sourceRoleId: input.sourceRoleId } : {}),
512
+ ...(input.proposedPatches ? { proposedPatches: input.proposedPatches } : {})
509
513
  },
510
514
  ...(input.traceId ? { traceId: input.traceId } : {})
511
515
  };
@@ -521,15 +525,35 @@ export class PouchyWorldClient {
521
525
  eventId,
522
526
  schemaVersion: input.schemaVersion ?? 1,
523
527
  data: input.data,
524
- world: { environment: input.environment, instance: input.worldInstance },
528
+ world: {
529
+ environment: input.environment,
530
+ instance: input.worldInstance,
531
+ ...(input.proposedPatches ? { proposedPatches: input.proposedPatches } : {})
532
+ },
525
533
  ...(input.occurredAt !== undefined ? { occurredAt: input.occurredAt } : {})
526
534
  };
527
535
  return this.signed(`/projects/${this.projectId}/events`, body, eventId);
528
536
  }
529
537
  // ── transport ────────────────────────────────────────────────────────────
538
+ /** A world READ. Prefers the machine lane when an admin key is present.
539
+ *
540
+ * `ownerPath` and `adminPath` address the same read through two doors; the
541
+ * server answers both from one shared function, so which door you came in
542
+ * by does not change the answer. The admin door drops `projectId` from the
543
+ * path because the key already names the project. */
544
+ async read(ownerPath, adminPath, query = '') {
545
+ if (this.adminKey) {
546
+ return this.request('GET', `${adminPath}${query}`, {
547
+ headers: { authorization: `Bearer ${this.adminKey}` }
548
+ });
549
+ }
550
+ return this.owner('GET', `${ownerPath}${query}`);
551
+ }
530
552
  async owner(method, path, body) {
531
553
  if (!this.adminToken) {
532
- throw new Error(`${method} ${path} needs an owner token (adminToken)`);
554
+ throw new Error(`${method} ${path} needs an owner token (adminToken) — or, for a world READ ` +
555
+ `from an unattended backend, a project admin key (adminKey), which routes ` +
556
+ `the read through the /admin mirror and does not expire`);
533
557
  }
534
558
  return this.request(method, path, {
535
559
  headers: { authorization: `Bearer ${this.adminToken}` },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/world-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Server-side TypeScript client for Pouchy World \u2014 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
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",