@maka/maka-cli 5.191.0 → 5.192.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.191.0",
3
+ "version": "5.192.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -704,7 +704,7 @@ fixed, as is its "kind"):
704
704
 
705
705
  ${JSON_ONLY}
706
706
  [ { "name": string, "description": string, "code"?: string, "openMessage": string,
707
- "refuseMessage": string, "guardsExit"?: string, "footprint"?: string } ]
707
+ "refuseMessage": string, "guardsExit"?: string, "footprint"?: string } ]${chunkEnvelopeNote('devices')}
708
708
  One entry per skeleton device, "name" copied exactly.${repairNote(previousError)}
709
709
  `.trim();
710
710
  }
@@ -753,10 +753,103 @@ amounts, not credentials.
753
753
  ${JSON_ONLY}
754
754
  [ { "name": string, "description": string, "size": string, "category": string,
755
755
  "shape": string, "color": string, "texture": string, "rating": string, "weight": number,
756
- "currencyAmount"?: number, "details"?: string, "transferable"?: boolean } ]
756
+ "currencyAmount"?: number, "details"?: string, "transferable"?: boolean } ]${chunkEnvelopeNote('items')}
757
757
  One entry per skeleton item, "name" and "category" copied exactly.${repairNote(previousError)}
758
758
  `.trim();
759
759
  }
760
+ /**
761
+ * SCHEMA-CONSTRAINED CHUNKS (user ruling 2026-09-14, after
762
+ * po7qSJycJrcfWbgaA put the generator's retry budget under a light).
763
+ *
764
+ * The expensive path in this generator is a REPAIR ROUND, and a good
765
+ * share of them are bought by a reply that was not valid JSON at all --
766
+ * "four of six whole-scene attempts died on Unterminated string in
767
+ * JSON", and 32 validation-errors in a single hour of production
768
+ * captures. A response schema (AI.ask's `jsonSchema` ->
769
+ * output_config.format) makes that fault impossible rather than
770
+ * catchable: the model cannot emit a shape that does not parse.
771
+ *
772
+ * ONLY TWO OF THE FOUR CHUNKS ARE HERE, and the reason is structural
773
+ * rather than a lack of appetite. The API requires every object in the
774
+ * schema to carry `additionalProperties: false`, and:
775
+ *
776
+ * rooms -- carries `npcSpots`, `itemSpots` and `exitSpots`, which are
777
+ * MAPS with arbitrary keys (a room's own spot names). A map
778
+ * cannot both accept unknown keys and forbid them.
779
+ * npcs -- carries `combat`, deliberately open ("optional flavor for
780
+ * defined concepts"), same problem.
781
+ *
782
+ * Constraining those two would mean freezing vocabularies that are
783
+ * meant to be open, which is a worse trade than leaving them on the
784
+ * existing prompt-plus-validator path. devices and items are flat,
785
+ * fully enumerable, and between them carry half the generator's output
786
+ * budget (3000 + 6000 of 18000 detail tokens) and the most entries.
787
+ *
788
+ * WHETHER THE PROVIDER TAKES IT AT ALL IS DISCOVERED, NOT ASSUMED --
789
+ * see AI.schemaSupport. A refusal costs one retried call, once, and
790
+ * then this is skipped for the life of the process.
791
+ */
792
+ export const CHUNK_ENVELOPE_KEY = 'entries';
793
+ /** The schema wants an OBJECT at the root, and these chunks are arrays
794
+ * -- so every schema'd chunk answers as one object with a single
795
+ * array under this key. The prompt says so too (chunkEnvelopeNote), so
796
+ * the shape is the same whether or not the schema actually rode. */
797
+ function arrayEnvelope(entry) {
798
+ return {
799
+ type: 'object',
800
+ properties: { [CHUNK_ENVELOPE_KEY]: { type: 'array', items: entry } },
801
+ required: [CHUNK_ENVELOPE_KEY],
802
+ additionalProperties: false,
803
+ };
804
+ }
805
+ const str = { type: 'string' };
806
+ export const CHUNK_SCHEMAS = {
807
+ devices: arrayEnvelope({
808
+ type: 'object',
809
+ properties: {
810
+ name: str, description: str, code: str,
811
+ openMessage: str, refuseMessage: str,
812
+ guardsExit: str, footprint: str,
813
+ },
814
+ required: ['name', 'description', 'openMessage', 'refuseMessage'],
815
+ additionalProperties: false,
816
+ }),
817
+ items: arrayEnvelope({
818
+ type: 'object',
819
+ properties: {
820
+ name: str, description: str, size: str, category: str,
821
+ shape: str, color: str, texture: str, rating: str,
822
+ weight: { type: 'number' },
823
+ currencyAmount: { type: 'number' },
824
+ details: str,
825
+ transferable: { type: 'boolean' },
826
+ },
827
+ required: ['name', 'description', 'size', 'category', 'shape', 'color', 'texture', 'rating', 'weight'],
828
+ additionalProperties: false,
829
+ }),
830
+ };
831
+ /** The line that tells the model about the envelope above. Only added
832
+ * to chunks that have a schema, so the other two keep asking for the
833
+ * bare array they have always returned. */
834
+ export function chunkEnvelopeNote(label) {
835
+ return CHUNK_SCHEMAS[label]
836
+ ? `\nReturn ONE object with a single key "${CHUNK_ENVELOPE_KEY}" whose value is that array: { "${CHUNK_ENVELOPE_KEY}": [ ... ] }`
837
+ : '';
838
+ }
839
+ /**
840
+ * The array a chunk reply carries, however it was wrapped. Tolerant on
841
+ * purpose: the schema forces the envelope, but the schema is skipped
842
+ * for providers that refuse it, and a bare array is what this
843
+ * generator has always accepted. One reader, both shapes.
844
+ */
845
+ export function unwrapChunk(parsed, label) {
846
+ if (Array.isArray(parsed))
847
+ return parsed;
848
+ const envelope = parsed?.[CHUNK_ENVELOPE_KEY];
849
+ if (Array.isArray(envelope))
850
+ return envelope;
851
+ throw new Error(`The "${label}" chunk came back as neither an array nor a { "${CHUNK_ENVELOPE_KEY}": [...] } object.`);
852
+ }
760
853
  // ========================= Parse / validate / assemble ====================
761
854
  /**
762
855
  * Can anyone at this table unweave a WARD?
@@ -9,7 +9,7 @@ import { GenerationCapture } from '../utilities/generation-capture.js';
9
9
  import { fetchCanonContext } from '../utilities/canon-lore.js';
10
10
  import { providerUnavailable } from '../utilities/draft-failure-note.js';
11
11
  import { SceneSynthesizer } from './scene-factory.js';
12
- import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, VAULT_HOST_RATING, } from './scene-chunks.js';
12
+ import { buildSkeletonPrompt, buildRoomsPrompt, buildNpcsPrompt, buildDevicesPrompt, buildItemsPrompt, pickRunType, parseJsonReply, validateSkeleton, normalizeSkeleton, assembleScene, classifyRepair, CHUNK_SCHEMAS, unwrapChunk, VAULT_HOST_RATING, } from './scene-chunks.js';
13
13
  // The fixer name convention and the player dossier live with the prompt
14
14
  // builders now (see scene-chunks.ts); re-exported so the existing
15
15
  // import sites (call.ts, game.ts) keep working unchanged.
@@ -207,9 +207,26 @@ export class SceneSeedGenerator {
207
207
  /** The skeleton pass: structure only, fast-validated before any detail pass builds on it. */
208
208
  static async requestSkeleton(tier, player, logger, pipeline, previousError, rumor, crew, canonBlock, runType = 'standard') {
209
209
  let lastError = previousError;
210
+ // Decided ONCE, before any prompt is built: the canon block either
211
+ // rides as a cached system prefix or stays interpolated in the
212
+ // prompt, and it must do exactly one of the two.
213
+ const cacheable = AI.cachesPrefix('generation');
210
214
  for (let attempt = 1; attempt <= this.maxCallAttempts; attempt++) {
211
- const prompt = buildSkeletonPrompt(tier, player, lastError, rumor, crew, canonBlock, runType);
212
- const reply = await AI.ask(prompt, { maxTokens: this.SKELETON_TOKENS, timeoutMs: AI.GENERATION_TIMEOUT_MS });
215
+ const prompt = buildSkeletonPrompt(tier, player, lastError, rumor, crew, cacheable ? undefined : canonBlock, runType);
216
+ const reply = await AI.ask(prompt, {
217
+ maxTokens: this.SKELETON_TOKENS,
218
+ timeoutMs: AI.GENERATION_TIMEOUT_MS,
219
+ workload: 'generation',
220
+ // THE CANON BLOCK IS THE ONE PREFIX WORTH CACHING HERE: ~4KB of
221
+ // book excerpts, byte-identical across both call attempts and
222
+ // all three pipeline retries. Sent as a cached system block when
223
+ // the provider supports caching -- and interpolated into the
224
+ // prompt the old way when it does not, which is why the caller
225
+ // decided that BEFORE building the prompt (see requestSkeleton's
226
+ // `cacheable`). Handing a cached prefix to a provider that drops
227
+ // it would take the canon out of the request entirely.
228
+ ...(cacheable && canonBlock ? { cachedPrefix: canonBlock } : {}),
229
+ });
213
230
  try {
214
231
  const skeleton = parseJsonReply(reply);
215
232
  skeleton.devices = skeleton.devices ?? [];
@@ -252,9 +269,19 @@ export class SceneSeedGenerator {
252
269
  const fullPrompt = lastError
253
270
  ? `${prompt}\n\nYour previous attempt failed with this error -- fix it and try again: ${lastError}`
254
271
  : prompt;
255
- const reply = await AI.ask(fullPrompt, { maxTokens, timeoutMs: AI.GENERATION_TIMEOUT_MS });
272
+ const reply = await AI.ask(fullPrompt, {
273
+ maxTokens,
274
+ timeoutMs: AI.GENERATION_TIMEOUT_MS,
275
+ workload: 'generation',
276
+ // Absent for the two chunks whose shapes cannot be schema'd --
277
+ // see CHUNK_SCHEMAS for which, and why.
278
+ ...(CHUNK_SCHEMAS[label] ? { jsonSchema: CHUNK_SCHEMAS[label] } : {}),
279
+ });
256
280
  try {
257
- const parsed = parseJsonReply(reply);
281
+ // Bare array or { entries: [...] } -- the schema forces the
282
+ // envelope where it rode, and a provider that refused one still
283
+ // answers the way it always did.
284
+ const parsed = unwrapChunk(parseJsonReply(reply), label);
258
285
  logger.write(`SceneSeedGenerator: chunk "${label}" ok (attempt ${attempt})`);
259
286
  // Positives matter as much as negatives: capturing only the
260
287
  // failures would leave the detail stages contributing nothing
@@ -28,18 +28,52 @@ function readPersistedKeys() {
28
28
  return {};
29
29
  }
30
30
  }
31
+ /**
32
+ * Per provider, per workload. Only the ANTHROPIC row actually splits --
33
+ * and that is a fact about what this project knows, not a judgement
34
+ * about the others:
35
+ *
36
+ * openai -- gpt-4.1 for both. A cheaper OpenAI model surely exists;
37
+ * naming one I have not priced would be guessing at a
38
+ * billing decision, which is how you end up rotating a
39
+ * working key over a queue backlog. Set
40
+ * MAKA_AI_MODEL_DIALOGUE if you know the one you want.
41
+ * deepseek -- v4-flash IS the cheap tier; there is nothing to split.
42
+ * server -- the relay enforces its own allowlist, so a split here
43
+ * would be a request the server is free to ignore.
44
+ */
31
45
  const DEFAULT_MODELS = {
32
- openai: 'gpt-4.1',
33
- anthropic: 'claude-sonnet-5',
46
+ openai: { dialogue: 'gpt-4.1', generation: 'gpt-4.1' },
47
+ anthropic: {
48
+ // Haiku 4.5 at $1/$5 per MTok against Sonnet 5's $2/$10 -- half
49
+ // price for a line of bar chatter. 200K context rather than 1M,
50
+ // which no dialogue prompt in this game comes close to.
51
+ dialogue: 'claude-haiku-4-5',
52
+ // Deliberately NOT downgraded. See the workload note above: this is
53
+ // the path where a cheaper model bills you twice.
54
+ generation: 'claude-sonnet-5',
55
+ },
34
56
  // DeepSeek is reached over its ANTHROPIC-compatible endpoint (see
35
57
  // HTTPS.askDeepSeek), so a claude-* name would also work -- DeepSeek
36
58
  // remaps it -- but naming the real model keeps the HUD and logs
37
59
  // honest about what actually answered.
38
- deepseek: 'deepseek-v4-flash',
60
+ deepseek: { dialogue: 'deepseek-v4-flash', generation: 'deepseek-v4-flash' },
39
61
  // The relay proxies on the server's own key; the server enforces its
40
62
  // own model allowlist regardless of what rides up.
41
- server: 'claude-sonnet-5',
63
+ server: { dialogue: 'claude-sonnet-5', generation: 'claude-sonnet-5' },
42
64
  };
65
+ /**
66
+ * The model for a provider and workload, honouring the overrides in
67
+ * precedence order: the workload-specific env var, then the global one
68
+ * (kept so an existing MAKA_AI_MODEL keeps meaning exactly what it
69
+ * meant -- pinning every call to one model), then the table.
70
+ */
71
+ export function modelFor(provider, workload) {
72
+ const specific = workload === 'dialogue'
73
+ ? process.env.MAKA_AI_MODEL_DIALOGUE
74
+ : process.env.MAKA_AI_MODEL_GENERATION;
75
+ return specific || process.env.MAKA_AI_MODEL || DEFAULT_MODELS[provider][workload];
76
+ }
43
77
  /**
44
78
  * Single entry point for every AI-backed feature in the app (currently the
45
79
  * sideQuest game's dynamic scene/dialogue generation). Picks a provider
@@ -90,7 +124,7 @@ export class AI {
90
124
  * `maka set:global-config --openai`/`--anthropic`, so a one-off env
91
125
  * var can still override a saved default for a single session.
92
126
  */
93
- static resolveConfig() {
127
+ static resolveConfig(workload = 'dialogue') {
94
128
  const persisted = readPersistedKeys();
95
129
  const explicit = process.env.MAKA_AI_PROVIDER?.toLowerCase();
96
130
  const openaiKey = process.env.OPENAI_API_KEY || persisted.openai;
@@ -140,7 +174,7 @@ export class AI {
140
174
  return {
141
175
  provider,
142
176
  apiKey,
143
- model: process.env.MAKA_AI_MODEL || DEFAULT_MODELS[provider],
177
+ model: modelFor(provider, workload),
144
178
  };
145
179
  }
146
180
  static isConfigured() {
@@ -398,21 +432,106 @@ export class AI {
398
432
  * it explicitly.
399
433
  */
400
434
  static GENERATION_TIMEOUT_MS = 90_000;
435
+ /**
436
+ * TWO OPTIONS THAT ONLY ANTHROPIC GETS, and the gate is deliberate.
437
+ *
438
+ * `cachedPrefix` -- text to send as a cached system block. Caching is
439
+ * a PREFIX match, so this is for the part of a prompt that repeats
440
+ * across calls: the generator's canon-lore block rides all five chunk
441
+ * requests and every pipeline retry unchanged. A cache read bills
442
+ * about a tenth of an input token.
443
+ *
444
+ * `jsonSchema` -- constrains the reply to a schema
445
+ * (output_config.format), so a malformed or truncated JSON answer
446
+ * stops being possible rather than being caught and retried. That is
447
+ * the point: the generator's expensive path is a repair round, and
448
+ * this deletes the fault that drives most of them.
449
+ *
450
+ * WHY ANTHROPIC ONLY. Both are Anthropic request fields. DeepSeek is
451
+ * reached over its Anthropic-COMPATIBLE endpoint, and compatible
452
+ * endpoints routinely implement the message shape and not the extras
453
+ * -- an unknown field may be ignored, or may 400 the whole call, and
454
+ * 400-ing every scene generation to save input tokens is not a trade
455
+ * worth making blind. OpenAI has its own spelling for both
456
+ * (`response_format`, automatic caching) which this has not verified.
457
+ * So they ride where they are known to work, and everywhere else the
458
+ * call goes out exactly as it did before.
459
+ */
460
+ static supportsRequestExtras(provider) {
461
+ return provider === 'anthropic';
462
+ }
463
+ /** Will a prefix handed to `ask` actually be cached for this workload?
464
+ * Callers need this BEFORE building the prompt: a cached prefix that
465
+ * gets dropped for an unsupported provider would take its content
466
+ * out of the request entirely, so the caller has to know whether to
467
+ * interpolate it the old way instead. */
468
+ static cachesPrefix(workload = 'generation') {
469
+ const config = this.resolveConfig(workload);
470
+ return !!config && this.supportsRequestExtras(config.provider);
471
+ }
472
+ /**
473
+ * DOES THIS PROVIDER HONOUR A RESPONSE SCHEMA? Discovered, not
474
+ * assumed -- the same posture as `serverAccess` above, and for the
475
+ * same reason: guessing wrong is not a degraded feature, it is a 400
476
+ * on every scene generation.
477
+ *
478
+ * `output_config.format` is documented for the Anthropic API, but two
479
+ * things can still refuse it in the field -- a compatible endpoint
480
+ * that implements the message shape and none of the extras, and a
481
+ * schema this code wrote that the validator reads more strictly than
482
+ * intended. Both surface identically, as a 400 naming the field. So
483
+ * the first refusal switches it off for the life of the process and
484
+ * the call is retried plainly; every later generation skips it.
485
+ *
486
+ * The cost of being wrong is therefore one retried call, once -- not
487
+ * a broken job pipeline, and not a feature nobody dares turn on.
488
+ */
489
+ static schemaSupport = 'unknown';
490
+ /** Does this failure read as "I don't take schemas"? Matched on the
491
+ * field names, which is what an API naming the offending parameter
492
+ * will say however it words the rest. */
493
+ static SCHEMA_REFUSAL = /output_config|json_schema|output_format|response_format/i;
401
494
  static async ask(prompt, opts) {
402
- const config = this.resolveConfig();
495
+ const config = this.resolveConfig(opts?.workload ?? 'dialogue');
403
496
  if (!config) {
404
497
  throw new Error('No AI provider is configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY, ' +
405
498
  'or log in to maka-cli.com (`maka login`) to use the server relay ' +
406
499
  '(optionally MAKA_AI_PROVIDER to choose explicitly).');
407
500
  }
408
501
  const timeoutMs = opts?.timeoutMs;
409
- const result = config.provider === 'openai'
410
- ? await HTTPS.askGPT({ prompt, openApiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs })
502
+ const extras = this.supportsRequestExtras(config.provider)
503
+ ? {
504
+ cachedPrefix: opts?.cachedPrefix,
505
+ // Dropped once the provider has told us it will not take one.
506
+ ...(this.schemaSupport === 'no' ? {} : { jsonSchema: opts?.jsonSchema }),
507
+ }
508
+ : {};
509
+ const send = (withExtras) => config.provider === 'openai'
510
+ ? HTTPS.askGPT({ prompt, openApiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs })
411
511
  : config.provider === 'anthropic'
412
- ? await HTTPS.askClaude({ prompt, apiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs })
512
+ ? HTTPS.askClaude({ prompt, apiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs, ...withExtras })
413
513
  : config.provider === 'deepseek'
414
- ? await HTTPS.askDeepSeek({ prompt, apiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs })
415
- : await HTTPS.askMakaServer({ prompt, authToken: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs });
514
+ ? HTTPS.askDeepSeek({ prompt, apiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs })
515
+ : HTTPS.askMakaServer({ prompt, authToken: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs });
516
+ let result = await send(extras);
517
+ // THE ONE RETRY THAT DISCOVERY COSTS. A 400 naming the schema field
518
+ // means this provider will not take one -- remember that for the
519
+ // rest of the process and send the call again plainly, so the
520
+ // generation that happened to be first does not pay for the lesson.
521
+ // Deliberately narrow: only a 4xx, only when a schema actually rode
522
+ // the request, and only when the body names the field. A 500 or a
523
+ // rate limit is not evidence about schemas and must keep its own
524
+ // meaning.
525
+ if (result.statusCode >= 400 && result.statusCode < 500
526
+ && 'jsonSchema' in extras && extras.jsonSchema
527
+ && AI.SCHEMA_REFUSAL.test(result.data ?? '')) {
528
+ this.schemaSupport = 'no';
529
+ const { jsonSchema: _dropped, ...rest } = extras;
530
+ result = await send(rest);
531
+ }
532
+ else if (result.statusCode < 400 && 'jsonSchema' in extras && extras.jsonSchema) {
533
+ this.schemaSupport = 'yes';
534
+ }
416
535
  // A BODY THAT IS NOT JSON IS AN HTTP PROBLEM, NOT A PARSE PROBLEM.
417
536
  // This parse used to run bare, AHEAD of the statusCode guard below
418
537
  // -- so that guard only ever protected JSON-shaped failures. When
@@ -287,7 +287,7 @@ export class HTTPS {
287
287
  timeout: timeoutMs
288
288
  });
289
289
  }
290
- static async askClaude({ prompt, apiKey, certPath, model = 'claude-sonnet-5', maxTokens = 4096, timeoutMs }) {
290
+ static async askClaude({ prompt, apiKey, certPath, model = 'claude-sonnet-5', maxTokens = 4096, timeoutMs, cachedPrefix, jsonSchema }) {
291
291
  const headers = {
292
292
  'x-api-key': apiKey,
293
293
  'anthropic-version': '2023-06-01',
@@ -317,6 +317,22 @@ export class HTTPS {
317
317
  // all -- not a concern for the default model here, but worth knowing
318
318
  // if MAKA_AI_MODEL is ever pointed at one of those.
319
319
  thinking: { type: 'disabled' },
320
+ // THE CACHED PREFIX GOES IN `system`, AHEAD OF THE MESSAGES.
321
+ // Caching matches on a prefix and the render order is
322
+ // tools -> system -> messages, so stable text has to sit here for
323
+ // the volatile per-chunk prompt below to be the only thing that
324
+ // changes between calls. Omitted entirely when there is nothing
325
+ // to cache -- an empty system block would be one more byte of
326
+ // prefix for no benefit.
327
+ ...(cachedPrefix
328
+ ? { system: [{ type: 'text', text: cachedPrefix, cache_control: { type: 'ephemeral' } }] }
329
+ : {}),
330
+ // SCHEMA-CONSTRAINED REPLY. The root must be an object carrying
331
+ // `additionalProperties: false` and `required`; the API rejects
332
+ // anything looser, and the caller owns getting that right.
333
+ ...(jsonSchema
334
+ ? { output_config: { format: { type: 'json_schema', schema: jsonSchema } } }
335
+ : {}),
320
336
  messages: [
321
337
  {
322
338
  role: 'user',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.191.0",
3
+ "version": "5.192.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",