@pouchy_ai/world-sdk 0.12.0 → 0.15.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 +63 -1
- package/README.md +62 -7
- package/conformance.mjs +5 -2
- package/dist/index.d.ts +188 -5
- package/dist/index.js +79 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# @pouchy_ai/world-sdk
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
- `getProgress()` — where a story is and whether it can go on, as a DERIVED
|
|
6
|
+
checkpoint over facts that already committed. Reads nothing into existence:
|
|
7
|
+
no new collection, no model call, no write.
|
|
8
|
+
- The return is a discriminated union. `status: 'unavailable'` carries **no
|
|
9
|
+
`checkpoint`**, so code that forgets to narrow fails loudly instead of reading
|
|
10
|
+
a plausible zero; `resumable` is typed `false` on that arm.
|
|
11
|
+
- `reason` is coarse on purpose. `world_unavailable` covers "no such instance",
|
|
12
|
+
"no access", "package revoked", "package gone" and "contract too new" as one
|
|
13
|
+
word — telling them apart would let a caller map which objects exist by
|
|
14
|
+
reading refusals. `story_reference_invalid` is distinct and leaks nothing: the
|
|
15
|
+
pinned story and its state disagree.
|
|
16
|
+
- The checkpoint pins its provenance — `environmentRevision`, and the story
|
|
17
|
+
package's exact `revision` + `contentHash`. It follows the INSTANCE's pin, so
|
|
18
|
+
publishing a new story revision never changes what a running world reports.
|
|
19
|
+
- **`declaredNodeCount` is not a denominator.** A branching story never visits
|
|
20
|
+
every declared node; there is no percentage field and `completedNodeCount /
|
|
21
|
+
declaredNodeCount` is not a completion ratio. Show two numbers.
|
|
22
|
+
- **`recentProgressRecords` is not a summary.** `{ seq, kind, at }` says a beat
|
|
23
|
+
happened, never what happened in it. Read `/turns` for the lines.
|
|
24
|
+
- `nextOptions` is the same shape and the same derivation the live turn uses —
|
|
25
|
+
never model-generated, capped server-side.
|
|
26
|
+
- Two reads of an unchanged world are byte-identical: no read-time clock, every
|
|
27
|
+
list explicitly ordered, state and ledger taken in one read-only transaction.
|
|
28
|
+
|
|
29
|
+
## 0.14.0
|
|
30
|
+
|
|
31
|
+
- `deliberate()` and `selectCandidate()` — ask for a few PUBLIC directions a
|
|
32
|
+
beat could take, then commit the one the player chose.
|
|
33
|
+
- Candidates are safe to render straight to a player: `candidateId`, `title`,
|
|
34
|
+
`direction`, `shortTeaser`, `participatingRoles` and nothing else. No
|
|
35
|
+
reasoning, no role secrets, no simulated effects, no scores.
|
|
36
|
+
- `selectCandidate` commits through the ordinary coordinator — same beat, same
|
|
37
|
+
ledger, same `WorldTurnResult`. A deliberation has no privileges over the
|
|
38
|
+
world: its simulated effects are never carried into the real turn.
|
|
39
|
+
- The `envelope` is opaque and belongs on your SERVER. Hand it back unchanged;
|
|
40
|
+
do not send it to a browser. The turn id is fixed inside it, so re-sending the
|
|
41
|
+
same envelope retries rather than committing a second beat.
|
|
42
|
+
- If the world moved since the candidates were produced, `selectCandidate`
|
|
43
|
+
answers 409 `stale_deliberation` having spent nothing — deliberate again.
|
|
44
|
+
- Off unless the world's published revision declares `deliberation`. A world
|
|
45
|
+
without it answers 409 on `deliberate()`, which simply means that story plays
|
|
46
|
+
directly.
|
|
47
|
+
|
|
48
|
+
## 0.13.0
|
|
49
|
+
|
|
50
|
+
- `createWorldSession` now returns a typed `WorldSessionResponse` instead of
|
|
51
|
+
`Record<string, unknown>`. The wire fields are unchanged — this names what the
|
|
52
|
+
server has always sent.
|
|
53
|
+
- **The field is `session_token`, not `token`.** The untyped return let that
|
|
54
|
+
mistake compile: `conformance.mjs` asserted `session.token`, which the server
|
|
55
|
+
has never sent, so the session check threw against any real server while every
|
|
56
|
+
local gate stayed green. Typing the response is what stops it recurring; the
|
|
57
|
+
runner and both quickstarts were corrected to match.
|
|
58
|
+
- `release-check.mjs` now compares `WORLD_SDK_VERSION` against the version of
|
|
59
|
+
the package it actually INSTALLED. The constant sat at `0.1.0` for eight
|
|
60
|
+
releases while the package shipped 0.8.0, so every request reported the wrong
|
|
61
|
+
client in its `user-agent`; the drift was corrected in 0.12.0, and this is the
|
|
62
|
+
gate that stops it recurring.
|
|
63
|
+
- No method changed shape, and no request or response bytes changed.
|
|
64
|
+
|
|
3
65
|
## 0.12.0
|
|
4
66
|
|
|
5
67
|
Additive: authoring joins the machine lane, so nothing in an integration needs
|
|
@@ -54,7 +116,7 @@ Additive: your backend can tell the world what it already knows. World API 1.8.
|
|
|
54
116
|
|
|
55
117
|
Additive: an unattended backend can now read its own world. World API 1.7.
|
|
56
118
|
|
|
57
|
-
- New `adminKey` client option — a project admin key (`
|
|
119
|
+
- New `adminKey` client option — a project admin key (`pchy_admin_…`), minted once from
|
|
58
120
|
the dashboard, long-lived and machine-held. When present, the world READS
|
|
59
121
|
(`getWorldState`, `listTurns`, `listTurnsSince`, `getTurn`, `getWorldMetrics`,
|
|
60
122
|
`listDeliveries`) go to a new `/v1/admin/environments/**` mirror instead of the
|
package/README.md
CHANGED
|
@@ -47,8 +47,12 @@ const world = new PouchyWorldClient({
|
|
|
47
47
|
|
|
48
48
|
**Running** — `createWorldSession`, `runTurn`, `sendEvent`.
|
|
49
49
|
|
|
50
|
-
**
|
|
51
|
-
|
|
50
|
+
**Deliberating** — `deliberate`, `selectCandidate`. Ask for a couple of public
|
|
51
|
+
directions a beat could take, then commit the one the player picked. Off unless
|
|
52
|
+
the world's published revision declares it; the `envelope` is server-side only.
|
|
53
|
+
|
|
54
|
+
**Reading** — `getWorldState`, `getProgress`, `getTurn`, `listTurns`,
|
|
55
|
+
`listTurnsSince`, `replayLedger`, `replayLedgerToEnd`.
|
|
52
56
|
|
|
53
57
|
**Which credential does what.** This matters more than it looks: one of them
|
|
54
58
|
expires within the hour.
|
|
@@ -56,7 +60,7 @@ expires within the hour.
|
|
|
56
60
|
| you hold | you can | you cannot |
|
|
57
61
|
|---|---|---|
|
|
58
62
|
| `secretKey` + `signing` | mint sessions, drive turns, send events | read anything back |
|
|
59
|
-
| `adminKey` (`
|
|
63
|
+
| `adminKey` (`pchy_admin_…`, long-lived) | author story packages and worlds; read the world: overview, state, timeline, turn read-back, metrics, delivery queue, cost | drive a turn, act on the delivery queue, run the content loop |
|
|
60
64
|
| `adminToken` (Firebase ID token, ~1h) | everything above plus the content loop | outlive the hour |
|
|
61
65
|
|
|
62
66
|
A server holds `secretKey` + `signing` + `adminKey` and needs **no browser login
|
|
@@ -109,6 +113,27 @@ Both are additive: a turn committed before world API 1.6 carries none of the
|
|
|
109
113
|
four turn-time facts (`selectedRoles`, `skippedRoles`, `repairs`,
|
|
110
114
|
`nextOptions`). Absent means UNKNOWN, never "none".
|
|
111
115
|
|
|
116
|
+
**Reopening a story.** `getProgress` answers the question a returning reader's
|
|
117
|
+
client has — where was I, and can I go on? — without replaying anything.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const p = await world.getProgress(envId, instanceId);
|
|
121
|
+
if (p.status !== 'ready') return renderStartFresh(); // narrow FIRST
|
|
122
|
+
renderScene(p.checkpoint.currentScene); // may be null
|
|
123
|
+
renderCounts(p.checkpoint.completedNodeCount, p.checkpoint.declaredNodeCount);
|
|
124
|
+
renderDirections(p.checkpoint.nextOptions);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Narrowing on `status` is not politeness: the `unavailable` arm has no
|
|
128
|
+
`checkpoint` key, so skipping the check reads `undefined` rather than telling a
|
|
129
|
+
reader they never started a story they are halfway through.
|
|
130
|
+
|
|
131
|
+
Two shapes it does not have. **`declaredNodeCount` is not a denominator** — a
|
|
132
|
+
branching story never visits every node the author declared, so show the two
|
|
133
|
+
counts, never a percentage. And **`recentProgressRecords` is not a summary**:
|
|
134
|
+
`{ seq, kind, at }` says a beat happened, not what happened in it; the lines
|
|
135
|
+
live behind `getTurn` / `listTurns`.
|
|
136
|
+
|
|
112
137
|
**Content return** — `createScriptDraft`, `getScriptDraft`, `listScriptDrafts`,
|
|
113
138
|
`reviewScriptDraft`, `exportScriptDraft`.
|
|
114
139
|
|
|
@@ -157,16 +182,46 @@ an `approved` editorial can be exported.
|
|
|
157
182
|
`newTurnId` / `isReservedTurnId` (idempotency), `describeTurn` (read a result
|
|
158
183
|
without guessing), `WorldApiError` with typed codes and `.retryable`.
|
|
159
184
|
|
|
185
|
+
## When a signed door refuses you
|
|
186
|
+
|
|
187
|
+
All three signed doors answer one uniform 403 on a bad signature — they will
|
|
188
|
+
never tell you which of the four things is wrong, because an endpoint that
|
|
189
|
+
names the failing credential is an oracle. The reason lives in the project's own
|
|
190
|
+
audit trail instead:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
GET https://pouchy.ai/v1/projects/{projectId}/environments/{envId}/preflight
|
|
194
|
+
Authorization: Bearer <OwnerToken>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**That is an OwnerToken — a signed-in project admin's Firebase ID token, not the
|
|
198
|
+
Secret Key this client holds**, and not the Admin key either (preflight is
|
|
199
|
+
deliberately absent from the `/admin` mirror). There is no SDK method for it for
|
|
200
|
+
the same reason: it answers a question a developer asks once while looking at
|
|
201
|
+
the dashboard, not one a backend asks in a loop.
|
|
202
|
+
|
|
203
|
+
It returns the recent refusals with a closed reason vocabulary — `missing`,
|
|
204
|
+
`malformed`, `unknown_key`, `stale`, `bad_signature`, `no_keys` — plus how many
|
|
205
|
+
audit rows it scanned, because an empty feed is not a clean bill of health.
|
|
206
|
+
|
|
207
|
+
`docs/world-sdk-errors.md` in the Pouchy repo maps every reason and every
|
|
208
|
+
`WorldApiError` code to what to change.
|
|
209
|
+
|
|
160
210
|
## The three things integrators get wrong
|
|
161
211
|
|
|
162
212
|
**1. Turn ids are the idempotency key.** Mint one per BEAT and re-send the same
|
|
163
213
|
one to retry. A new id is a new beat: it will run the models again and commit
|
|
164
214
|
again. `newTurnId()` exists so this is a deliberate choice rather than a habit.
|
|
165
215
|
|
|
166
|
-
**2. Sign the bytes you send.** `signSourceRequest`
|
|
167
|
-
string. Serializing twice — once to sign, once to send —
|
|
168
|
-
not send, and the server will (correctly) refuse them. This
|
|
169
|
-
the string it is about to write.
|
|
216
|
+
**2. Sign the bytes you send, with the door's own id slot.** `signSourceRequest`
|
|
217
|
+
hashes the exact body string. Serializing twice — once to sign, once to send —
|
|
218
|
+
signs bytes you did not send, and the server will (correctly) refuse them. This
|
|
219
|
+
client always signs the string it is about to write. The fourth canonical line
|
|
220
|
+
is the ID SLOT, and it differs per door: `turnId` on the turns door, `eventId`
|
|
221
|
+
on `/events`, and the body's own `world.request_id` on `/sessions` — a session
|
|
222
|
+
mint has neither a turn nor an event. `createWorldSession` passes it for you; if you
|
|
223
|
+
are signing by hand there, an invented or empty id verifies locally and comes
|
|
224
|
+
back `bad_signature`, which reads exactly like a wrong secret.
|
|
170
225
|
|
|
171
226
|
**3. Execution and delivery are different questions.** `executionStatus` says
|
|
172
227
|
whether the world moved; `deliveryStatus` says whether the audience has heard
|
package/conformance.mjs
CHANGED
|
@@ -184,9 +184,12 @@ await check('session mint binds one user to one role (machine lane)', async () =
|
|
|
184
184
|
role: scenario.leadRole,
|
|
185
185
|
externalUserId: `conformance-${scenarioName}-${stamp}`
|
|
186
186
|
});
|
|
187
|
-
|
|
187
|
+
// The wire names, exactly as the server sends them (`WorldSessionResponse`).
|
|
188
|
+
// This check used to read `session.token`, which the server has never sent —
|
|
189
|
+
// so it threw against every real server while every local gate stayed green.
|
|
190
|
+
instanceId = session?.world?.instance;
|
|
188
191
|
if (!instanceId) throw new Error('no world instance in the session payload');
|
|
189
|
-
if (!session
|
|
192
|
+
if (!session?.session_token) throw new Error('no session token minted (expected session_token)');
|
|
190
193
|
return instanceId;
|
|
191
194
|
});
|
|
192
195
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
/** Kept in step with `package.json` by `release-check.mjs`, which compares this
|
|
2
|
+
* constant against the version of the package it actually INSTALLED. It rides
|
|
3
|
+
* the `user-agent` of every request, so a drift here misreports which client
|
|
4
|
+
* a project is running — which is exactly the field you reach for when a
|
|
5
|
+
* customer's integration behaves like an older SDK than they say they have.
|
|
6
|
+
* It sat at '0.1.0' for eight releases before anything compared the two. */
|
|
7
|
+
export declare const WORLD_SDK_VERSION = "0.15.0";
|
|
2
8
|
export declare const DEFAULT_BASE_URL = "https://pouchy.ai/v1";
|
|
9
|
+
/** One direction a beat could take. The WHOLE of what a deliberation shows a
|
|
10
|
+
* player: no reasoning, no role secrets, no simulated effects, no scores. */
|
|
11
|
+
export interface WorldDeliberationCandidate {
|
|
12
|
+
candidateId: string;
|
|
13
|
+
title: string;
|
|
14
|
+
/** What the scene will be steered toward. Public, because the player is
|
|
15
|
+
* choosing it and must be able to read what they are choosing. */
|
|
16
|
+
direction: string;
|
|
17
|
+
shortTeaser: string;
|
|
18
|
+
participatingRoles: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface WorldDeliberationResponse {
|
|
21
|
+
candidates: WorldDeliberationCandidate[];
|
|
22
|
+
/** Signed evidence. Opaque — hand it back to `selectCandidate` unchanged. */
|
|
23
|
+
envelope: string;
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
budgetSpent: {
|
|
26
|
+
modelCalls: number;
|
|
27
|
+
tokens: number;
|
|
28
|
+
ms: number;
|
|
29
|
+
};
|
|
30
|
+
/** Why it stopped. A run that hit a ceiling says so rather than letting a
|
|
31
|
+
* short list pass for a complete one. */
|
|
32
|
+
stop: 'complete' | 'candidate_cap' | 'model_call_cap' | 'token_cap' | 'time_cap' | 'no_roles';
|
|
33
|
+
}
|
|
3
34
|
/** The refusal classes a world call can produce. `unknown` is deliberate: an
|
|
4
35
|
* unrecognized status is never quietly folded into a neighbour. */
|
|
5
36
|
export declare const WORLD_ERROR_CODES: readonly ["unauthorized", "forbidden", "not_found", "conflict", "unprocessable", "rate_limited", "payload_too_large", "server_error", "network", "unknown"];
|
|
@@ -26,7 +57,7 @@ export declare const SOURCE_SIGNATURE_HEADER = "X-Pouchy-Source-Signature";
|
|
|
26
57
|
/** Build the `X-Pouchy-Source-Signature` header for one request body.
|
|
27
58
|
*
|
|
28
59
|
* The canonical string is five newline-joined lines — scheme, unix seconds,
|
|
29
|
-
* the declared source, the
|
|
60
|
+
* the declared source, the door's id slot (see `id`), and the sha256 of the EXACT body
|
|
30
61
|
* bytes. Sign at SEND time, every attempt: a legitimate retry of the same id
|
|
31
62
|
* days later carries a fresh timestamp and passes the ±5 minute skew, because
|
|
32
63
|
* the signature proves origin and never doubles as a dedupe key.
|
|
@@ -36,7 +67,11 @@ export declare const SOURCE_SIGNATURE_HEADER = "X-Pouchy-Source-Signature";
|
|
|
36
67
|
* send. */
|
|
37
68
|
export declare function signSourceRequest(input: {
|
|
38
69
|
source: string;
|
|
39
|
-
/**
|
|
70
|
+
/** The id slot, which is per-door: `turnId` for the turns door, `eventId`
|
|
71
|
+
* for `/events`, and the request's own `world.request_id` for `/sessions`
|
|
72
|
+
* (`createWorldSession` passes it for you). A mint has neither a turn nor an
|
|
73
|
+
* event, so signing an empty or invented id there is the classic first
|
|
74
|
+
* failure — it verifies locally and is refused as `bad_signature`. */
|
|
40
75
|
id: string;
|
|
41
76
|
body: string;
|
|
42
77
|
keyId: string;
|
|
@@ -121,6 +156,68 @@ export interface WorldTurnResult {
|
|
|
121
156
|
*
|
|
122
157
|
* The four turn-time facts are optional because rows committed before world
|
|
123
158
|
* API 1.6 do not carry them. Absent means UNKNOWN. */
|
|
159
|
+
/** Where a story is, and whether it can go on. World API 1.18.
|
|
160
|
+
*
|
|
161
|
+
* A DISCRIMINATED UNION, not an optional-field bag. The `unavailable` arm has
|
|
162
|
+
* no `checkpoint` key at all, so narrowing on `status` is not a style choice:
|
|
163
|
+
* it is the only way to reach the data, and code that skips it fails loudly
|
|
164
|
+
* instead of reading a plausible zero. `resumable` is typed `false` on that
|
|
165
|
+
* arm, so "unavailable but resumable" cannot be constructed. */
|
|
166
|
+
export type WorldProgressResponse = {
|
|
167
|
+
status: 'ready';
|
|
168
|
+
resumable: boolean;
|
|
169
|
+
checkpoint: WorldProgressCheckpointV1;
|
|
170
|
+
} | {
|
|
171
|
+
status: 'unavailable';
|
|
172
|
+
resumable: false;
|
|
173
|
+
reason: WorldProgressUnavailableReason;
|
|
174
|
+
};
|
|
175
|
+
/** `world_unavailable` is deliberately coarse — no such instance, no access, a
|
|
176
|
+
* revoked or deleted package, a contract too new to read. Distinguishing them
|
|
177
|
+
* would let a caller map which internal objects exist by reading refusals.
|
|
178
|
+
* `story_reference_invalid` is different in kind: the pinned story and the
|
|
179
|
+
* state it is pinned to disagree, which reveals nothing about existence. */
|
|
180
|
+
export type WorldProgressUnavailableReason = 'world_unavailable' | 'story_reference_invalid';
|
|
181
|
+
export interface WorldProgressCheckpointV1 {
|
|
182
|
+
contractVersion: 1;
|
|
183
|
+
/** The revision this INSTANCE is pinned to for life. */
|
|
184
|
+
environmentRevision: number;
|
|
185
|
+
storyPackageRef: {
|
|
186
|
+
packageId: string;
|
|
187
|
+
revision: number;
|
|
188
|
+
contentHash: string;
|
|
189
|
+
};
|
|
190
|
+
stateRevision: number;
|
|
191
|
+
started: boolean;
|
|
192
|
+
/** Null when no scene has been opened. Never a substitute for one the
|
|
193
|
+
* pinned story does not declare — that answers `story_reference_invalid`. */
|
|
194
|
+
currentScene: {
|
|
195
|
+
sceneId: string;
|
|
196
|
+
title: string;
|
|
197
|
+
} | null;
|
|
198
|
+
completedNodeCount: number;
|
|
199
|
+
/** What the AUTHOR declared. NOT a denominator: a branching story never
|
|
200
|
+
* visits every node, so `completedNodeCount / declaredNodeCount` is not a
|
|
201
|
+
* completion ratio. Render them as two numbers. */
|
|
202
|
+
declaredNodeCount: number;
|
|
203
|
+
publicRelations: Array<{
|
|
204
|
+
between: [string, string];
|
|
205
|
+
descriptor: string;
|
|
206
|
+
}>;
|
|
207
|
+
publicRelationsTruncated: boolean;
|
|
208
|
+
/** Newest first. That a beat happened — never what happened in it. */
|
|
209
|
+
recentProgressRecords: Array<{
|
|
210
|
+
seq: number;
|
|
211
|
+
kind: 'turn' | 'event' | 'system';
|
|
212
|
+
at: number;
|
|
213
|
+
}>;
|
|
214
|
+
recentProgressTruncated: boolean;
|
|
215
|
+
nextOptions: Array<{
|
|
216
|
+
branchId: string;
|
|
217
|
+
condition: string;
|
|
218
|
+
}>;
|
|
219
|
+
rebuildable: true;
|
|
220
|
+
}
|
|
124
221
|
export interface WorldTurnReadback {
|
|
125
222
|
turnId: string;
|
|
126
223
|
worldInstanceId: string;
|
|
@@ -470,6 +567,43 @@ export interface ApprovedScriptExportRow {
|
|
|
470
567
|
deliveryAttempted?: number;
|
|
471
568
|
deliveryOk?: number;
|
|
472
569
|
}
|
|
570
|
+
/** What `POST /v1/sessions` actually returns for a WORLD mint.
|
|
571
|
+
*
|
|
572
|
+
* Typed because it was untyped: `createWorldSession` used to answer
|
|
573
|
+
* `Record<string, unknown>`, so a caller reading `session.token` — a field the
|
|
574
|
+
* server has never sent — compiled, shipped, and failed only against a live
|
|
575
|
+
* server. The wire is snake_case and stays that way here; renaming it in the
|
|
576
|
+
* client would put a second vocabulary between an integrator and the HTTP
|
|
577
|
+
* responses they read in their own logs.
|
|
578
|
+
*
|
|
579
|
+
* `world` is present because `createWorldSession` always sends a world block;
|
|
580
|
+
* an ordinary (non-world) mint omits it, and that lane is not this method's. */
|
|
581
|
+
export interface WorldSessionResponse {
|
|
582
|
+
/** The end-user session token. Hand it to YOUR frontend, which drives it with
|
|
583
|
+
* `@pouchy_ai/companion-sdk`. Scoped to one instance and one role, and
|
|
584
|
+
* carries no project credential. NOT named `token`. */
|
|
585
|
+
readonly session_token: string;
|
|
586
|
+
/** Seconds. The server clamps a requested ttl into [300, 86400]; this is the
|
|
587
|
+
* clamped value, not what you asked for. */
|
|
588
|
+
readonly expires_in: number;
|
|
589
|
+
/** The agent the ROLE resolved to. The world decides it — passing an agent to
|
|
590
|
+
* a world mint is refused. */
|
|
591
|
+
readonly agent: string;
|
|
592
|
+
readonly instance: {
|
|
593
|
+
readonly id: string;
|
|
594
|
+
readonly external_user_id: string;
|
|
595
|
+
/** True the first time this end user was provisioned. */
|
|
596
|
+
readonly created: boolean;
|
|
597
|
+
};
|
|
598
|
+
readonly world: {
|
|
599
|
+
readonly environment: string;
|
|
600
|
+
/** The revision this instance is PINNED to — for life. */
|
|
601
|
+
readonly environment_revision: number;
|
|
602
|
+
/** The world instance id to drive turns against. */
|
|
603
|
+
readonly instance: string;
|
|
604
|
+
readonly role: string;
|
|
605
|
+
};
|
|
606
|
+
}
|
|
473
607
|
export interface WorldClientOptions {
|
|
474
608
|
/** The project this client acts for. */
|
|
475
609
|
projectId: string;
|
|
@@ -480,7 +614,7 @@ export interface WorldClientOptions {
|
|
|
480
614
|
* browser sign-in. Fine for a script a person is watching; wrong for a
|
|
481
615
|
* server. For a server, use `adminKey`. */
|
|
482
616
|
adminToken?: string;
|
|
483
|
-
/** A project ADMIN key (`
|
|
617
|
+
/** A project ADMIN key (`pchy_admin_…`), minted once from the dashboard.
|
|
484
618
|
*
|
|
485
619
|
* Long-lived and machine-held: this is what an unattended backend uses.
|
|
486
620
|
* When present, the world READS (overview, state, timeline, turn read-back,
|
|
@@ -558,6 +692,26 @@ export declare class PouchyWorldClient {
|
|
|
558
692
|
environmentRevision: number;
|
|
559
693
|
state: Record<string, unknown>;
|
|
560
694
|
}>;
|
|
695
|
+
/** Where the story is, and whether it can go on. World API 1.18.
|
|
696
|
+
*
|
|
697
|
+
* A DERIVED checkpoint: nothing is stored to answer it, no model is asked,
|
|
698
|
+
* and the story it reports is the package this INSTANCE pinned, at its
|
|
699
|
+
* exact revision and content hash — never whatever was published since.
|
|
700
|
+
*
|
|
701
|
+
* Check `status` before reading anything else. `unavailable` has no
|
|
702
|
+
* `checkpoint` at all, which is deliberate: a caller that skips the check
|
|
703
|
+
* gets `undefined` rather than a plausible zero. Its `reason` is
|
|
704
|
+
* intentionally coarse — one word covers "no such instance", "no access",
|
|
705
|
+
* "package revoked", "package gone" and "contract too new", because telling
|
|
706
|
+
* them apart would let a caller map which objects exist by reading errors.
|
|
707
|
+
*
|
|
708
|
+
* Two things it does NOT give you. There is no completion percentage:
|
|
709
|
+
* `declaredNodeCount` is what the AUTHOR wrote, and a branching story never
|
|
710
|
+
* visits all of it, so a ratio of the two counts is meaningless — show them
|
|
711
|
+
* as two numbers. And `recentProgressRecords` says a beat happened, not
|
|
712
|
+
* what happened in it; for the lines themselves, read the timeline.
|
|
713
|
+
*/
|
|
714
|
+
getProgress(environmentId: string, worldInstanceId: string): Promise<WorldProgressResponse>;
|
|
561
715
|
/** Read back a COMMITTED turn. The recovery path when a response was lost:
|
|
562
716
|
* it re-runs nothing, and a 404 means the turn never committed. */
|
|
563
717
|
/** Read a COMMITTED turn back — the recovery path when a response was lost.
|
|
@@ -740,7 +894,7 @@ export declare class PouchyWorldClient {
|
|
|
740
894
|
externalUserId: string;
|
|
741
895
|
worldInstance?: string;
|
|
742
896
|
requestId?: string;
|
|
743
|
-
}): Promise<
|
|
897
|
+
}): Promise<WorldSessionResponse>;
|
|
744
898
|
/** Drive ONE coordinated beat. `turnId` is the idempotency key: re-send the
|
|
745
899
|
* same one to retry, mint a new one for a new beat. */
|
|
746
900
|
runTurn(input: {
|
|
@@ -764,6 +918,35 @@ export declare class PouchyWorldClient {
|
|
|
764
918
|
* role's private notes. */
|
|
765
919
|
proposedPatches?: readonly Record<string, unknown>[];
|
|
766
920
|
}): Promise<WorldTurnResult>;
|
|
921
|
+
/** Ask for a few PUBLIC directions this beat could take, without taking it.
|
|
922
|
+
*
|
|
923
|
+
* Off unless the world's published revision declares `deliberation`. Nothing
|
|
924
|
+
* is written and no candidate is stored: what comes back is a short list
|
|
925
|
+
* plus a signed `envelope` you hand to `selectCandidate`. The candidates are
|
|
926
|
+
* public by construction — no reasoning, no role secrets, no simulated
|
|
927
|
+
* effects — so they are safe to render straight to a player. */
|
|
928
|
+
deliberate(input: {
|
|
929
|
+
environmentId: string;
|
|
930
|
+
worldInstanceId: string;
|
|
931
|
+
requestId?: string;
|
|
932
|
+
text: string;
|
|
933
|
+
model?: string;
|
|
934
|
+
}): Promise<WorldDeliberationResponse>;
|
|
935
|
+
/** Commit the direction the player chose. This IS an ordinary beat — the
|
|
936
|
+
* same coordinator, the same ledger, the same result shape.
|
|
937
|
+
*
|
|
938
|
+
* Re-send the same `envelope` to retry: the turn id is fixed inside it, so a
|
|
939
|
+
* second call is a duplicate rather than a second beat. If the world moved
|
|
940
|
+
* since the candidates were produced, this answers 409 `stale_deliberation`
|
|
941
|
+
* and spends nothing — deliberate again. */
|
|
942
|
+
selectCandidate(input: {
|
|
943
|
+
environmentId: string;
|
|
944
|
+
worldInstanceId: string;
|
|
945
|
+
envelope: string;
|
|
946
|
+
candidateId: string;
|
|
947
|
+
direction: string;
|
|
948
|
+
text: string;
|
|
949
|
+
}): Promise<WorldTurnResult>;
|
|
767
950
|
/** Send a trusted EVENT into a world. On a `coordinated` world this becomes
|
|
768
951
|
* one coordinator turn; on an `actor` world it wakes each subscribed role.
|
|
769
952
|
* Either way `eventId` is the dedupe key — re-send it freely. */
|
package/dist/index.js
CHANGED
|
@@ -17,9 +17,30 @@
|
|
|
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
|
-
|
|
20
|
+
/** Kept in step with `package.json` by `release-check.mjs`, which compares this
|
|
21
|
+
* constant against the version of the package it actually INSTALLED. It rides
|
|
22
|
+
* the `user-agent` of every request, so a drift here misreports which client
|
|
23
|
+
* a project is running — which is exactly the field you reach for when a
|
|
24
|
+
* customer's integration behaves like an older SDK than they say they have.
|
|
25
|
+
* It sat at '0.1.0' for eight releases before anything compared the two. */
|
|
26
|
+
export const WORLD_SDK_VERSION = '0.15.0';
|
|
21
27
|
export const DEFAULT_BASE_URL = 'https://pouchy.ai/v1';
|
|
22
|
-
|
|
28
|
+
/** Read the commit turn id out of an envelope, so the signature covers the id
|
|
29
|
+
* the server will commit under. The payload half is base64url JSON; the
|
|
30
|
+
* signature is not needed to read it, and reading it proves nothing — the
|
|
31
|
+
* SERVER verifies. This is only so the client signs the right slot. */
|
|
32
|
+
function commitTurnIdOf(envelope) {
|
|
33
|
+
const payload = envelope.slice(0, envelope.lastIndexOf('.'));
|
|
34
|
+
try {
|
|
35
|
+
const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
36
|
+
if (typeof json.commitTurnId === 'string' && json.commitTurnId)
|
|
37
|
+
return json.commitTurnId;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* fall through */
|
|
41
|
+
}
|
|
42
|
+
throw new Error('deliberation envelope is malformed — re-run deliberate()');
|
|
43
|
+
}
|
|
23
44
|
/** The refusal classes a world call can produce. `unknown` is deliberate: an
|
|
24
45
|
* unrecognized status is never quietly folded into a neighbour. */
|
|
25
46
|
export const WORLD_ERROR_CODES = [
|
|
@@ -111,7 +132,7 @@ const SOURCE_SIGNATURE_SCHEME = 'POUCHY-SOURCE-V1';
|
|
|
111
132
|
/** Build the `X-Pouchy-Source-Signature` header for one request body.
|
|
112
133
|
*
|
|
113
134
|
* The canonical string is five newline-joined lines — scheme, unix seconds,
|
|
114
|
-
* the declared source, the
|
|
135
|
+
* the declared source, the door's id slot (see `id`), and the sha256 of the EXACT body
|
|
115
136
|
* bytes. Sign at SEND time, every attempt: a legitimate retry of the same id
|
|
116
137
|
* days later carries a fresh timestamp and passes the ±5 minute skew, because
|
|
117
138
|
* the signature proves origin and never doubles as a dedupe key.
|
|
@@ -276,6 +297,28 @@ export class PouchyWorldClient {
|
|
|
276
297
|
getWorldState(environmentId, worldInstanceId) {
|
|
277
298
|
return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/state`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/state`);
|
|
278
299
|
}
|
|
300
|
+
/** Where the story is, and whether it can go on. World API 1.18.
|
|
301
|
+
*
|
|
302
|
+
* A DERIVED checkpoint: nothing is stored to answer it, no model is asked,
|
|
303
|
+
* and the story it reports is the package this INSTANCE pinned, at its
|
|
304
|
+
* exact revision and content hash — never whatever was published since.
|
|
305
|
+
*
|
|
306
|
+
* Check `status` before reading anything else. `unavailable` has no
|
|
307
|
+
* `checkpoint` at all, which is deliberate: a caller that skips the check
|
|
308
|
+
* gets `undefined` rather than a plausible zero. Its `reason` is
|
|
309
|
+
* intentionally coarse — one word covers "no such instance", "no access",
|
|
310
|
+
* "package revoked", "package gone" and "contract too new", because telling
|
|
311
|
+
* them apart would let a caller map which objects exist by reading errors.
|
|
312
|
+
*
|
|
313
|
+
* Two things it does NOT give you. There is no completion percentage:
|
|
314
|
+
* `declaredNodeCount` is what the AUTHOR wrote, and a branching story never
|
|
315
|
+
* visits all of it, so a ratio of the two counts is meaningless — show them
|
|
316
|
+
* as two numbers. And `recentProgressRecords` says a beat happened, not
|
|
317
|
+
* what happened in it; for the lines themselves, read the timeline.
|
|
318
|
+
*/
|
|
319
|
+
getProgress(environmentId, worldInstanceId) {
|
|
320
|
+
return this.read(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/progress`, `/admin/environments/${environmentId}/instances/${worldInstanceId}/progress`);
|
|
321
|
+
}
|
|
279
322
|
/** Read back a COMMITTED turn. The recovery path when a response was lost:
|
|
280
323
|
* it re-runs nothing, and a 404 means the turn never committed. */
|
|
281
324
|
/** Read a COMMITTED turn back — the recovery path when a response was lost.
|
|
@@ -515,6 +558,39 @@ export class PouchyWorldClient {
|
|
|
515
558
|
};
|
|
516
559
|
return this.signed(`/projects/${this.projectId}/environments/${input.environmentId}/instances/${input.worldInstanceId}/turns`, body, turnId);
|
|
517
560
|
}
|
|
561
|
+
/** Ask for a few PUBLIC directions this beat could take, without taking it.
|
|
562
|
+
*
|
|
563
|
+
* Off unless the world's published revision declares `deliberation`. Nothing
|
|
564
|
+
* is written and no candidate is stored: what comes back is a short list
|
|
565
|
+
* plus a signed `envelope` you hand to `selectCandidate`. The candidates are
|
|
566
|
+
* public by construction — no reasoning, no role secrets, no simulated
|
|
567
|
+
* effects — so they are safe to render straight to a player. */
|
|
568
|
+
deliberate(input) {
|
|
569
|
+
const requestId = input.requestId ?? newTurnId('dlb-req');
|
|
570
|
+
const body = { requestId, text: input.text, ...(input.model ? { model: input.model } : {}) };
|
|
571
|
+
return this.signed(`/projects/${this.projectId}/environments/${input.environmentId}/instances/${input.worldInstanceId}/deliberations`, body,
|
|
572
|
+
// The signature's id slot is namespaced, so a deliberation signature
|
|
573
|
+
// can never be replayed onto the turn door.
|
|
574
|
+
`dlb:${requestId}`);
|
|
575
|
+
}
|
|
576
|
+
/** Commit the direction the player chose. This IS an ordinary beat — the
|
|
577
|
+
* same coordinator, the same ledger, the same result shape.
|
|
578
|
+
*
|
|
579
|
+
* Re-send the same `envelope` to retry: the turn id is fixed inside it, so a
|
|
580
|
+
* second call is a duplicate rather than a second beat. If the world moved
|
|
581
|
+
* since the candidates were produced, this answers 409 `stale_deliberation`
|
|
582
|
+
* and spends nothing — deliberate again. */
|
|
583
|
+
selectCandidate(input) {
|
|
584
|
+
const body = {
|
|
585
|
+
envelope: input.envelope,
|
|
586
|
+
candidateId: input.candidateId,
|
|
587
|
+
direction: input.direction,
|
|
588
|
+
text: input.text
|
|
589
|
+
};
|
|
590
|
+
// The id slot is the turn id the envelope already fixed, so the signature
|
|
591
|
+
// covers the id the server will actually commit under.
|
|
592
|
+
return this.signed(`/projects/${this.projectId}/environments/${input.environmentId}/instances/${input.worldInstanceId}/deliberations/select`, body, commitTurnIdOf(input.envelope));
|
|
593
|
+
}
|
|
518
594
|
/** Send a trusted EVENT into a world. On a `coordinated` world this becomes
|
|
519
595
|
* one coordinator turn; on an `actor` world it wakes each subscribed role.
|
|
520
596
|
* Either way `eventId` is the dedupe key — re-send it freely. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/world-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|