@pouchy_ai/world-sdk 0.8.0 → 0.9.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,35 @@
1
1
  # @pouchy_ai/world-sdk
2
2
 
3
+ ## 0.9.0
4
+
5
+ Additive: the read-back becomes a real recovery path, and the timeline gets a
6
+ resume cursor. World API 1.6.
7
+
8
+ - `getTurn` now returns a typed `WorldTurnReadback` instead of an opaque
9
+ record, and the server fills in the four turn-time facts the live result
10
+ always had and the read-back never did: `selectedRoles`, `skippedRoles`,
11
+ `repairs` and `nextOptions`. Recovering a lost response used to tell you what
12
+ the world changed and not what it offered next, which made resuming a story
13
+ guesswork. All four are optional — a turn committed before this release
14
+ carries none of them, and absence means UNKNOWN, not "none".
15
+ - Two divergences from `WorldTurnResult` are deliberate and now typed rather
16
+ than implied: a read-back's `completionStatus`/`executionStatus` are the
17
+ constant `'committed'` (a turn that never committed has no entry and answers
18
+ 404), and its `deliveryStatus` adds `'unknown'` (once settled rows are
19
+ reaped, the absence of a queue row is not proof of delivery).
20
+ - New `listTurns(environmentId, worldInstanceId, { limit, sinceSeq })` returning
21
+ a `WorldTimelinePage`. With `sinceSeq` you get the beats AFTER that revision,
22
+ oldest first, plus `nextSinceSeq` to continue; without it you get the
23
+ newest-first window as before. The response names its own `order`.
24
+ - New `listTurnsSince(...)` — follows that cursor to the end. This is the resume
25
+ primitive: a backend that stored the last `seq` it handled gets exactly the
26
+ beats it missed, in order, with no gap at a page boundary and no beat twice.
27
+ Bounded by `maxPages`, because an unbounded follow loop against a busy world
28
+ never returns.
29
+ - The API document is now served at `GET https://pouchy.ai/v1/world/openapi` —
30
+ public, unauthenticated, and previously reachable by nobody.
31
+ - No existing method changed shape.
32
+
3
33
  ## 0.8.0
4
34
 
5
35
  - `WorldDeliveryOpsRow` gained `cleanupRetryCount` and `cleanupStalledAt`.
package/README.md CHANGED
@@ -47,7 +47,22 @@ const world = new PouchyWorldClient({
47
47
 
48
48
  **Running** — `createWorldSession`, `runTurn`, `sendEvent`.
49
49
 
50
- **Reading** — `getWorldState`, `getTurn`, `replayLedger`, `replayLedgerToEnd`.
50
+ **Reading** — `getWorldState`, `getTurn`, `listTurns`, `listTurnsSince`,
51
+ `replayLedger`, `replayLedgerToEnd`.
52
+
53
+ **Resuming after a crash.** `getTurn` returns the same fields the live result
54
+ did — `nextOptions` included — so a recovered session can offer the audience
55
+ the choices it was about to. To catch up on beats you missed entirely, store
56
+ the last `seq` you processed and call `listTurnsSince`:
57
+
58
+ ```ts
59
+ const missed = await world.listTurnsSince(envId, instanceId, lastSeqIHandled);
60
+ for (const beat of missed) render(beat); // in order, no gap, no repeat
61
+ ```
62
+
63
+ Both are additive: a turn committed before world API 1.6 carries none of the
64
+ four turn-time facts (`selectedRoles`, `skippedRoles`, `repairs`,
65
+ `nextOptions`). Absent means UNKNOWN, never "none".
51
66
 
52
67
  **Content return** — `createScriptDraft`, `getScriptDraft`, `listScriptDrafts`,
53
68
  `reviewScriptDraft`, `exportScriptDraft`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const WORLD_SDK_VERSION = "0.1.0";
1
+ export declare const WORLD_SDK_VERSION = "0.9.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. */
@@ -106,6 +106,102 @@ export interface WorldTurnResult {
106
106
  /** Why a redacted dead letter could not be rebuilt. A closed vocabulary: an
107
107
  * operator deciding whether to resolve a gap is making a judgement call, and a
108
108
  * free-text reason is one they cannot compare across incidents. */
109
+ /** What `GET …/turns/{turnId}` answers. The live result's fields, plus what
110
+ * only the record knows (when it committed, which revision it bound to, how
111
+ * its lines were delivered).
112
+ *
113
+ * Two deliberate differences from `WorldTurnResult`, both because a read-back
114
+ * exists ONLY for a turn that committed:
115
+ *
116
+ * · `completionStatus` / `executionStatus` are the constant `'committed'`,
117
+ * not a member of the live union. A turn that never committed has no entry
118
+ * and answers 404 — there is no status to report;
119
+ * · `deliveryStatus` adds `'unknown'`, which is a real answer once settled
120
+ * rows have been reaped: no queue row is not proof of delivery.
121
+ *
122
+ * The four turn-time facts are optional because rows committed before world
123
+ * API 1.6 do not carry them. Absent means UNKNOWN. */
124
+ export interface WorldTurnReadback {
125
+ turnId: string;
126
+ worldInstanceId: string;
127
+ beforeStateRevision: number;
128
+ afterStateRevision: number;
129
+ roleMessages: Array<{
130
+ roleId: string;
131
+ message: string;
132
+ }>;
133
+ committedStateDiff: unknown[];
134
+ rejectedEffects: Array<{
135
+ index: number;
136
+ reason: string;
137
+ roleId?: string;
138
+ kind?: string;
139
+ }>;
140
+ selectedRoles?: string[];
141
+ skippedRoles?: Array<{
142
+ roleId: string;
143
+ reason: string;
144
+ }>;
145
+ repairs?: Array<{
146
+ roleId: string;
147
+ outcome: 'repaired' | 'fallback';
148
+ reason?: string;
149
+ }>;
150
+ nextOptions?: Array<{
151
+ branchId: string;
152
+ condition: string;
153
+ }>;
154
+ committedAt: number;
155
+ environmentRevision: number;
156
+ storyPackageRef?: {
157
+ packageId: string;
158
+ revision: number;
159
+ contentHash: string;
160
+ };
161
+ eventId?: string;
162
+ traceId?: string;
163
+ completionStatus: 'committed';
164
+ executionStatus: 'committed';
165
+ deliveryStatus: WorldDeliveryStatus | 'unknown';
166
+ deliveryGaps?: unknown[];
167
+ deliveryCounts?: {
168
+ total: number;
169
+ delivered: number;
170
+ [k: string]: number;
171
+ };
172
+ }
173
+ /** One page of the committed timeline. `order` says which way it runs, because
174
+ * the two calls answer different questions: without a cursor you get the
175
+ * newest beats first, with one you get everything after it, oldest first. */
176
+ export interface WorldTimelinePage {
177
+ beats: Array<{
178
+ entryId: string;
179
+ seq: number;
180
+ kind: string;
181
+ actorRoleId: string | null;
182
+ beforeRevision: number;
183
+ afterRevision: number;
184
+ patchCount: number;
185
+ committedAt: number;
186
+ turnId: string | null;
187
+ messages: Array<{
188
+ roleId: string;
189
+ text: string;
190
+ fallback?: true;
191
+ }>;
192
+ effects: Array<{
193
+ kind: string;
194
+ roleId: string;
195
+ target: string | null;
196
+ accepted: boolean;
197
+ code?: WorldRejectionCode;
198
+ reason?: string;
199
+ }>;
200
+ }>;
201
+ order: 'asc' | 'desc';
202
+ /** Present only on a cursor read: pass it back as `sinceSeq` to continue. */
203
+ nextSinceSeq?: number | null;
204
+ }
109
205
  export type WorldRehydrateFailure = 'source_missing' | 'archive_unavailable' | 'hash_mismatch' | 'ledger_drift' | 'identity_mismatch';
110
206
  /** What to DO about a stuck delivery, in one call. The three answers are the
111
207
  * three verbs, in the order they should be tried: re-send what is still there,
@@ -448,10 +544,38 @@ export declare class PouchyWorldClient {
448
544
  }>;
449
545
  /** Read back a COMMITTED turn. The recovery path when a response was lost:
450
546
  * it re-runs nothing, and a 404 means the turn never committed. */
451
- getTurn(environmentId: string, worldInstanceId: string, turnId: string): Promise<Record<string, unknown>>;
547
+ /** Read a COMMITTED turn back the recovery path when a response was lost.
548
+ *
549
+ * As of world API 1.6 this carries the same fields the live result did,
550
+ * `nextOptions` included, so a resumed session can offer the audience the
551
+ * same choices it would have. Rows committed before that carry none of the
552
+ * four turn-time facts: absent means UNKNOWN, never "none". */
553
+ getTurn(environmentId: string, worldInstanceId: string, turnId: string): Promise<WorldTurnReadback>;
452
554
  /** Verify that the materialized state is what the committed ledger says.
453
555
  * Always a dry run — it reports, it never repairs. Pass the previous
454
556
  * report's `cursor` to continue a run that came back `incomplete`. */
557
+ /** ONE page of the committed timeline.
558
+ *
559
+ * Pass `sinceSeq` to resume: you get the beats after it, oldest first, and
560
+ * a `nextSinceSeq` to continue with. Without it you get the newest-first
561
+ * window, which is the "what happened lately" view and cannot be paged
562
+ * past its cap. */
563
+ listTurns(environmentId: string, worldInstanceId: string, opts?: {
564
+ limit?: number;
565
+ sinceSeq?: number;
566
+ }): Promise<WorldTimelinePage>;
567
+ /** Walk the timeline FORWARD from a revision already processed, following
568
+ * the cursor to the end. The resume primitive: a backend that stored the
569
+ * last `seq` it handled calls this once on restart and receives exactly the
570
+ * beats it missed, in order, with no gap at a page boundary and no beat
571
+ * twice.
572
+ *
573
+ * `maxPages` is a stop, not a tuning knob — an unbounded follow loop
574
+ * against a busy world never returns. */
575
+ listTurnsSince(environmentId: string, worldInstanceId: string, sinceSeq: number, opts?: {
576
+ pageSize?: number;
577
+ maxPages?: number;
578
+ }): Promise<WorldTimelinePage['beats']>;
455
579
  replayLedger(environmentId: string, worldInstanceId: string, options?: {
456
580
  cursor?: string;
457
581
  maxEntries?: number;
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.1.0';
20
+ export const WORLD_SDK_VERSION = '0.9.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
@@ -275,12 +275,63 @@ export class PouchyWorldClient {
275
275
  }
276
276
  /** Read back a COMMITTED turn. The recovery path when a response was lost:
277
277
  * it re-runs nothing, and a 404 means the turn never committed. */
278
+ /** Read a COMMITTED turn back — the recovery path when a response was lost.
279
+ *
280
+ * As of world API 1.6 this carries the same fields the live result did,
281
+ * `nextOptions` included, so a resumed session can offer the audience the
282
+ * same choices it would have. Rows committed before that carry none of the
283
+ * four turn-time facts: absent means UNKNOWN, never "none". */
278
284
  getTurn(environmentId, worldInstanceId, turnId) {
279
285
  return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns/${encodeURIComponent(turnId)}`);
280
286
  }
281
287
  /** Verify that the materialized state is what the committed ledger says.
282
288
  * Always a dry run — it reports, it never repairs. Pass the previous
283
289
  * report's `cursor` to continue a run that came back `incomplete`. */
290
+ /** ONE page of the committed timeline.
291
+ *
292
+ * Pass `sinceSeq` to resume: you get the beats after it, oldest first, and
293
+ * a `nextSinceSeq` to continue with. Without it you get the newest-first
294
+ * window, which is the "what happened lately" view and cannot be paged
295
+ * past its cap. */
296
+ listTurns(environmentId, worldInstanceId, opts) {
297
+ const q = new URLSearchParams();
298
+ if (opts?.limit !== undefined)
299
+ q.set('limit', String(opts.limit));
300
+ if (opts?.sinceSeq !== undefined)
301
+ q.set('sinceSeq', String(opts.sinceSeq));
302
+ const suffix = q.toString() ? `?${q}` : '';
303
+ return this.owner('GET', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/turns${suffix}`);
304
+ }
305
+ /** Walk the timeline FORWARD from a revision already processed, following
306
+ * the cursor to the end. The resume primitive: a backend that stored the
307
+ * last `seq` it handled calls this once on restart and receives exactly the
308
+ * beats it missed, in order, with no gap at a page boundary and no beat
309
+ * twice.
310
+ *
311
+ * `maxPages` is a stop, not a tuning knob — an unbounded follow loop
312
+ * against a busy world never returns. */
313
+ async listTurnsSince(environmentId, worldInstanceId, sinceSeq, opts) {
314
+ const pageSize = opts?.pageSize ?? 50;
315
+ const maxPages = opts?.maxPages ?? 20;
316
+ const out = [];
317
+ let cursor = sinceSeq;
318
+ for (let page = 0; page < maxPages; page++) {
319
+ const got = await this.listTurns(environmentId, worldInstanceId, {
320
+ limit: pageSize,
321
+ sinceSeq: cursor
322
+ });
323
+ if (!got.beats.length)
324
+ break;
325
+ out.push(...got.beats);
326
+ const next = got.nextSinceSeq;
327
+ // Defensive: a server that stopped advancing the cursor would other-
328
+ // wise spin here re-reading the same page until maxPages.
329
+ if (typeof next !== 'number' || next <= cursor)
330
+ break;
331
+ cursor = next;
332
+ }
333
+ return out;
334
+ }
284
335
  replayLedger(environmentId, worldInstanceId, options = {}) {
285
336
  return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/ledger/replay`, options);
286
337
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.9.0",
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",
7
7
  "homepage": "https://pouchy.ai/sdk",