@maka/maka-cli 5.190.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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +95 -2
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +53 -5
- package/bundle/typescript/src/commands/game/sideQuest/game.js +20 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/draft-failure-note.js +25 -1
- package/bundle/typescript/src/tools/ai/ai.class.js +153 -12
- package/bundle/typescript/src/tools/https/https.class.js +30 -9
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
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?
|
|
@@ -7,8 +7,9 @@ import { Logger } from '../utilities/logger.js';
|
|
|
7
7
|
import { clampDeviceKind } from '../utilities/affordances.js';
|
|
8
8
|
import { GenerationCapture } from '../utilities/generation-capture.js';
|
|
9
9
|
import { fetchCanonContext } from '../utilities/canon-lore.js';
|
|
10
|
+
import { providerUnavailable } from '../utilities/draft-failure-note.js';
|
|
10
11
|
import { SceneSynthesizer } from './scene-factory.js';
|
|
11
|
-
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';
|
|
12
13
|
// The fixer name convention and the player dossier live with the prompt
|
|
13
14
|
// builders now (see scene-chunks.ts); re-exported so the existing
|
|
14
15
|
// import sites (call.ts, game.ts) keep working unchanged.
|
|
@@ -169,6 +170,26 @@ export class SceneSeedGenerator {
|
|
|
169
170
|
catch (err) {
|
|
170
171
|
carriedError = err instanceof Error ? err.message : String(err);
|
|
171
172
|
logger.write(`SceneSeedGenerator: pipeline ${pipeline} failed: ${carriedError}`);
|
|
173
|
+
// A PROVIDER THAT IS NOT ANSWERING WILL NOT ANSWER THREE TIMES
|
|
174
|
+
// (po7qSJycJrcfWbgaA, 2026-09-14). The pipeline budget exists for
|
|
175
|
+
// a model that wrote a bad SCENE -- a fresh skeleton is a real
|
|
176
|
+
// second chance at that. It is worth nothing against a provider
|
|
177
|
+
// that never started: DeepSeek refused this run's generation with
|
|
178
|
+
// "unable to start processing your request within the 900-second
|
|
179
|
+
// timeout limit", and the loop dutifully spent that wait twice
|
|
180
|
+
// more. Three-quarters of an hour, for a player standing in the
|
|
181
|
+
// hub, to arrive at the same refusal the first attempt already
|
|
182
|
+
// had in hand.
|
|
183
|
+
//
|
|
184
|
+
// So an unavailable provider ends the generation instead of
|
|
185
|
+
// re-buying it. The message survives in carriedError and reaches
|
|
186
|
+
// the player through draftFailureNote, which tells them it is the
|
|
187
|
+
// provider and not their key -- which is the whole point of
|
|
188
|
+
// stopping early rather than looking flaky for 45 minutes.
|
|
189
|
+
if (providerUnavailable(carriedError)) {
|
|
190
|
+
logger.write(`SceneSeedGenerator: provider is not answering -- abandoning the remaining ${this.maxPipelineAttempts - pipeline} pipeline attempt(s).`);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
172
193
|
}
|
|
173
194
|
}
|
|
174
195
|
logger.write(`SceneSeedGenerator: giving up (${carriedError ?? 'unknown'}) -- falling back to a static scene`);
|
|
@@ -186,9 +207,26 @@ export class SceneSeedGenerator {
|
|
|
186
207
|
/** The skeleton pass: structure only, fast-validated before any detail pass builds on it. */
|
|
187
208
|
static async requestSkeleton(tier, player, logger, pipeline, previousError, rumor, crew, canonBlock, runType = 'standard') {
|
|
188
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');
|
|
189
214
|
for (let attempt = 1; attempt <= this.maxCallAttempts; attempt++) {
|
|
190
|
-
const prompt = buildSkeletonPrompt(tier, player, lastError, rumor, crew, canonBlock, runType);
|
|
191
|
-
const reply = await AI.ask(prompt, {
|
|
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
|
+
});
|
|
192
230
|
try {
|
|
193
231
|
const skeleton = parseJsonReply(reply);
|
|
194
232
|
skeleton.devices = skeleton.devices ?? [];
|
|
@@ -231,9 +269,19 @@ export class SceneSeedGenerator {
|
|
|
231
269
|
const fullPrompt = lastError
|
|
232
270
|
? `${prompt}\n\nYour previous attempt failed with this error -- fix it and try again: ${lastError}`
|
|
233
271
|
: prompt;
|
|
234
|
-
const reply = await AI.ask(fullPrompt, {
|
|
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
|
+
});
|
|
235
280
|
try {
|
|
236
|
-
|
|
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);
|
|
237
285
|
logger.write(`SceneSeedGenerator: chunk "${label}" ok (attempt ${attempt})`);
|
|
238
286
|
// Positives matter as much as negatives: capturing only the
|
|
239
287
|
// failures would leave the detail stages contributing nothing
|
|
@@ -191,7 +191,7 @@ import { clockTime, worldNow } from './utilities/world-clock.js';
|
|
|
191
191
|
import { CommandFactory } from './factories/command-factory.js';
|
|
192
192
|
import { SceneSynthesizer } from './factories/scene-factory.js';
|
|
193
193
|
import { SceneSeedGenerator, FIXER_NAME, isFixerName } from './factories/scene-seed-generator.js';
|
|
194
|
-
import { draftFailureNote } from './utilities/draft-failure-note.js';
|
|
194
|
+
import { draftFailureNote, providerUnavailable } from './utilities/draft-failure-note.js';
|
|
195
195
|
import { CALL_ICON, CALL_COLOR, END_CALL_SENTINEL } from './utilities/comm-style.js';
|
|
196
196
|
import { AI } from '../../../tools/ai/ai.class.js';
|
|
197
197
|
import { BULLET } from './utilities/log-style.js';
|
|
@@ -4646,8 +4646,25 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
4646
4646
|
// caller but the ask-for-data-work door passes.
|
|
4647
4647
|
const nextSeed = await SceneSeedGenerator.generate(this._logFilePath, nextTier, this.player, this.activeRumor, localCrew, undefined, runType);
|
|
4648
4648
|
if (!nextSeed) {
|
|
4649
|
-
const
|
|
4650
|
-
|
|
4649
|
+
const why = SceneSeedGenerator.lastFailure ?? '';
|
|
4650
|
+
const opsNote = draftFailureNote(why);
|
|
4651
|
+
// DO NOT INVENT A CLIENT WHO GOT COLD FEET (po7qSJycJrcfWbgaA).
|
|
4652
|
+
// That line is fine for a draft that came back malformed -- the
|
|
4653
|
+
// fiction has to say something, and "the deal soured" is as true
|
|
4654
|
+
// as anything. It is a LIE when the provider never answered: it
|
|
4655
|
+
// names a cause inside the story for something that happened
|
|
4656
|
+
// entirely outside it, and the advice it gives ("call me in a
|
|
4657
|
+
// bit") walks the player straight back into the same wall. This
|
|
4658
|
+
// codebase refuses invented causes everywhere else -- a fixer may
|
|
4659
|
+
// not name a venue it does not know, an NPC may not name a runner
|
|
4660
|
+
// who is not there -- and the rule holds here too.
|
|
4661
|
+
//
|
|
4662
|
+
// So an outage gets its own line: Krow cannot reach anyone, which
|
|
4663
|
+
// is the honest shape of it in his voice, and the OOC note under
|
|
4664
|
+
// it says the rest.
|
|
4665
|
+
return providerUnavailable(why)
|
|
4666
|
+
? `Incoming call -- ${FIXER_NAME}: "Can't raise anybody tonight, chummer -- whole street's gone quiet on me. Sit tight; I'll call when the lines come back."${opsNote}`
|
|
4667
|
+
: `Incoming call -- ${FIXER_NAME}: "Job fell through, chummer. Client got cold feet before the ink dried. Give me another call in a bit -- I'll shake something loose."${opsNote}`;
|
|
4651
4668
|
}
|
|
4652
4669
|
// HUB sessions: the draft lands as a MESSAGE, never a teleport (real
|
|
4653
4670
|
// playtest note: being yanked into the run the instant generation
|
|
@@ -23,6 +23,30 @@
|
|
|
23
23
|
* codes need word boundaries so they can't match inside some other
|
|
24
24
|
* number.
|
|
25
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* IS THE PROVIDER SIMPLY NOT ANSWERING? Exported because two decisions
|
|
28
|
+
* hang off the same question and must never disagree: what the player is
|
|
29
|
+
* told (below), and whether SceneSeedGenerator bothers with its remaining
|
|
30
|
+
* pipeline attempts (po7qSJycJrcfWbgaA). Two separately-written lists
|
|
31
|
+
* would drift, and the drift would be invisible -- the generator would
|
|
32
|
+
* keep re-buying a fifteen-minute wait while the note said the provider
|
|
33
|
+
* was down.
|
|
34
|
+
*
|
|
35
|
+
* Matched on PROSE, not status codes: only the prose crosses the wire,
|
|
36
|
+
* and each provider words it differently.
|
|
37
|
+
*
|
|
38
|
+
* The 2026-09-14 addition is DeepSeek's own queue refusal -- "We were
|
|
39
|
+
* unable to start processing your request within the 900-second timeout
|
|
40
|
+
* limit. Please try again later." Nothing in the old list came close to
|
|
41
|
+
* it, so a real outage read to the player as "Job fell through, chummer.
|
|
42
|
+
* Client got cold feet" with no note at all, and sent them straight back
|
|
43
|
+
* to call into the same wall. `Request timeout` is this codebase's own
|
|
44
|
+
* transport giving up (https.class.ts), which means the same thing from
|
|
45
|
+
* the other end of the socket.
|
|
46
|
+
*/
|
|
47
|
+
export function providerUnavailable(why) {
|
|
48
|
+
return /overloaded or down|rate-limited by its provider|took too long to answer|could not (?:complete that request|reach its ai provider)|unable to start processing|\b\d{2,4}-second timeout limit|Request timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(why);
|
|
49
|
+
}
|
|
26
50
|
export function draftFailureNote(why) {
|
|
27
51
|
// NAME THE CAUSE WHEN IT'S THE PROVIDER, NOT LUCK (real session: six
|
|
28
52
|
// Krow calls into an out-of-credits key, indistinguishable from flaky
|
|
@@ -50,7 +74,7 @@ export function draftFailureNote(why) {
|
|
|
50
74
|
// answering. Matched on the SITE'S OWN wording (ai-error-envelope.ts
|
|
51
75
|
// classifyUpstream/classifyTransportFailure) rather than status
|
|
52
76
|
// codes, since only the prose crosses the wire.
|
|
53
|
-
const providerOutage = !providerDown &&
|
|
77
|
+
const providerOutage = !providerDown && providerUnavailable(why);
|
|
54
78
|
// A structural failure is OURS, not the player's -- name it as the
|
|
55
79
|
// generator's problem so nobody goes hunting their billing page, and
|
|
56
80
|
// keep it short: the fiction already covers the beat.
|
|
@@ -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:
|
|
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:
|
|
177
|
+
model: modelFor(provider, workload),
|
|
144
178
|
};
|
|
145
179
|
}
|
|
146
180
|
static isConfigured() {
|
|
@@ -377,20 +411,127 @@ export class AI {
|
|
|
377
411
|
* runs 3-5x a dialogue reply, and the old fixed caps truncated it
|
|
378
412
|
* mid-string). Leave unset for ordinary dialogue-sized replies.
|
|
379
413
|
*/
|
|
414
|
+
/**
|
|
415
|
+
* HOW LONG A GENERATION CALL WAITS (po7qSJycJrcfWbgaA, 2026-09-14).
|
|
416
|
+
*
|
|
417
|
+
* DeepSeek answered a run's scene generation with "We were unable to
|
|
418
|
+
* start processing your request within the 900-second timeout limit"
|
|
419
|
+
* -- fifteen minutes of queue before a refusal, and the generator's
|
|
420
|
+
* pipeline budget took that three times over. Forty-five minutes of a
|
|
421
|
+
* player standing in the hub waiting for a call that, when it finally
|
|
422
|
+
* came, said the job fell through.
|
|
423
|
+
*
|
|
424
|
+
* The transport's own default is the module's TIMEOUT (300s), which
|
|
425
|
+
* is a sane ceiling for a big download and far too patient for a
|
|
426
|
+
* prompt somebody is waiting on. A scene chunk that has not started
|
|
427
|
+
* coming back in 90 seconds is not going to save the commission.
|
|
428
|
+
*
|
|
429
|
+
* Deliberately NOT applied to every AI.ask: dialogue calls are short
|
|
430
|
+
* and already bounded by the same default, and an NPC that takes an
|
|
431
|
+
* extra beat is not a player staring at nothing. Generation asks for
|
|
432
|
+
* it explicitly.
|
|
433
|
+
*/
|
|
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;
|
|
380
494
|
static async ask(prompt, opts) {
|
|
381
|
-
const config = this.resolveConfig();
|
|
495
|
+
const config = this.resolveConfig(opts?.workload ?? 'dialogue');
|
|
382
496
|
if (!config) {
|
|
383
497
|
throw new Error('No AI provider is configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY, ' +
|
|
384
498
|
'or log in to maka-cli.com (`maka login`) to use the server relay ' +
|
|
385
499
|
'(optionally MAKA_AI_PROVIDER to choose explicitly).');
|
|
386
500
|
}
|
|
387
|
-
const
|
|
388
|
-
|
|
501
|
+
const timeoutMs = opts?.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 })
|
|
389
511
|
: config.provider === 'anthropic'
|
|
390
|
-
?
|
|
512
|
+
? HTTPS.askClaude({ prompt, apiKey: config.apiKey, model: config.model, maxTokens: opts?.maxTokens, timeoutMs, ...withExtras })
|
|
391
513
|
: config.provider === 'deepseek'
|
|
392
|
-
?
|
|
393
|
-
:
|
|
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
|
+
}
|
|
394
535
|
// A BODY THAT IS NOT JSON IS AN HTTP PROBLEM, NOT A PARSE PROBLEM.
|
|
395
536
|
// This parse used to run bare, AHEAD of the statusCode guard below
|
|
396
537
|
// -- so that guard only ever protected JSON-shaped failures. When
|
|
@@ -258,7 +258,7 @@ export class HTTPS {
|
|
|
258
258
|
req.end();
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
|
-
static async askGPT({ prompt, openApiKey, certPath, model = 'gpt-4.1', maxTokens = 3000 }) {
|
|
261
|
+
static async askGPT({ prompt, openApiKey, certPath, model = 'gpt-4.1', maxTokens = 3000, timeoutMs }) {
|
|
262
262
|
const headers = {
|
|
263
263
|
'Authorization': `Bearer ${openApiKey}`,
|
|
264
264
|
'Content-Type': 'application/json',
|
|
@@ -283,10 +283,11 @@ export class HTTPS {
|
|
|
283
283
|
path: '/v1/chat/completions',
|
|
284
284
|
data,
|
|
285
285
|
headers,
|
|
286
|
-
cert: certPath
|
|
286
|
+
cert: certPath,
|
|
287
|
+
timeout: timeoutMs
|
|
287
288
|
});
|
|
288
289
|
}
|
|
289
|
-
static async askClaude({ prompt, apiKey, certPath, model = 'claude-sonnet-5', maxTokens = 4096 }) {
|
|
290
|
+
static async askClaude({ prompt, apiKey, certPath, model = 'claude-sonnet-5', maxTokens = 4096, timeoutMs, cachedPrefix, jsonSchema }) {
|
|
290
291
|
const headers = {
|
|
291
292
|
'x-api-key': apiKey,
|
|
292
293
|
'anthropic-version': '2023-06-01',
|
|
@@ -316,6 +317,22 @@ export class HTTPS {
|
|
|
316
317
|
// all -- not a concern for the default model here, but worth knowing
|
|
317
318
|
// if MAKA_AI_MODEL is ever pointed at one of those.
|
|
318
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
|
+
: {}),
|
|
319
336
|
messages: [
|
|
320
337
|
{
|
|
321
338
|
role: 'user',
|
|
@@ -328,7 +345,8 @@ export class HTTPS {
|
|
|
328
345
|
path: '/v1/messages',
|
|
329
346
|
data,
|
|
330
347
|
headers,
|
|
331
|
-
cert: certPath
|
|
348
|
+
cert: certPath,
|
|
349
|
+
timeout: timeoutMs
|
|
332
350
|
});
|
|
333
351
|
}
|
|
334
352
|
/**
|
|
@@ -349,7 +367,7 @@ export class HTTPS {
|
|
|
349
367
|
* reasoning tokens that never reach the player. The same trap the
|
|
350
368
|
* Claude path above documents, sprung by a different vendor.
|
|
351
369
|
*/
|
|
352
|
-
static async askDeepSeek({ prompt, apiKey, certPath, model = 'deepseek-v4-flash', maxTokens = 4096 }) {
|
|
370
|
+
static async askDeepSeek({ prompt, apiKey, certPath, model = 'deepseek-v4-flash', maxTokens = 4096, timeoutMs }) {
|
|
353
371
|
return await this.post({
|
|
354
372
|
hostname: 'api.deepseek.com',
|
|
355
373
|
path: '/anthropic/v1/messages',
|
|
@@ -364,7 +382,8 @@ export class HTTPS {
|
|
|
364
382
|
'anthropic-version': '2023-06-01',
|
|
365
383
|
'Content-Type': 'application/json',
|
|
366
384
|
},
|
|
367
|
-
cert: certPath
|
|
385
|
+
cert: certPath,
|
|
386
|
+
timeout: timeoutMs
|
|
368
387
|
});
|
|
369
388
|
}
|
|
370
389
|
/**
|
|
@@ -375,7 +394,7 @@ export class HTTPS {
|
|
|
375
394
|
* resume token, not an API key. Production host only -- the relay is
|
|
376
395
|
* an account nicety, not a dev-loop dependency.
|
|
377
396
|
*/
|
|
378
|
-
static async askMakaServer({ prompt, authToken, model, maxTokens = 4096 }) {
|
|
397
|
+
static async askMakaServer({ prompt, authToken, model, maxTokens = 4096, timeoutMs }) {
|
|
379
398
|
// MAKA_DEV TARGETING (kvhsgn3ouEG2znsha). Every other maka-cli.com
|
|
380
399
|
// transport in this codebase already swings to a local server --
|
|
381
400
|
// backlog, canon-lore, catalog, cloud-saves, session-telemetry,
|
|
@@ -401,8 +420,10 @@ export class HTTPS {
|
|
|
401
420
|
data: { prompt, model, maxTokens },
|
|
402
421
|
headers: { 'x-auth-token': authToken },
|
|
403
422
|
// Generation-length calls ride this too; outlive the server's own
|
|
404
|
-
// 150s upstream timeout so its error body reaches us.
|
|
405
|
-
|
|
423
|
+
// 150s upstream timeout so its error body reaches us. A caller
|
|
424
|
+
// asking for LESS patience still wins -- it is the one that knows
|
|
425
|
+
// whether somebody is waiting on the answer.
|
|
426
|
+
timeout: timeoutMs ?? 180000,
|
|
406
427
|
});
|
|
407
428
|
}
|
|
408
429
|
static async askGPTStreaming(prompt, model = 'gpt-4.1') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
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.",
|