@pipeworx/mcp-creative-constraints 0.1.0 → 0.2.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/src/index.ts DELETED
@@ -1,917 +0,0 @@
1
- interface McpToolDefinition {
2
- name: string;
3
- description: string;
4
- /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
- * description. Kept in step with shared/src/types.ts — scripts/lib/
6
- * check-inlined-types.mjs reports drift at publish time. */
7
- summary?: string;
8
- inputSchema: {
9
- type: 'object';
10
- properties: Record<string, unknown>;
11
- required?: string[];
12
- anyOf?: Array<{ required: string[] }>;
13
- oneOf?: Array<{ required: string[] }>;
14
- allOf?: Array<{ required: string[] }>;
15
- };
16
- outputSchema?: Record<string, unknown>;
17
- }
18
-
19
- interface McpToolExport {
20
- tools: McpToolDefinition[];
21
- callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
22
- meter?: { credits: number };
23
- cost?: Record<string, unknown>;
24
- provider?: string;
25
- }
26
-
27
- /**
28
- * chance.ts — the shared randomization engine for the chance-pack family
29
- * (dice-roller, tarot, i-ching, creative-constraints, random-tables).
30
- * Spec: docs/chance-packs-plan.md, "The shared engine".
31
- *
32
- * THE SIGNATURES BELOW ARE A CONTRACT. Five packs were written against them
33
- * before this file existed; renaming or reshaping an export breaks them all.
34
- * Add exports freely, change none.
35
- *
36
- * Three properties every caller relies on:
37
- *
38
- * 1. Real randomness by default. `makeRng()` with no seed draws 128 bits from
39
- * `crypto.getRandomValues` and RETURNS them as `rng.seed` (32 hex chars).
40
- * 2. Reproducible on demand. `makeRng(seed)` is a pure function of the seed
41
- * string: cyrb128 hashes it to four 32-bit words, which seed sfc32. The
42
- * unseeded path runs through the same function with the generated hex, so
43
- * the returned seed reproduces that exact call.
44
- * 3. Stable across releases. The sequence for a given seed is PINNED in
45
- * __tests__/chance.test.ts. Changing a constant here re-deals every seeded
46
- * result anyone has ever cited — that test is meant to go red when you do.
47
- *
48
- * Zero third-party dependencies, no module-scope state, no I/O except the
49
- * optional KV handed to the session functions. Safe in Workers and Node 19+.
50
- */
51
-
52
- // ── PRNG ────────────────────────────────────────────────────────────────────
53
-
54
- type Rng = {
55
- seed: string; // always set; pass it back to reproduce
56
- next(): number; // [0,1)
57
- int(min: number, max: number): number; // inclusive
58
- pick<T>(items: readonly T[]): T;
59
- shuffle<T>(items: readonly T[]): T[]; // Fisher–Yates, returns a copy
60
- };
61
-
62
- /** Longest seed string accepted. A seed is a citation handle, not a payload. */
63
- const MAX_SEED_LENGTH = 256;
64
-
65
- /** cyrb128 (bryc, public domain): string → four 32-bit words. */
66
- function cyrb128(str: string): [number, number, number, number] {
67
- let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;
68
- for (let i = 0; i < str.length; i++) {
69
- const k = str.charCodeAt(i);
70
- h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
71
- h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
72
- h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
73
- h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
74
- }
75
- h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
76
- h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
77
- h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
78
- h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
79
- h1 ^= h2 ^ h3 ^ h4;
80
- h2 ^= h1;
81
- h3 ^= h1;
82
- h4 ^= h1;
83
- return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0];
84
- }
85
-
86
- /** sfc32 (Chris Doty-Humphrey, public domain): four words → uint32 stream. */
87
- function sfc32(a: number, b: number, c: number, d: number): () => number {
88
- return () => {
89
- a |= 0; b |= 0; c |= 0; d |= 0;
90
- const t = (((a + b) | 0) + d) | 0;
91
- d = (d + 1) | 0;
92
- a = b ^ (b >>> 9);
93
- b = (c + (c << 3)) | 0;
94
- c = (c << 21) | (c >>> 11);
95
- c = (c + t) | 0;
96
- return t >>> 0;
97
- };
98
- }
99
-
100
- /** Outputs discarded after seeding so near-identical seeds decorrelate. */
101
- const SFC32_WARMUP = 15;
102
-
103
- /** 128 bits from the platform CSPRNG, hex-encoded (32 chars). */
104
- function randomSeed(): string {
105
- const bytes = new Uint8Array(16);
106
- crypto.getRandomValues(bytes);
107
- let hex = '';
108
- for (const b of bytes) hex += b.toString(16).padStart(2, '0');
109
- return hex;
110
- }
111
-
112
- function normalizeSeed(seed: unknown): string | undefined {
113
- if (seed === undefined || seed === null) return undefined;
114
- if (typeof seed === 'number' && Number.isFinite(seed)) seed = String(seed);
115
- if (typeof seed !== 'string') {
116
- throw new Error(`seed must be a string (got ${typeof seed}). Omit it for a fresh random draw; pass the returned seed back to reproduce one.`);
117
- }
118
- if (seed.length === 0) return undefined;
119
- if (seed.length > MAX_SEED_LENGTH) {
120
- throw new Error(`seed is ${seed.length} characters; the limit is ${MAX_SEED_LENGTH}.`);
121
- }
122
- return seed;
123
- }
124
-
125
- // seed omitted → 128 bits from crypto.getRandomValues, hex-encoded, RETURNED as
126
- // rng.seed. seed given → deterministic (hash the string, e.g. cyrb128 → sfc32).
127
- function makeRng(seed?: string): Rng {
128
- const s = normalizeSeed(seed) ?? randomSeed();
129
- const [a, b, c, d] = cyrb128(s);
130
- const u32 = sfc32(a, b, c, d);
131
- for (let i = 0; i < SFC32_WARMUP; i++) u32();
132
-
133
- const next = (): number => u32() / 4294967296;
134
-
135
- const int = (min: number, max: number): number => {
136
- if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) {
137
- throw new Error(`rng.int needs integer bounds (got ${min}, ${max}).`);
138
- }
139
- if (min > max) throw new Error(`rng.int: min ${min} is greater than max ${max}.`);
140
- const range = max - min + 1;
141
- if (range <= 4294967296) {
142
- // Rejection sampling: no modulo bias at any range.
143
- const limit = 4294967296 - (4294967296 % range);
144
- let x = u32();
145
- while (x >= limit) x = u32();
146
- return min + (x % range);
147
- }
148
- // Ranges wider than 2^32: 53 bits from two draws.
149
- const hi = u32() >>> 5, lo = u32() >>> 6;
150
- const f = (hi * 67108864 + lo) / 9007199254740992;
151
- return min + Math.floor(f * range);
152
- };
153
-
154
- const pick = <T>(items: readonly T[]): T => {
155
- if (!items || items.length === 0) throw new Error('rng.pick: nothing to pick from (empty list).');
156
- return items[int(0, items.length - 1)];
157
- };
158
-
159
- const shuffle = <T>(items: readonly T[]): T[] => {
160
- const out = items.slice();
161
- for (let i = out.length - 1; i > 0; i--) {
162
- const j = int(0, i);
163
- const tmp = out[i];
164
- out[i] = out[j];
165
- out[j] = tmp;
166
- }
167
- return out;
168
- };
169
-
170
- return { seed: s, next, int, pick, shuffle };
171
- }
172
-
173
- // ── draws ───────────────────────────────────────────────────────────────────
174
-
175
- function drawWithoutReplacement<T>(items: readonly T[], n: number, rng: Rng): T[] {
176
- if (!Number.isInteger(n) || n < 0) throw new Error(`draw count must be a whole number ≥ 0 (got ${n}).`);
177
- if (n > items.length) {
178
- throw new Error(`cannot draw ${n} without replacement from ${items.length} item(s); draw at most ${items.length}.`);
179
- }
180
- // Partial Fisher–Yates: only the first n positions are settled.
181
- const pool = items.slice();
182
- for (let i = 0; i < n; i++) {
183
- const j = rng.int(i, pool.length - 1);
184
- const tmp = pool[i];
185
- pool[i] = pool[j];
186
- pool[j] = tmp;
187
- }
188
- return pool.slice(0, n);
189
- }
190
-
191
- type WeightedEntry<T> = { value: T; weight?: number }; // weight default 1, must be > 0
192
-
193
- function rollWeighted<T>(entries: readonly WeightedEntry<T>[], rng: Rng): { value: T; index: number } {
194
- if (!entries || entries.length === 0) throw new Error('rollWeighted: the table is empty.');
195
- let total = 0;
196
- const weights = entries.map((e, i) => {
197
- const w = e.weight === undefined ? 1 : e.weight;
198
- if (typeof w !== 'number' || !Number.isFinite(w) || w <= 0) {
199
- throw new Error(`rollWeighted: entry ${i} has weight ${String(w)}; weights must be finite numbers > 0 (default 1).`);
200
- }
201
- total += w;
202
- return w;
203
- });
204
- let r = rng.next() * total;
205
- for (let i = 0; i < weights.length; i++) {
206
- r -= weights[i];
207
- if (r < 0) return { value: entries[i].value, index: i };
208
- }
209
- // Floating-point residue: the last entry owns the top of the range.
210
- const last = entries.length - 1;
211
- return { value: entries[last].value, index: last };
212
- }
213
-
214
- // ── dice notation ───────────────────────────────────────────────────────────
215
-
216
- /** Most dice in one notation (summed across terms, before explosions). */
217
- const MAX_DICE = 1000;
218
- /** Most sides on one die. */
219
- const MAX_SIDES = 10000;
220
- /** Most extra dice one exploding die may add before the chain is cut. */
221
- const MAX_EXPLOSIONS_PER_DIE = 100;
222
- const MAX_TERMS = 50;
223
- const MAX_NOTATION_LENGTH = 200;
224
-
225
- type DiceKeep = { mode: 'kh' | 'kl' | 'dh' | 'dl'; count: number };
226
-
227
- /** One parsed term. `kind:'constant'` carries only `value`; dice terms carry the rest. */
228
- type DiceTerm = {
229
- sign: 1 | -1;
230
- text: string; // the term as written, normalized, without its sign
231
- kind: 'dice' | 'constant';
232
- count: number; // dice in the term (0 for a constant)
233
- sides: number; // 3 for fudge (faces −1, 0, +1); 0 for a constant
234
- fudge: boolean;
235
- explode: boolean;
236
- keep?: DiceKeep;
237
- value: number; // constant value (0 for dice)
238
- };
239
-
240
- type ParsedDice = { notation: string; terms: DiceTerm[]; dice: number };
241
-
242
- const DICE_GRAMMAR =
243
- 'NdS terms joined by + or - (e.g. 2d6+1d4+3). N defaults to 1; S is a number, % (=100) or F (fudge: -1/0/+1). ' +
244
- 'Optional modifiers, in this order: ! (exploding: a max face rolls again), then kh/kl/dh/dl with an optional count ' +
245
- '(keep highest / keep lowest / drop highest / drop lowest; k alone means kh). Examples: 4d6kh3, 2d20kl1, d%, 4dF, 3d6!, 1d20+5.';
246
-
247
- const TERM_RE = /^(\d*)d(\d+|%|f)(!)?(?:(kh|kl|dh|dl|k)(\d+)?)?$/;
248
-
249
- function diceError(notation: string, why: string): Error {
250
- return new Error(`Cannot read dice notation ${JSON.stringify(notation)}: ${why}. Grammar: ${DICE_GRAMMAR}`);
251
- }
252
-
253
- /** Parse without rolling. Throws a plain-English error naming the grammar on anything malformed or over the limits. */
254
- function parseDiceNotation(notation: string): ParsedDice {
255
- if (typeof notation !== 'string') throw diceError(String(notation), 'notation must be a string');
256
- const raw = notation;
257
- const s = notation.replace(/\s+/g, '').toLowerCase();
258
- if (s.length === 0) throw diceError(raw, 'it is empty');
259
- if (s.length > MAX_NOTATION_LENGTH) throw diceError(raw.slice(0, 40) + '…', `it is longer than ${MAX_NOTATION_LENGTH} characters`);
260
-
261
- const parts: Array<{ sign: 1 | -1; body: string }> = [];
262
- let i = 0;
263
- let sign: 1 | -1 = 1;
264
- if (s[0] === '+' || s[0] === '-') { sign = s[0] === '-' ? -1 : 1; i = 1; }
265
- let start = i;
266
- for (; i <= s.length; i++) {
267
- const ch = s[i];
268
- if (i === s.length || ch === '+' || ch === '-') {
269
- const body = s.slice(start, i);
270
- if (body.length === 0) throw diceError(raw, 'there is an empty term (two operators in a row, or a trailing operator)');
271
- parts.push({ sign, body });
272
- if (i < s.length) sign = ch === '-' ? -1 : 1;
273
- start = i + 1;
274
- }
275
- }
276
- if (parts.length > MAX_TERMS) throw diceError(raw, `it has ${parts.length} terms; the limit is ${MAX_TERMS}`);
277
-
278
- let dice = 0;
279
- let hasDice = false;
280
- const terms: DiceTerm[] = parts.map(({ sign: sg, body }) => {
281
- if (/^\d+$/.test(body)) {
282
- const value = Number(body);
283
- if (!Number.isSafeInteger(value)) throw diceError(raw, `the constant ${body} is too large`);
284
- return { sign: sg, text: body, kind: 'constant', count: 0, sides: 0, fudge: false, explode: false, value };
285
- }
286
- const m = TERM_RE.exec(body);
287
- // Fudge is conventionally written with a capital F; show it that way.
288
- const text = body.replace(/df/, 'dF');
289
- if (!m) throw diceError(raw, `the term "${body}" is not NdS with optional ! and kh/kl/dh/dl`);
290
- const count = m[1] === '' ? 1 : Number(m[1]);
291
- const fudge = m[2] === 'f';
292
- const sides = fudge ? 3 : m[2] === '%' ? 100 : Number(m[2]);
293
- const explode = m[3] === '!';
294
- if (count < 1) throw diceError(raw, `"${body}" rolls zero dice`);
295
- if (!fudge && sides < 1) throw diceError(raw, `"${body}" has a zero-sided die`);
296
- if (!fudge && sides > MAX_SIDES) throw diceError(raw, `"${body}" has ${sides} sides; the limit is ${MAX_SIDES}`);
297
- if (explode && fudge) throw diceError(raw, `"${text}": fudge dice cannot explode`);
298
- if (explode && sides === 1) throw diceError(raw, `"${body}": a one-sided die always shows its max, so it would explode forever`);
299
- let keep: DiceKeep | undefined;
300
- if (m[4]) {
301
- const mode = (m[4] === 'k' ? 'kh' : m[4]) as DiceKeep['mode'];
302
- const n = m[5] === undefined ? 1 : Number(m[5]);
303
- if (n < 1) throw diceError(raw, `"${body}" keeps or drops zero dice`);
304
- if (mode === 'kh' || mode === 'kl') {
305
- if (n > count) throw diceError(raw, `"${body}" keeps ${n} of only ${count} dice`);
306
- } else if (n >= count) {
307
- throw diceError(raw, `"${body}" drops ${n} of only ${count} dice, leaving none`);
308
- }
309
- keep = { mode, count: n };
310
- }
311
- dice += count;
312
- hasDice = true;
313
- if (dice > MAX_DICE) throw diceError(raw, `it rolls more than ${MAX_DICE} dice`);
314
- return { sign: sg, text, kind: 'dice', count, sides, fudge, explode, keep, value: 0 };
315
- });
316
- if (!hasDice) throw diceError(raw, 'it has no dice term (a bare number is not a roll)');
317
-
318
- const normalized = terms
319
- .map((t, idx) => (t.sign < 0 ? '-' : idx === 0 ? '' : '+') + t.text)
320
- .join('');
321
- return { notation: normalized, terms, dice };
322
- }
323
-
324
- /** Indices (into `values`) that a keep/drop rule retains. Ties break by position, so it is deterministic. */
325
- function keptIndices(values: readonly number[], keep: DiceKeep | undefined): number[] {
326
- const all = values.map((_, i) => i);
327
- if (!keep) return all;
328
- const asc = all.slice().sort((x, y) => values[x] - values[y] || x - y);
329
- const n = keep.count;
330
- let chosen: number[] = all;
331
- switch (keep.mode) {
332
- case 'kh': chosen = asc.slice(asc.length - Math.min(n, asc.length)); break;
333
- case 'kl': chosen = asc.slice(0, Math.min(n, asc.length)); break;
334
- case 'dh': chosen = asc.slice(0, Math.max(0, asc.length - n)); break;
335
- case 'dl': chosen = asc.slice(Math.min(n, asc.length)); break;
336
- }
337
- return chosen.sort((x, y) => x - y);
338
- }
339
-
340
- /** Roll one parsed dice term: the individual dice (explosions appended after the die that caused them) and the kept ones. */
341
- function rollDiceTerm(term: DiceTerm, rng: Rng): { rolls: number[]; kept: number[] } {
342
- if (term.kind === 'constant') return { rolls: [], kept: [] };
343
- const rolls: number[] = [];
344
- for (let d = 0; d < term.count; d++) {
345
- let face = term.fudge ? rng.int(-1, 1) : rng.int(1, term.sides);
346
- rolls.push(face);
347
- let chain = 0;
348
- while (term.explode && face === term.sides && chain < MAX_EXPLOSIONS_PER_DIE) {
349
- face = rng.int(1, term.sides);
350
- rolls.push(face);
351
- chain++;
352
- }
353
- }
354
- const idx = keptIndices(rolls, term.keep);
355
- return { rolls, kept: idx.map((i) => rolls[i]) };
356
- }
357
-
358
- // Dice notation: NdS, modifiers (+/-), multiple terms (2d6+1d4+3), keep/drop
359
- // (4d6kh3, 2d20kl1, dl/dh), exploding (d6!), fudge (4dF), percentile (d%/d100).
360
- // Refuse >1000 dice or >10000 sides with a plain error. `detail` is human-readable.
361
- type DiceResult = { notation: string; total: number; terms: Array<{ term: string; rolls: number[]; kept: number[]; subtotal: number }>; detail: string };
362
-
363
- function rollDice(notation: string, rng: Rng): DiceResult {
364
- const parsed = parseDiceNotation(notation);
365
- let total = 0;
366
- const pieces: string[] = [];
367
- const terms = parsed.terms.map((t, idx) => {
368
- const signText = t.sign < 0 ? '-' : idx === 0 ? '' : '+';
369
- if (t.kind === 'constant') {
370
- const subtotal = t.sign * t.value;
371
- total += subtotal;
372
- pieces.push(`${signText}${t.value}`);
373
- return { term: `${signText}${t.text}`, rolls: [], kept: [], subtotal };
374
- }
375
- const { rolls, kept } = rollDiceTerm(t, rng);
376
- const sum = kept.reduce((a, b) => a + b, 0);
377
- const subtotal = t.sign * sum;
378
- total += subtotal;
379
- const fmt = (v: number) => (t.fudge ? (v > 0 ? '+' : v < 0 ? '-' : '0') : String(v));
380
- const keptSet = new Set(keptIndices(rolls, t.keep));
381
- const shown = rolls.map((v, i) => (keptSet.has(i) ? fmt(v) : `(${fmt(v)} dropped)`)).join(', ');
382
- pieces.push(`${signText}${t.text} [${shown}] = ${sum}`);
383
- return { term: `${signText}${t.text}`, rolls, kept, subtotal };
384
- });
385
- // `-0` is a legal sum of fudge dice; normalise so JSON shows 0.
386
- if (Object.is(total, -0)) total = 0;
387
- return { notation: parsed.notation, total, terms, detail: `${pieces.join(' ')} → total ${total}` };
388
- }
389
-
390
- // ── sessions: draw without replacement ACROSS calls ─────────────────────────
391
- //
392
- // Backed by the gateway's KV (`injectCacheKv: true` on the manifest row →
393
- // `args._cacheKv`), key `chance:session:<id>`, TTL 24 h. Hosted-only: with no
394
- // KV every function returns SessionUnsupported instead of throwing, and a
395
- // seeded single-call draw still works.
396
- //
397
- // KV is eventually consistent and has no compare-and-swap, so two draws on the
398
- // SAME session racing within the same second can both read the old position.
399
- // Sessions are a convenience for one agent drawing in sequence, not a ledger.
400
-
401
- /** The `{ get, put }` shape mcps/web-fetch uses; Cloudflare's KVNamespace satisfies it. */
402
- interface KvLike {
403
- get(key: string): Promise<string | null>;
404
- put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
405
- }
406
-
407
- type SessionUnsupported = { session_supported: false; hint: string };
408
- type SessionNotFound = { session_supported: true; session_found: false; session_id: string; hint: string };
409
- // A successful DeckSession never carries `session_supported` or `session_found`:
410
- // test `session_supported === false` for "no KV here", `session_found === false`
411
- // for "no such session", and treat anything else as the session.
412
-
413
- type DeckSession = { session_id: string; deck_id: string; deck_version: string; seed: string; order: number[]; position: number; created_at: string; expires_at: string };
414
-
415
- const SESSION_KEY_PREFIX = 'chance:session:';
416
- const SESSION_TTL_SECONDS = 86400;
417
- /** Largest deck a session will hold (the order array is stored whole). */
418
- const MAX_SESSION_DECK_SIZE = 10000;
419
- const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
420
-
421
- const SESSION_UNSUPPORTED_HINT =
422
- 'Deck sessions (drawing without replacement across calls) work only through the Pipeworx gateway; this standalone build has no session support. ' +
423
- 'Single-call draws still work: pass `seed` to make one reproducible, and draw several items in one call to avoid repeats.';
424
-
425
- function unsupported(): SessionUnsupported {
426
- return { session_supported: false, hint: SESSION_UNSUPPORTED_HINT };
427
- }
428
-
429
- function notFound(sessionId: string): SessionNotFound {
430
- return {
431
- session_supported: true,
432
- session_found: false,
433
- session_id: sessionId,
434
- hint: 'No such session: it never existed, or it expired (sessions last 24 hours from their last shuffle). Open a new one.',
435
- };
436
- }
437
-
438
- function isSessionUnsupported(x: unknown): x is SessionUnsupported {
439
- return typeof x === 'object' && x !== null && (x as { session_supported?: unknown }).session_supported === false;
440
- }
441
-
442
- function isSessionNotFound(x: unknown): x is SessionNotFound {
443
- return typeof x === 'object' && x !== null && (x as { session_found?: unknown }).session_found === false;
444
- }
445
-
446
- function usableKv(kv: KvLike | null | undefined): kv is KvLike {
447
- return !!kv && typeof kv.get === 'function' && typeof kv.put === 'function';
448
- }
449
-
450
- function sessionKey(id: string): string {
451
- return SESSION_KEY_PREFIX + id;
452
- }
453
-
454
- function checkSessionId(id: unknown): string {
455
- if (typeof id !== 'string' || !SESSION_ID_RE.test(id)) {
456
- throw new Error(`session_id must be the id a previous call returned (letters, digits, - and _; got ${JSON.stringify(id)}).`);
457
- }
458
- return id;
459
- }
460
-
461
- function newSessionId(): string {
462
- return 'cs_' + randomSeed().slice(0, 24);
463
- }
464
-
465
- async function writeSession(kv: KvLike, s: DeckSession, nowMs: number): Promise<void> {
466
- // KV's floor is 60 s; keep the session's own expiry rather than extending it on every draw.
467
- const ttl = Math.max(60, Math.ceil((Date.parse(s.expires_at) - nowMs) / 1000));
468
- await kv.put(sessionKey(s.session_id), JSON.stringify(s), { expirationTtl: ttl });
469
- }
470
-
471
- async function readSession(kv: KvLike, id: string, nowMs: number): Promise<DeckSession | null> {
472
- const raw = await kv.get(sessionKey(id));
473
- if (!raw) return null;
474
- let s: DeckSession;
475
- try {
476
- s = JSON.parse(raw) as DeckSession;
477
- } catch {
478
- return null;
479
- }
480
- if (!s || !Array.isArray(s.order) || typeof s.position !== 'number') return null;
481
- if (Date.parse(s.expires_at) <= nowMs) return null;
482
- return s;
483
- }
484
-
485
- function shuffledOrder(size: number, rng: Rng): number[] {
486
- const base = Array.from({ length: size }, (_, i) => i);
487
- return rng.shuffle(base);
488
- }
489
-
490
- async function openSession(kv: KvLike | null, deck: { id: string; version: string; size: number }, seed?: string): Promise<DeckSession | SessionUnsupported> {
491
- if (!deck || typeof deck.id !== 'string' || deck.id.length === 0 || typeof deck.version !== 'string') {
492
- throw new Error('openSession: deck needs a string id and version.');
493
- }
494
- if (!Number.isInteger(deck.size) || deck.size < 1 || deck.size > MAX_SESSION_DECK_SIZE) {
495
- throw new Error(`openSession: deck size must be a whole number from 1 to ${MAX_SESSION_DECK_SIZE} (got ${deck.size}).`);
496
- }
497
- if (!usableKv(kv)) return unsupported();
498
- const rng = makeRng(seed);
499
- const now = Date.now();
500
- const session: DeckSession = {
501
- session_id: newSessionId(),
502
- deck_id: deck.id,
503
- deck_version: deck.version,
504
- seed: rng.seed,
505
- order: shuffledOrder(deck.size, rng),
506
- position: 0,
507
- created_at: new Date(now).toISOString(),
508
- expires_at: new Date(now + SESSION_TTL_SECONDS * 1000).toISOString(),
509
- };
510
- await writeSession(kv, session, now);
511
- return session;
512
- }
513
-
514
- async function drawFromSession(kv: KvLike | null, sessionId: string, n: number): Promise<{ indices: number[]; remaining: number; session: DeckSession } | SessionUnsupported | SessionNotFound> {
515
- if (!usableKv(kv)) return unsupported();
516
- const id = checkSessionId(sessionId);
517
- if (!Number.isInteger(n) || n < 1) throw new Error(`draw count must be a whole number ≥ 1 (got ${n}).`);
518
- const now = Date.now();
519
- const s = await readSession(kv, id, now);
520
- if (!s) return notFound(id);
521
- const remaining = s.order.length - s.position;
522
- if (remaining === 0) {
523
- throw new Error(`session ${id} is exhausted: all ${s.order.length} items of deck "${s.deck_id}" have been drawn. Reshuffle it, or open a new session.`);
524
- }
525
- if (n > remaining) {
526
- throw new Error(`session ${id} has only ${remaining} of ${s.order.length} items left; draw at most ${remaining}, or reshuffle.`);
527
- }
528
- const indices = s.order.slice(s.position, s.position + n);
529
- const updated: DeckSession = { ...s, position: s.position + n };
530
- await writeSession(kv, updated, now);
531
- return { indices, remaining: updated.order.length - updated.position, session: updated };
532
- }
533
-
534
- async function reshuffleSession(kv: KvLike | null, sessionId: string, seed?: string): Promise<DeckSession | SessionUnsupported | SessionNotFound> {
535
- if (!usableKv(kv)) return unsupported();
536
- const id = checkSessionId(sessionId);
537
- const now = Date.now();
538
- const s = await readSession(kv, id, now);
539
- if (!s) return notFound(id);
540
- const rng = makeRng(seed);
541
- const updated: DeckSession = {
542
- ...s,
543
- seed: rng.seed,
544
- order: shuffledOrder(s.order.length, rng),
545
- position: 0,
546
- expires_at: new Date(now + SESSION_TTL_SECONDS * 1000).toISOString(),
547
- };
548
- await writeSession(kv, updated, now);
549
- return updated;
550
- }
551
-
552
- // ── response envelope ───────────────────────────────────────────────────────
553
-
554
- // Every pack response carries this envelope so the caller can reproduce and cite.
555
- type ChanceMeta = { seed: string; procedure: string; deck: { id: string; version: string }; session_id?: string; data_as_of?: string; source: string };
556
- /**
557
- * creative-constraints — an original deck of 162 domain-neutral creative
558
- * constraints, drawn by category, seeded for reproducibility, with optional
559
- * no-repeat sessions.
560
- *
561
- * Keyless, offline, no upstream, no COGS. Every card is one imperative
562
- * sentence an agent or a team can apply to ANY task — a product launch, an
563
- * essay, an onboarding flow, a dinner — plus one sentence on why it helps.
564
- * Interpretation is always the caller's: these tools return what was drawn
565
- * and the procedure used, never advice framed as instruction to the caller's
566
- * end user.
567
- *
568
- * This pack is one of six built on the shared `chance` engine
569
- * `openSession` / `drawFromSession`): the same seeded-RNG and KV-backed
570
- * session machinery dice-roller, tarot, i-ching and random-tables
571
- * also use. See docs/chance-packs-plan.md.
572
- *
573
- * Sessions are HOSTED-ONLY (`injectCacheKv: true` on the manifest row gives
574
- * this pack `args._cacheKv`). The published npm package has no KV binding, so
575
- * `draw_constraint` with a `session_id` there (or on any caller without the
576
- * gateway's cache) answers `session_supported: false` and falls back to a
577
- * plain seeded draw — still fully reproducible, just not draw-without-
578
- * replacement ACROSS calls.
579
- */
580
-
581
- import { CARDS, CONSTRAINT_CATEGORIES, DECK_ID, DECK_VERSION, type ConstraintCard, type ConstraintCategory } from './cards.js';
582
-
583
- const SOURCE = `Pipeworx original creative-constraints deck v${DECK_VERSION} — entirely original text, no public-domain or commercial source adapted.`;
584
-
585
- const MAX_DRAW_COUNT = 10;
586
-
587
- const CATEGORY_LIST = CONSTRAINT_CATEGORIES.join(', ');
588
-
589
- function kvFrom(args: Record<string, unknown>): KvLike | null {
590
- const kv = (args as Record<string, unknown>)._cacheKv as KvLike | undefined;
591
- return kv && typeof (kv as { get?: unknown }).get === 'function' && typeof (kv as { put?: unknown }).put === 'function'
592
- ? (kv as KvLike)
593
- : null;
594
- }
595
-
596
- function isConstraintCategory(value: unknown): value is ConstraintCategory {
597
- return typeof value === 'string' && (CONSTRAINT_CATEGORIES as string[]).includes(value);
598
- }
599
-
600
- /** Validate and normalize a `categories` argument. Undefined/empty = no filter (full deck). */
601
- function validateCategories(raw: unknown): ConstraintCategory[] | undefined {
602
- if (raw === undefined || raw === null) return undefined;
603
- if (!Array.isArray(raw) || raw.length === 0) return undefined;
604
- const bad = raw.filter((c) => !isConstraintCategory(c));
605
- if (bad.length > 0) {
606
- throw new Error(
607
- `Unknown categor${bad.length === 1 ? 'y' : 'ies'}: ${bad.map((b) => JSON.stringify(b)).join(', ')}. ` +
608
- `Valid categories are: ${CATEGORY_LIST}.`,
609
- );
610
- }
611
- // De-dupe, keep the deck's own category order so the deck id is stable.
612
- const set = new Set(raw as ConstraintCategory[]);
613
- return CONSTRAINT_CATEGORIES.filter((c) => set.has(c));
614
- }
615
-
616
- function deckIdForCategories(categories: ConstraintCategory[] | undefined): string {
617
- return categories && categories.length > 0 ? `${DECK_ID}:${categories.join(',')}` : `${DECK_ID}:all`;
618
- }
619
-
620
- function categoriesFromDeckId(deckId: string): ConstraintCategory[] | undefined {
621
- const prefix = `${DECK_ID}:`;
622
- if (!deckId.startsWith(prefix)) return undefined;
623
- const suffix = deckId.slice(prefix.length);
624
- if (suffix === 'all' || suffix === '') return undefined;
625
- return suffix.split(',').filter(isConstraintCategory) as ConstraintCategory[];
626
- }
627
-
628
- function filteredDeck(categories: ConstraintCategory[] | undefined): ConstraintCard[] {
629
- if (!categories || categories.length === 0) return CARDS;
630
- const set = new Set(categories);
631
- return CARDS.filter((c) => set.has(c.category));
632
- }
633
-
634
- function formatCard(card: ConstraintCard) {
635
- return {
636
- id: card.id,
637
- category: card.category,
638
- title: card.title,
639
- constraint: card.constraint,
640
- why: card.why,
641
- };
642
- }
643
-
644
- function validateCount(raw: unknown): number {
645
- if (raw === undefined || raw === null) return 3;
646
- const n = Number(raw);
647
- if (!Number.isInteger(n) || n < 1 || n > MAX_DRAW_COUNT) {
648
- throw new Error(`count must be an integer between 1 and ${MAX_DRAW_COUNT} (got ${JSON.stringify(raw)}).`);
649
- }
650
- return n;
651
- }
652
-
653
- const tools: McpToolExport['tools'] = [
654
- {
655
- name: 'draw_constraint',
656
- description:
657
- `Draws from Pipeworx's original 162-card creative-constraints deck (${CATEGORY_LIST}) — each card is one imperative sentence an agent or team can apply to any task (a launch, an essay, an onboarding flow, a dinner), with a one-sentence reason it helps. ` +
658
- 'Interpretation is yours: the tool returns what was drawn and the procedure used, not advice. ' +
659
- 'Optionally restrict to one or more categories. Pass `seed` to reproduce an exact draw later (the same seed, count, and categories always return the same cards); omit it for a fresh random draw — the seed used is always returned. ' +
660
- 'Pass `session_id` from a previous response to continue a no-repeat session (draw-without-replacement across calls, hosted gateway only); when continuing a session, categories cannot be changed mid-session.',
661
- inputSchema: {
662
- type: 'object' as const,
663
- properties: {
664
- count: {
665
- type: 'number',
666
- description: `How many cards to draw, 1-${MAX_DRAW_COUNT}. Default 3.`,
667
- },
668
- categories: {
669
- type: 'array',
670
- items: { type: 'string', enum: CONSTRAINT_CATEGORIES },
671
- description: `Restrict the draw to one or more of: ${CATEGORY_LIST}. Omit (or pass an empty array) for the full 162-card deck.`,
672
- },
673
- seed: {
674
- type: 'string',
675
- description: 'Optional seed for a reproducible draw. The same seed, count and categories always return the same cards. Omit for a fresh random draw; the seed actually used is returned in `meta.seed`.',
676
- },
677
- session_id: {
678
- type: 'string',
679
- description: 'A session_id returned by a previous draw_constraint call, to continue drawing without repeating cards already drawn in that session. Hosted gateway only — answers session_supported:false elsewhere.',
680
- },
681
- },
682
- },
683
- outputSchema: {
684
- type: 'object' as const,
685
- properties: {
686
- cards: {
687
- type: 'array',
688
- items: {
689
- type: 'object',
690
- properties: {
691
- id: { type: 'string' },
692
- category: { type: 'string' },
693
- title: { type: 'string' },
694
- constraint: { type: 'string' },
695
- why: { type: 'string' },
696
- },
697
- },
698
- },
699
- count: { type: 'number' },
700
- remaining: { type: 'number' },
701
- seed: { type: 'string' },
702
- session_supported: { type: 'boolean' },
703
- meta: {
704
- type: 'object',
705
- properties: {
706
- seed: { type: 'string' },
707
- procedure: { type: 'string' },
708
- deck: { type: 'object', properties: { id: { type: 'string' }, version: { type: 'string' } } },
709
- session_id: { type: 'string' },
710
- source: { type: 'string' },
711
- },
712
- },
713
- },
714
- },
715
- },
716
- {
717
- name: 'list_constraint_categories',
718
- description:
719
- "Lists the nine categories in Pipeworx's original creative-constraints deck (perspective, limitation, medium, time, audience, inversion, scale, process, wildcard), each with its card count and one example title — use this to decide what to pass as draw_constraint's `categories` filter.",
720
- inputSchema: {
721
- type: 'object' as const,
722
- properties: {},
723
- },
724
- },
725
- {
726
- name: 'get_constraint',
727
- description:
728
- "Fetches one specific card from Pipeworx's original creative-constraints deck by its id (e.g. \"inversion_03\") — returns its category, title, the constraint sentence and why it helps. Use list_constraint_categories or a draw_constraint result to find real ids.",
729
- inputSchema: {
730
- type: 'object' as const,
731
- properties: {
732
- id: {
733
- type: 'string',
734
- description: 'The card id, e.g. "perspective_01" or "wildcard_12".',
735
- },
736
- },
737
- required: ['id'],
738
- },
739
- },
740
- ];
741
-
742
- async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
743
- switch (name) {
744
- case 'draw_constraint':
745
- return drawConstraint(args);
746
- case 'list_constraint_categories':
747
- return listConstraintCategories();
748
- case 'get_constraint':
749
- return getConstraint(args);
750
- default:
751
- throw new Error(`Unknown tool: ${name}`);
752
- }
753
- }
754
-
755
- async function drawConstraint(args: Record<string, unknown>): Promise<unknown> {
756
- const count = validateCount(args.count);
757
- const sessionId = typeof args.session_id === 'string' && args.session_id.length > 0 ? args.session_id : undefined;
758
- const seed = typeof args.seed === 'string' && args.seed.length > 0 ? args.seed : undefined;
759
- const kv = kvFrom(args);
760
-
761
- if (sessionId) {
762
- if (!kv) {
763
- // No KV binding at all (npm standalone, or a caller without the
764
- // gateway's cache) — fall back to a plain seeded draw rather than
765
- // silently ignoring session_id.
766
- return seedOnlyDraw(validateCategories(args.categories), count, seed, {
767
- hint: 'Sessions require the hosted Pipeworx gateway (no cache binding available here). Falling back to a plain seeded draw.',
768
- });
769
- }
770
- let result;
771
- try {
772
- result = await drawFromSession(kv, sessionId, count);
773
- } catch (err) {
774
- // The engine throws a plain Error when the session is exhausted or the
775
- // draw exceeds what's left; its message already names `remaining`
776
- // (e.g. "has only 3 of 18 items left") — surface it with context rather
777
- // than swallowing it.
778
- throw new Error(`Could not draw ${count} card(s) from session ${JSON.stringify(sessionId)}: ${(err as Error).message}`);
779
- }
780
- if (isSessionUnsupported(result)) {
781
- return seedOnlyDraw(validateCategories(args.categories), count, seed, result);
782
- }
783
- if (isSessionNotFound(result)) {
784
- throw new Error(
785
- `session_id ${JSON.stringify(sessionId)} was not found or has expired (sessions last 24h). ` +
786
- 'Omit session_id to start a new session — the response includes a fresh session_id you can reuse on your next call.',
787
- );
788
- }
789
- const categoriesForSession = categoriesFromDeckId(result.session.deck_id);
790
- const deck = filteredDeck(categoriesForSession);
791
- const cards = result.indices.map((i) => deck[i]).filter((c): c is ConstraintCard => Boolean(c));
792
- return {
793
- cards: cards.map(formatCard),
794
- count: cards.length,
795
- remaining: result.remaining,
796
- seed: result.session.seed,
797
- meta: buildMeta({
798
- seed: result.session.seed,
799
- procedure: `drawn without replacement, continuing session over ${deckDescription(categoriesForSession)}`,
800
- deckId: result.session.deck_id,
801
- sessionId: result.session.session_id,
802
- }),
803
- };
804
- }
805
-
806
- const categories = validateCategories(args.categories);
807
- const deck = filteredDeck(categories);
808
- const deckId = deckIdForCategories(categories);
809
-
810
- if (kv) {
811
- const opened = await openSession(kv, { id: deckId, version: DECK_VERSION, size: deck.length }, seed);
812
- if (isSessionUnsupported(opened)) {
813
- return seedOnlyDraw(categories, count, seed, opened);
814
- }
815
- const session: DeckSession = opened;
816
- let drawn;
817
- try {
818
- drawn = await drawFromSession(kv, session.session_id, count);
819
- } catch (err) {
820
- throw new Error(`Could not draw ${count} card(s) after opening a new session: ${(err as Error).message}`);
821
- }
822
- if (isSessionUnsupported(drawn) || isSessionNotFound(drawn)) {
823
- // Opened fine but the immediate draw failed — fall back rather than
824
- // surface an internal inconsistency to the caller.
825
- return seedOnlyDraw(categories, count, seed, { hint: 'Session draw failed immediately after opening; served a plain seeded draw instead.' });
826
- }
827
- const cards = drawn.indices.map((i) => deck[i]).filter((c): c is ConstraintCard => Boolean(c));
828
- return {
829
- cards: cards.map(formatCard),
830
- count: cards.length,
831
- remaining: drawn.remaining,
832
- seed: drawn.session.seed,
833
- meta: buildMeta({
834
- seed: drawn.session.seed,
835
- procedure: `drawn without replacement, opening a new session over ${deckDescription(categories)}`,
836
- deckId,
837
- sessionId: drawn.session.session_id,
838
- }),
839
- };
840
- }
841
-
842
- return seedOnlyDraw(categories, count, seed);
843
- }
844
-
845
- /** Pure, KV-free seeded draw: used on the npm standalone and whenever the
846
- * hosted session machinery is unavailable or declined. Fully reproducible:
847
- * same seed + count + categories always returns the same cards. */
848
- function seedOnlyDraw(
849
- categories: ConstraintCategory[] | undefined,
850
- count: number,
851
- seed: string | undefined,
852
- sessionNote?: { hint: string },
853
- ): unknown {
854
- const deck = filteredDeck(categories);
855
- const deckId = deckIdForCategories(categories);
856
- const rng = makeRng(seed);
857
- const drawn = drawWithoutReplacement(deck, count, rng);
858
- return {
859
- cards: drawn.map(formatCard),
860
- count: drawn.length,
861
- seed: rng.seed,
862
- ...(sessionNote ? { session_supported: false as const, session_hint: sessionNote.hint } : {}),
863
- meta: buildMeta({
864
- seed: rng.seed,
865
- procedure: `drawn without replacement (single call, no session) over ${deckDescription(categories)}`,
866
- deckId,
867
- }),
868
- };
869
- }
870
-
871
- function deckDescription(categories: ConstraintCategory[] | undefined): string {
872
- return categories && categories.length > 0 ? `the categories [${categories.join(', ')}]` : 'the full 162-card deck';
873
- }
874
-
875
- function buildMeta(opts: { seed: string; procedure: string; deckId: string; sessionId?: string }) {
876
- return {
877
- seed: opts.seed,
878
- procedure: opts.procedure,
879
- deck: { id: opts.deckId, version: DECK_VERSION },
880
- ...(opts.sessionId ? { session_id: opts.sessionId } : {}),
881
- source: SOURCE,
882
- };
883
- }
884
-
885
- function listConstraintCategories(): unknown {
886
- const categories = CONSTRAINT_CATEGORIES.map((category) => {
887
- const cardsInCategory = CARDS.filter((c) => c.category === category);
888
- const example = cardsInCategory[0];
889
- return {
890
- category,
891
- count: cardsInCategory.length,
892
- example: example ? { id: example.id, title: example.title } : null,
893
- };
894
- });
895
- return {
896
- categories,
897
- total_cards: CARDS.length,
898
- source: SOURCE,
899
- };
900
- }
901
-
902
- function getConstraint(args: Record<string, unknown>): unknown {
903
- const id = args.id;
904
- if (typeof id !== 'string' || id.length === 0) {
905
- throw new Error('id is required, e.g. "perspective_01".');
906
- }
907
- const card = CARDS.find((c) => c.id === id);
908
- if (!card) {
909
- throw new Error(
910
- `No card with id ${JSON.stringify(id)}. Ids look like "<category>_NN" (NN 01-18) within: ${CATEGORY_LIST}. ` +
911
- 'Call list_constraint_categories for real examples, or draw_constraint to sample ids.',
912
- );
913
- }
914
- return { card: formatCard(card), source: SOURCE };
915
- }
916
-
917
- export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;