@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/CHANGELOG.md +142 -0
- package/LICENSE +41 -0
- package/README.md +141 -0
- package/conformance.mjs +279 -0
- package/dist/index.d.ts +631 -0
- package/dist/index.js +560 -0
- package/package.json +54 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @pouchy_ai/world-sdk
|
|
2
|
+
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- `WorldDeliveryOpsRow` gained `cleanupRetryCount` and `cleanupStalledAt`.
|
|
6
|
+
- These surface the Batch 7.2 atomic receipt cleanup: when a settled row and
|
|
7
|
+
its two delivery receipts are purged, all three go in one transaction, so a
|
|
8
|
+
refused delete now leaves the row intact with a retry scheduled instead of
|
|
9
|
+
orphaning a receipt whose routing was just deleted. `cleanupRetryCount` is how
|
|
10
|
+
many times that cleanup has failed; `cleanupStalledAt` is stamped once it
|
|
11
|
+
crosses the retry threshold and becomes an operator signal. Both are absent on
|
|
12
|
+
an ordinary row, and neither is a delivery status — a row carrying them is
|
|
13
|
+
still `delivered`.
|
|
14
|
+
- No method changed shape.
|
|
15
|
+
|
|
16
|
+
## 0.7.1
|
|
17
|
+
|
|
18
|
+
- `WorldInstanceMetrics.delivery` gained `receiptCount` and
|
|
19
|
+
`eligibleForCleanup`. Delivery receipts are now collected when the outbox row
|
|
20
|
+
they proved is purged — the moment the last legal path back into delivery
|
|
21
|
+
closes — so a world running for months no longer accumulates them without
|
|
22
|
+
bound.
|
|
23
|
+
- No method changed shape.
|
|
24
|
+
|
|
25
|
+
## 0.7.0
|
|
26
|
+
|
|
27
|
+
Additive: the dead-letter recovery terminal states.
|
|
28
|
+
|
|
29
|
+
- `rehydrateDelivery`, `resolveDeliveryGap`, `describeDeliveryResolution`, and
|
|
30
|
+
the `WorldRehydrateFailure` type.
|
|
31
|
+
- `WorldDeliveryOpsRow.status` gained `rehydrating`, `unrecoverable` and
|
|
32
|
+
`resolved_gap`, plus `rehydratedAt` / `rehydratedFrom` /
|
|
33
|
+
`unrecoverableReason` / `resolvedGapAt` / `replacesDeliveryId`.
|
|
34
|
+
- Closes a loop 0.4.0 left open: a dead letter blocks its session, its text is
|
|
35
|
+
redacted after 30 days, and a redacted row refuses requeue. `rehydrateDelivery`
|
|
36
|
+
rebuilds it from the committed ledger (or the verified archive) and accepts the
|
|
37
|
+
result only if it re-derives the same delivery id and content digest;
|
|
38
|
+
`resolveDeliveryGap` is the last resort and takes no text.
|
|
39
|
+
- `requeueDelivery` now refuses `unrecoverable` and `rehydrating` explicitly
|
|
40
|
+
rather than reporting them as already-queued — they block, and saying
|
|
41
|
+
otherwise would tell you the stream is moving when it is stopped.
|
|
42
|
+
- No existing method changed shape.
|
|
43
|
+
|
|
44
|
+
## 0.6.0
|
|
45
|
+
|
|
46
|
+
Additive: the production hand-off.
|
|
47
|
+
|
|
48
|
+
- `createApprovedExport`, `listApprovedExports`, `deriveStoryPackageCandidate`,
|
|
49
|
+
and the `ApprovedScriptExportRow` type.
|
|
50
|
+
- An export carries lineage from the original story package through the world
|
|
51
|
+
instance, ledger range, evidence draft and editorial draft to the reviewer who
|
|
52
|
+
approved it. It is idempotent on content: the same approved review exports to
|
|
53
|
+
the same id forever, so replaying one is a no-op rather than a second script.
|
|
54
|
+
- `notify` sends `world.script_approved` to the project's webhooks with lineage
|
|
55
|
+
and identifiers — never the script body, which a webhook payload should not
|
|
56
|
+
scatter across logs and proxies. Fetch the body over the authenticated route.
|
|
57
|
+
- `deriveStoryPackageCandidate` returns a VALIDATED candidate and publishes
|
|
58
|
+
nothing. Only evidence-origin material becomes canon.
|
|
59
|
+
- No existing method changed shape.
|
|
60
|
+
|
|
61
|
+
## 0.5.0
|
|
62
|
+
|
|
63
|
+
Additive: the refusal taxonomy.
|
|
64
|
+
|
|
65
|
+
- `WorldRejectionCode` and `isDefectRejection`. `rejectedEffects[].code` now
|
|
66
|
+
travels on `WorldTurnResult`, and `WorldInstanceMetrics.turns` carries
|
|
67
|
+
`rejectionsByCode` plus the per-code rates.
|
|
68
|
+
- Only `narrative_conflict` describes content — two characters wanted
|
|
69
|
+
incompatible things and the story rule settled it. Every other code is a
|
|
70
|
+
defect in the story package, the prompt or the integration. Treat the
|
|
71
|
+
aggregate `effectRejectionRate` as a mixed number and read the split instead.
|
|
72
|
+
- `code` is absent on turns committed before this version. Read that as unknown,
|
|
73
|
+
never as narrative.
|
|
74
|
+
- No existing method changed shape.
|
|
75
|
+
|
|
76
|
+
## 0.4.0
|
|
77
|
+
|
|
78
|
+
Additive: the dead-letter operating loop.
|
|
79
|
+
|
|
80
|
+
- `listDeliveries`, `getDelivery`, `drainDeliveries`, `requeueDelivery`, and the
|
|
81
|
+
`describeDelivery` helper — plus `WorldDeliveryOpsRow`, `WorldDeliveryPage`,
|
|
82
|
+
`WorldDeliveryDetail` and `WorldDrainReport`.
|
|
83
|
+
- `requeueDelivery` takes no payload parameter, on purpose: the beat was
|
|
84
|
+
committed by the coordinator, and supplying a different line here would be
|
|
85
|
+
authoring world history through the delivery plane. It is idempotent, refuses
|
|
86
|
+
a delivered row, and refuses one whose payload has aged out (30 days).
|
|
87
|
+
- There is no discard method, and there is not going to be one.
|
|
88
|
+
- `getDelivery({ includePayload: true })` writes an audit row naming the reader.
|
|
89
|
+
- No existing method changed shape.
|
|
90
|
+
|
|
91
|
+
## 0.3.1
|
|
92
|
+
|
|
93
|
+
- `WorldInstanceMetrics.delivery` gained `blockedSessions`: delivery is now
|
|
94
|
+
ordered per target session, so a stuck line holds the ones behind it in that
|
|
95
|
+
session (and only that session). This is the count of readers currently
|
|
96
|
+
missing a beat.
|
|
97
|
+
- No method changed shape.
|
|
98
|
+
|
|
99
|
+
## 0.3.0
|
|
100
|
+
|
|
101
|
+
Additive: operations.
|
|
102
|
+
|
|
103
|
+
- `getWorldMetrics` — delivery and turn metric families for one instance, with
|
|
104
|
+
the `WorldInstanceMetrics` type.
|
|
105
|
+
- `evaluateWorld` — run the `drama` or `npc` quality suite over an instance's
|
|
106
|
+
committed history, with the `WorldEvalReport` type. Deterministic: no model
|
|
107
|
+
judges the output.
|
|
108
|
+
- `archiveLedger` — ledger archival, defaulting to the dry-run `plan`.
|
|
109
|
+
`execute` copies into immutable hash-chained segments and deletes nothing;
|
|
110
|
+
`prune` is the only call in this client that destroys a row, and it refuses
|
|
111
|
+
without `confirm` and again unless the archive verifies and has outlived its
|
|
112
|
+
retention.
|
|
113
|
+
- No existing method changed shape.
|
|
114
|
+
|
|
115
|
+
## 0.2.0
|
|
116
|
+
|
|
117
|
+
Additive: the EDITORIAL layer over script drafts.
|
|
118
|
+
|
|
119
|
+
- `createEditorialDraft`, `getEditorialDraft`, `listEditorialDrafts`,
|
|
120
|
+
`setEditorialStatus`, `decideEditorialScene`, `exportEditorialDraft`, and the
|
|
121
|
+
`EditorialDraftRow` type.
|
|
122
|
+
- The deterministic script draft is unchanged and is never rewritten by this
|
|
123
|
+
layer; an editorial draft is stored beneath its evidence draft. Lines the
|
|
124
|
+
model cannot be shown to have taken from the committed record are recorded as
|
|
125
|
+
suggestions, and suggestions never enter world canon.
|
|
126
|
+
- No existing method changed shape.
|
|
127
|
+
|
|
128
|
+
## 0.1.0
|
|
129
|
+
|
|
130
|
+
First release. Server-side client for the Pouchy World plane:
|
|
131
|
+
|
|
132
|
+
- authoring: story packages and world definitions (both immutable-versioned,
|
|
133
|
+
both idempotent on content bytes);
|
|
134
|
+
- running: world sessions, coordinated turns, trusted events — the machine lane
|
|
135
|
+
with a Secret Key plus a POUCHY-SOURCE-V1 signature over the exact body;
|
|
136
|
+
- reading: world state (user projection), committed turn read-back, bounded
|
|
137
|
+
resumable ledger replay;
|
|
138
|
+
- content return: script draft generation, review and export;
|
|
139
|
+
- helpers: request signing, turn-id idempotency, typed errors with a
|
|
140
|
+
`retryable` verdict, and `describeTurn` for reading a result without
|
|
141
|
+
guessing which of execution and delivery a status refers to;
|
|
142
|
+
- `conformance.mjs`: a runnable check of an integration end to end.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Pouchy Companion SDK License
|
|
2
|
+
Copyright (c) 2026 Pouchy.ai. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
This license governs use of the "@pouchy_ai/companion-sdk" software package (the
|
|
5
|
+
"SDK") as published to the npm registry. The SDK is proprietary software owned
|
|
6
|
+
by Pouchy.ai. By installing, copying, or using the SDK you agree to these terms.
|
|
7
|
+
|
|
8
|
+
1. GRANT. Subject to these terms, Pouchy.ai grants you a worldwide,
|
|
9
|
+
non-exclusive, non-transferable, royalty-free, revocable license to install
|
|
10
|
+
and use the SDK, as distributed, solely to build and operate applications
|
|
11
|
+
that integrate with Pouchy.ai's official services and APIs.
|
|
12
|
+
|
|
13
|
+
2. RESTRICTIONS. Except to the extent a restriction below is prohibited by
|
|
14
|
+
applicable law, you may not:
|
|
15
|
+
(a) modify, adapt, or create derivative works of the SDK's source for
|
|
16
|
+
redistribution;
|
|
17
|
+
(b) redistribute, sublicense, sell, rent, or lease the SDK as a standalone
|
|
18
|
+
product, or republish it under a different name;
|
|
19
|
+
(c) reverse engineer, decompile, or disassemble the SDK except to the extent
|
|
20
|
+
necessary for interoperability and expressly permitted by law;
|
|
21
|
+
(d) use the SDK to build or operate a service that competes with Pouchy.ai's
|
|
22
|
+
services, or to access Pouchy.ai's services in violation of their terms;
|
|
23
|
+
(e) remove or alter any copyright, trademark, or other proprietary notices.
|
|
24
|
+
|
|
25
|
+
3. RESERVATION OF RIGHTS. All rights not expressly granted are reserved by
|
|
26
|
+
Pouchy.ai. No rights are granted in Pouchy.ai's source repositories,
|
|
27
|
+
trademarks, or services beyond what is needed to use the SDK as described.
|
|
28
|
+
|
|
29
|
+
4. TERMINATION. This license terminates automatically if you breach it. On
|
|
30
|
+
termination you must stop using and delete all copies of the SDK.
|
|
31
|
+
|
|
32
|
+
5. NO WARRANTY. THE SDK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
33
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
34
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
35
|
+
|
|
36
|
+
6. LIMITATION OF LIABILITY. IN NO EVENT SHALL POUCHY.AI BE LIABLE FOR ANY CLAIM,
|
|
37
|
+
DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
|
|
38
|
+
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SDK OR THE USE OR
|
|
39
|
+
OTHER DEALINGS IN THE SDK.
|
|
40
|
+
|
|
41
|
+
For any other use, or for written permission, contact legal@pouchy.ai.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @pouchy_ai/world-sdk
|
|
2
|
+
|
|
3
|
+
Server-side TypeScript client for **Pouchy World** — build an interactive short
|
|
4
|
+
drama, a game's NPC cast, or an interactive novel on top of Pouchy, without an
|
|
5
|
+
official app.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @pouchy_ai/world-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node ≥ 18, zero dependencies.
|
|
12
|
+
|
|
13
|
+
## Node only. Really.
|
|
14
|
+
|
|
15
|
+
This client holds a project **Secret Key** (`pchy_sk_…`) and an event-source
|
|
16
|
+
**signing secret** (`pesk_…`). They never belong in a browser or a mobile app.
|
|
17
|
+
The world's machine lane requires BOTH — a key proves the project and its
|
|
18
|
+
test/live axis, a signature proves the provider — and a leaked pair is a world
|
|
19
|
+
anyone can drive.
|
|
20
|
+
|
|
21
|
+
What your users' devices get is the **session token** this client mints for
|
|
22
|
+
them, which is scoped to one world instance and one role and carries no project
|
|
23
|
+
credential. Drive that with `@pouchy_ai/companion-sdk`.
|
|
24
|
+
|
|
25
|
+
## The two lanes
|
|
26
|
+
|
|
27
|
+
| Lane | Credential | What it does |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| owner | a signed-in project admin's ID token | author story packages and worlds; read state, turns, drafts; run replay |
|
|
30
|
+
| machine | Secret Key **and** `X-Pouchy-Source-Signature` | mint sessions, drive turns, send trusted events |
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { PouchyWorldClient, newTurnId, describeTurn } from '@pouchy_ai/world-sdk';
|
|
34
|
+
|
|
35
|
+
const world = new PouchyWorldClient({
|
|
36
|
+
projectId: process.env.POUCHY_PROJECT_ID!,
|
|
37
|
+
adminToken: process.env.POUCHY_ADMIN_TOKEN!,
|
|
38
|
+
secretKey: process.env.POUCHY_SECRET_KEY!,
|
|
39
|
+
signing: { source: 'drama-backend', keyId: process.env.KID!, secret: process.env.SECRET! }
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What it covers
|
|
44
|
+
|
|
45
|
+
**Authoring** — `createStoryPackage`, `publishStoryPackage`, `getStoryPackage`,
|
|
46
|
+
`listStoryPackages`, `createWorld`, `publishWorld`, `getWorld`, `listWorlds`.
|
|
47
|
+
|
|
48
|
+
**Running** — `createWorldSession`, `runTurn`, `sendEvent`.
|
|
49
|
+
|
|
50
|
+
**Reading** — `getWorldState`, `getTurn`, `replayLedger`, `replayLedgerToEnd`.
|
|
51
|
+
|
|
52
|
+
**Content return** — `createScriptDraft`, `getScriptDraft`, `listScriptDrafts`,
|
|
53
|
+
`reviewScriptDraft`, `exportScriptDraft`.
|
|
54
|
+
|
|
55
|
+
**Delivery ops** — `listDeliveries`, `getDelivery`, `drainDeliveries`,
|
|
56
|
+
`requeueDelivery`, `describeDelivery`. Deliveries land in ledger order per
|
|
57
|
+
session, so a stuck line holds the ones behind it in that session (and only
|
|
58
|
+
that session) — `blocking` on a row is a reader who has stopped receiving the
|
|
59
|
+
story. `requeueDelivery` is the way out; it takes no payload, because the beat
|
|
60
|
+
was committed by the coordinator and re-writing it here would make the delivery
|
|
61
|
+
plane a second authoring path. There is no discard.
|
|
62
|
+
|
|
63
|
+
**Stuck deliveries** — `rehydrateDelivery`, `resolveDeliveryGap`,
|
|
64
|
+
`describeDeliveryResolution`. Three verbs in the order to try them: re-send what
|
|
65
|
+
is still there, rebuild it from the committed record, or tell the reader the
|
|
66
|
+
beat is gone. None of them accepts replacement text, and none of them skips a
|
|
67
|
+
beat silently — `resolved_gap` means the reader was told, and it is the only
|
|
68
|
+
non-delivered state a session moves past.
|
|
69
|
+
|
|
70
|
+
**Operations** — `getWorldMetrics`, `evaluateWorld`, `archiveLedger`,
|
|
71
|
+
`replayLedger` / `replayLedgerToEnd`. Metrics keep delivery and turn health as
|
|
72
|
+
separate families; the eval suites are deterministic, so a score can be
|
|
73
|
+
regressed. `archiveLedger` defaults to a dry-run plan, `execute` only ever
|
|
74
|
+
copies, and `prune` — the one call here that deletes — refuses without
|
|
75
|
+
`confirm`, and again unless the archive verifies and has outlived retention.
|
|
76
|
+
|
|
77
|
+
**Production hand-off** — `createApprovedExport`, `listApprovedExports`,
|
|
78
|
+
`deriveStoryPackageCandidate`. An approved editorial draft becomes a versioned
|
|
79
|
+
`ApprovedScriptExportV1` carrying the whole chain: original story package →
|
|
80
|
+
world instance → ledger range → evidence draft → editorial draft → reviewer.
|
|
81
|
+
Idempotent on content, so replaying an export is a no-op. The Story Package
|
|
82
|
+
candidate is validated and *returned*; publishing it is a separate act by a
|
|
83
|
+
person, and only evidence-origin material becomes canon.
|
|
84
|
+
|
|
85
|
+
**Editorial layer** — `createEditorialDraft`, `getEditorialDraft`,
|
|
86
|
+
`listEditorialDrafts`, `setEditorialStatus`, `decideEditorialScene`,
|
|
87
|
+
`exportEditorialDraft`. The script draft is deterministic: it is assembled from
|
|
88
|
+
committed ledger entries and nothing else, and nothing rewrites it. The
|
|
89
|
+
editorial draft is a model's *reading* of that draft — scenes, ordering,
|
|
90
|
+
connective prose — stored underneath it. Every line the model presents as
|
|
91
|
+
something a character said is re-checked against the committed record on the
|
|
92
|
+
server; anything that does not match is stored as `origin: "suggestion"`,
|
|
93
|
+
whatever the model labelled it. Suggestions never reach world canon, and only
|
|
94
|
+
an `approved` editorial can be exported.
|
|
95
|
+
|
|
96
|
+
**Helpers** — `signSourceRequest` (the exact canonical the server verifies),
|
|
97
|
+
`newTurnId` / `isReservedTurnId` (idempotency), `describeTurn` (read a result
|
|
98
|
+
without guessing), `WorldApiError` with typed codes and `.retryable`.
|
|
99
|
+
|
|
100
|
+
## The three things integrators get wrong
|
|
101
|
+
|
|
102
|
+
**1. Turn ids are the idempotency key.** Mint one per BEAT and re-send the same
|
|
103
|
+
one to retry. A new id is a new beat: it will run the models again and commit
|
|
104
|
+
again. `newTurnId()` exists so this is a deliberate choice rather than a habit.
|
|
105
|
+
|
|
106
|
+
**2. Sign the bytes you send.** `signSourceRequest` hashes the exact body
|
|
107
|
+
string. Serializing twice — once to sign, once to send — signs bytes you did
|
|
108
|
+
not send, and the server will (correctly) refuse them. This client always signs
|
|
109
|
+
the string it is about to write.
|
|
110
|
+
|
|
111
|
+
**3. Execution and delivery are different questions.** `executionStatus` says
|
|
112
|
+
whether the world moved; `deliveryStatus` says whether the audience has heard
|
|
113
|
+
about it yet. A `pending` delivery is not a failed turn — the intent was
|
|
114
|
+
written inside the commit and a durable outbox is retrying it.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const beat = await world.runTurn({ environmentId, worldInstanceId, text: '…' });
|
|
118
|
+
const read = describeTurn(beat);
|
|
119
|
+
if (read.shouldRetrySameTurn) { /* conflict — re-send the SAME turnId */ }
|
|
120
|
+
if (read.needsDifferentRequest) { /* rejected/refused — fix it, use a NEW id */ }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Conformance
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
node node_modules/@pouchy_ai/world-sdk/conformance.mjs
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Checks your credentials, signing, world resolution, role bindings, a real turn,
|
|
130
|
+
turn recovery, state read, replay and draft generation against your OWN
|
|
131
|
+
project — before you write product code. It prints a pass/fail line per check
|
|
132
|
+
and exits non-zero on the first structural failure.
|
|
133
|
+
|
|
134
|
+
## Quickstarts
|
|
135
|
+
|
|
136
|
+
- `docs/world-quickstart-drama.md` — screenplay → world → new screenplay draft
|
|
137
|
+
- `docs/world-quickstart-npc.md` — one town, three NPCs, one shared state
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
See LICENSE.
|
package/conformance.mjs
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pouchy World — conformance runner.
|
|
3
|
+
//
|
|
4
|
+
// Checks an integration END TO END against a real project, before any product
|
|
5
|
+
// code exists: credentials, the exact signing canonical, world resolution, role
|
|
6
|
+
// bindings, capability intersection, a real coordinated turn, turn recovery,
|
|
7
|
+
// state reads, replay verification and draft generation.
|
|
8
|
+
//
|
|
9
|
+
// It is deliberately a script rather than a test suite: an integrator running
|
|
10
|
+
// this has no repository, no vitest and no fixtures — they have a terminal, a
|
|
11
|
+
// project, and a question about whether their setup is right.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here is destructive: it creates a story package, a world and one
|
|
14
|
+
// session in YOUR project (all versioned, all disable-able), drives one beat,
|
|
15
|
+
// and reads. It never deletes and never publishes anything to end users.
|
|
16
|
+
//
|
|
17
|
+
// Two scenarios, because the two products fail differently and an integrator
|
|
18
|
+
// should check the one they are building. `--scenario=drama` casts two
|
|
19
|
+
// characters who both speak in a beat; `--scenario=npc` casts a merchant who
|
|
20
|
+
// has to answer AND touch world state, which is the failure mode that matters
|
|
21
|
+
// there (an NPC that chats without the world moving looks fine and is not).
|
|
22
|
+
//
|
|
23
|
+
// POUCHY_PROJECT_ID=… POUCHY_ADMIN_TOKEN=… POUCHY_SECRET_KEY=… \
|
|
24
|
+
// POUCHY_SOURCE=… POUCHY_SOURCE_KID=… POUCHY_SOURCE_SECRET=… \
|
|
25
|
+
// POUCHY_AGENT_A=… POUCHY_AGENT_B=… node conformance.mjs [--scenario=drama|npc]
|
|
26
|
+
|
|
27
|
+
import { PouchyWorldClient, newTurnId, describeTurn, WorldApiError } from './dist/index.js';
|
|
28
|
+
|
|
29
|
+
const env = (name, required = true) => {
|
|
30
|
+
const value = process.env[name];
|
|
31
|
+
if (!value && required) {
|
|
32
|
+
console.error(`MISSING ${name}`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The two shapes an integrator can be building. Same plane, different cast,
|
|
39
|
+
* and a different question at the end of the beat. */
|
|
40
|
+
const SCENARIOS = {
|
|
41
|
+
drama: {
|
|
42
|
+
label: 'short drama',
|
|
43
|
+
roles: [
|
|
44
|
+
{ storyRoleId: 'heroine', worldRoleId: 'heroine', effectsAllow: ['set_flag'] },
|
|
45
|
+
{ storyRoleId: 'rival', worldRoleId: 'rival', effectsAllow: ['set_flag'] }
|
|
46
|
+
],
|
|
47
|
+
scenes: [{ sceneId: 'docks', title: 'Docks', description: 'After rain, before dawn.' }],
|
|
48
|
+
nodes: [
|
|
49
|
+
{ nodeId: 'n1', sceneId: 'docks', title: 'Meet', objective: 'they meet', prerequisites: [] }
|
|
50
|
+
],
|
|
51
|
+
flags: [{ key: 'tension', kind: 'number', initial: 0 }],
|
|
52
|
+
leadRole: 'heroine',
|
|
53
|
+
prompt: 'Conformance check: you two have just met at the docks. One short line each.',
|
|
54
|
+
// A drama is wrong when only one voice comes back.
|
|
55
|
+
expect: (beat) => {
|
|
56
|
+
if ((beat.roleMessages ?? []).length < 2) {
|
|
57
|
+
throw new Error(`a drama beat returned ${(beat.roleMessages ?? []).length} line(s); both roles should speak`);
|
|
58
|
+
}
|
|
59
|
+
return `${beat.roleMessages.length} roles spoke`;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
npc: {
|
|
63
|
+
label: 'game NPC',
|
|
64
|
+
roles: [
|
|
65
|
+
{ storyRoleId: 'merchant', worldRoleId: 'merchant', effectsAllow: ['set_flag'] },
|
|
66
|
+
{ storyRoleId: 'guard', worldRoleId: 'guard' }
|
|
67
|
+
],
|
|
68
|
+
scenes: [{ sceneId: 'market', title: 'Market', description: 'A stall at the gate.' }],
|
|
69
|
+
nodes: [
|
|
70
|
+
{ nodeId: 'n1', sceneId: 'market', title: 'Trade', objective: 'a price is agreed', prerequisites: [] }
|
|
71
|
+
],
|
|
72
|
+
flags: [{ key: 'stock', kind: 'number', initial: 3 }],
|
|
73
|
+
leadRole: 'merchant',
|
|
74
|
+
prompt: 'Conformance check: a player asks your price for one lamp. Answer briefly.',
|
|
75
|
+
// An NPC is wrong when it talks and the world does not move.
|
|
76
|
+
expect: (beat) => {
|
|
77
|
+
if (!(beat.roleMessages ?? []).length) throw new Error('no NPC answered');
|
|
78
|
+
const moved = (beat.committedStateDiff ?? []).length > 0;
|
|
79
|
+
return moved
|
|
80
|
+
? `${beat.roleMessages.length} answered, ${beat.committedStateDiff.length} state op(s)`
|
|
81
|
+
: `${beat.roleMessages.length} answered, world state unchanged (check effectsAllow if this is unexpected)`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const scenarioName = (process.argv.find((a) => a.startsWith('--scenario=')) ?? '--scenario=drama')
|
|
86
|
+
.split('=')[1];
|
|
87
|
+
const scenario = SCENARIOS[scenarioName];
|
|
88
|
+
if (!scenario) {
|
|
89
|
+
console.error(`unknown --scenario=${scenarioName}; expected one of: ${Object.keys(SCENARIOS).join(', ')}`);
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
console.log(`Pouchy World conformance — ${scenario.label} scenario\n`);
|
|
93
|
+
|
|
94
|
+
const results = [];
|
|
95
|
+
let failed = false;
|
|
96
|
+
|
|
97
|
+
async function check(name, fn) {
|
|
98
|
+
try {
|
|
99
|
+
const detail = await fn();
|
|
100
|
+
results.push({ name, ok: true, detail });
|
|
101
|
+
console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
failed = true;
|
|
104
|
+
const detail =
|
|
105
|
+
err instanceof WorldApiError
|
|
106
|
+
? `${err.code} ${err.status}${err.detail ? `: ${err.detail}` : ''}`
|
|
107
|
+
: err instanceof Error
|
|
108
|
+
? err.message
|
|
109
|
+
: String(err);
|
|
110
|
+
results.push({ name, ok: false, detail });
|
|
111
|
+
console.error(`FAIL ${name} — ${detail}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const world = new PouchyWorldClient({
|
|
116
|
+
projectId: env('POUCHY_PROJECT_ID'),
|
|
117
|
+
adminToken: env('POUCHY_ADMIN_TOKEN'),
|
|
118
|
+
secretKey: env('POUCHY_SECRET_KEY'),
|
|
119
|
+
signing: {
|
|
120
|
+
source: env('POUCHY_SOURCE'),
|
|
121
|
+
keyId: env('POUCHY_SOURCE_KID'),
|
|
122
|
+
secret: env('POUCHY_SOURCE_SECRET')
|
|
123
|
+
},
|
|
124
|
+
...(process.env.POUCHY_BASE_URL ? { baseUrl: process.env.POUCHY_BASE_URL } : {})
|
|
125
|
+
});
|
|
126
|
+
const agentA = env('POUCHY_AGENT_A');
|
|
127
|
+
const agentB = env('POUCHY_AGENT_B');
|
|
128
|
+
const stamp = Date.now().toString(36);
|
|
129
|
+
|
|
130
|
+
let pkg;
|
|
131
|
+
let created;
|
|
132
|
+
let instanceId;
|
|
133
|
+
let turnId;
|
|
134
|
+
|
|
135
|
+
await check('owner credential resolves the project', async () => {
|
|
136
|
+
const list = await world.listStoryPackages();
|
|
137
|
+
return `${(list.packages ?? []).length} story package(s) visible`;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await check('story package imports and pins', async () => {
|
|
141
|
+
pkg = await world.createStoryPackage({
|
|
142
|
+
name: `Conformance ${scenarioName} ${stamp}`,
|
|
143
|
+
summary: `Generated by the Pouchy World conformance runner (${scenario.label}).`,
|
|
144
|
+
roles: scenario.roles,
|
|
145
|
+
scenes: scenario.scenes,
|
|
146
|
+
nodes: scenario.nodes,
|
|
147
|
+
stateSchema: { flags: scenario.flags }
|
|
148
|
+
});
|
|
149
|
+
return `${pkg.packageId} rev ${pkg.revision}`;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await check('world publishes with an EXPLICIT coordinated runtime', async () => {
|
|
153
|
+
created = await world.createWorld({
|
|
154
|
+
name: `Conformance World ${stamp}`,
|
|
155
|
+
providerRef: env('POUCHY_SOURCE'),
|
|
156
|
+
runtimeMode: 'coordinated',
|
|
157
|
+
storyPackageRef: {
|
|
158
|
+
packageId: pkg.packageId,
|
|
159
|
+
revision: pkg.revision,
|
|
160
|
+
contentHash: pkg.contentHash
|
|
161
|
+
},
|
|
162
|
+
capabilityAllow: { views: [], actions: [], events: [] },
|
|
163
|
+
roles: [
|
|
164
|
+
{ roleId: scenario.roles[0].worldRoleId, agentId: agentA },
|
|
165
|
+
{ roleId: scenario.roles[1].worldRoleId, agentId: agentB }
|
|
166
|
+
]
|
|
167
|
+
});
|
|
168
|
+
return `${created.environmentId} rev ${created.environmentRevision}`;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await check('session mint binds one user to one role (machine lane)', async () => {
|
|
172
|
+
const session = await world.createWorldSession({
|
|
173
|
+
environment: created.environmentId,
|
|
174
|
+
role: scenario.leadRole,
|
|
175
|
+
externalUserId: `conformance-${scenarioName}-${stamp}`
|
|
176
|
+
});
|
|
177
|
+
instanceId = session?.world?.instance ?? session?.world?.world_instance;
|
|
178
|
+
if (!instanceId) throw new Error('no world instance in the session payload');
|
|
179
|
+
if (!session.token) throw new Error('no session token minted');
|
|
180
|
+
return instanceId;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await check('a signed turn runs a coordinated beat', async () => {
|
|
184
|
+
turnId = newTurnId('conformance');
|
|
185
|
+
const beat = await world.runTurn({
|
|
186
|
+
environmentId: created.environmentId,
|
|
187
|
+
worldInstanceId: instanceId,
|
|
188
|
+
turnId,
|
|
189
|
+
text: scenario.prompt
|
|
190
|
+
});
|
|
191
|
+
const read = describeTurn(beat);
|
|
192
|
+
if (!beat.selectedRoles?.length && beat.completionStatus === 'refused') {
|
|
193
|
+
throw new Error(`refused: ${beat.rejectedEffects?.[0]?.reason ?? 'unknown'}`);
|
|
194
|
+
}
|
|
195
|
+
// The scenario's own question — a drama that returns one voice and an NPC
|
|
196
|
+
// that never touches the world both pass every generic check above.
|
|
197
|
+
return `${read.summary}; ${scenario.expect(beat)}`;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await check('the SAME turnId is idempotent (no second beat)', async () => {
|
|
201
|
+
const again = await world.runTurn({
|
|
202
|
+
environmentId: created.environmentId,
|
|
203
|
+
worldInstanceId: instanceId,
|
|
204
|
+
turnId,
|
|
205
|
+
text: 'Conformance check: say one short line in character.'
|
|
206
|
+
});
|
|
207
|
+
if (again.completionStatus !== 'duplicate') {
|
|
208
|
+
throw new Error(`expected duplicate, got ${again.completionStatus}`);
|
|
209
|
+
}
|
|
210
|
+
return 'duplicate, nothing advanced';
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await check('a committed turn is recoverable by id', async () => {
|
|
214
|
+
const recovered = await world.getTurn(created.environmentId, instanceId, turnId);
|
|
215
|
+
if (recovered.turnId !== turnId) throw new Error('turn read back with a different id');
|
|
216
|
+
return `${(recovered.roleMessages ?? []).length} line(s) on the record`;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await check('world state reads back (user projection, no private layer)', async () => {
|
|
220
|
+
const { state } = await world.getWorldState(created.environmentId, instanceId);
|
|
221
|
+
if (typeof state.stateRevision !== 'number') throw new Error('no stateRevision');
|
|
222
|
+
if ('rolePrivate' in state) throw new Error('the user projection leaked the private layer');
|
|
223
|
+
return `revision ${state.stateRevision}`;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await check('replay verifies the ledger against the served state', async () => {
|
|
227
|
+
const report = await world.replayLedgerToEnd(created.environmentId, instanceId);
|
|
228
|
+
if (report.verdict !== 'consistent') {
|
|
229
|
+
throw new Error(`${report.verdict}${report.detail ? `: ${report.detail}` : ''}`);
|
|
230
|
+
}
|
|
231
|
+
if (report.dryRun !== true) throw new Error('replay did not report itself as a dry run');
|
|
232
|
+
return `consistent over ${report.entriesScanned} entr(ies)`;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await check('a script draft generates and refuses export before review', async () => {
|
|
236
|
+
const draft = await world.createScriptDraft(created.environmentId, instanceId);
|
|
237
|
+
if (draft.content?.humanReviewRequired !== true) {
|
|
238
|
+
throw new Error('draft did not carry humanReviewRequired');
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await world.exportScriptDraft(created.environmentId, instanceId, draft.draftId);
|
|
242
|
+
throw new Error('export succeeded WITHOUT a human review');
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (!(err instanceof WorldApiError) || err.status !== 409) throw err;
|
|
245
|
+
}
|
|
246
|
+
return `${draft.draftId} (export correctly refused)`;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await check('a WRONG signature is refused (the second proof is real)', async () => {
|
|
250
|
+
const bad = new PouchyWorldClient({
|
|
251
|
+
projectId: env('POUCHY_PROJECT_ID'),
|
|
252
|
+
secretKey: env('POUCHY_SECRET_KEY'),
|
|
253
|
+
signing: {
|
|
254
|
+
source: env('POUCHY_SOURCE'),
|
|
255
|
+
keyId: env('POUCHY_SOURCE_KID'),
|
|
256
|
+
secret: 'pesk_not_the_real_secret'
|
|
257
|
+
},
|
|
258
|
+
...(process.env.POUCHY_BASE_URL ? { baseUrl: process.env.POUCHY_BASE_URL } : {})
|
|
259
|
+
});
|
|
260
|
+
try {
|
|
261
|
+
await bad.runTurn({
|
|
262
|
+
environmentId: created.environmentId,
|
|
263
|
+
worldInstanceId: instanceId,
|
|
264
|
+
text: 'this must not run'
|
|
265
|
+
});
|
|
266
|
+
throw new Error('a bad signature was ACCEPTED');
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (!(err instanceof WorldApiError) || err.status !== 403) throw err;
|
|
269
|
+
}
|
|
270
|
+
return 'refused with 403, as it must be';
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(`${results.filter((r) => r.ok).length}/${results.length} checks passed`);
|
|
275
|
+
if (failed) {
|
|
276
|
+
console.error('Conformance FAILED — fix the first failure above before writing product code.');
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
console.log('Conformance passed. The world plane is wired correctly for this project.');
|