narraeon 0.5.6 → 0.6.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/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +40 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +103 -8
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +15 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +177 -57
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +25 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/assets/index-BgBL8G-A.js +1323 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
- package/dist/web/assets/index-CVN48xrw.css +0 -1
|
@@ -4,6 +4,7 @@ import { readFileSync, unlinkSync } from "node:fs";
|
|
|
4
4
|
import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
|
|
6
6
|
import { portableContentTreePathKey } from "../../protocol/contentTree.js";
|
|
7
|
+
import { assertSameGameplayMode, gameplayFile, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
|
|
7
8
|
import { defaultContentWorkspaceLimits, } from "./ContentLimits.js";
|
|
8
9
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
|
|
9
10
|
const localIdPattern = /^package-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
@@ -228,6 +229,29 @@ export class CurrentTreeContentLibrary {
|
|
|
228
229
|
}
|
|
229
230
|
});
|
|
230
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Change only the resolution scheme of a tabletop package. The edit reads the
|
|
234
|
+
* tree under the same lease as ordinary saves, so it publishes atomically and
|
|
235
|
+
* follows the same conflict rules.
|
|
236
|
+
*/
|
|
237
|
+
setGameplayScheme(localId, scheme, options = {}) {
|
|
238
|
+
return this.#mutate(async () => {
|
|
239
|
+
const lease = await this.acquireOperationLease(localId, {
|
|
240
|
+
wait: options.waitForLease !== false,
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
options.signal?.throwIfAborted();
|
|
244
|
+
const current = lease.package;
|
|
245
|
+
if (current.gameplay.mode !== "tabletop")
|
|
246
|
+
throw new InvalidContentTreeError("Only a tabletop content package has a resolution scheme / 只有跑团内容包有判定方案");
|
|
247
|
+
const replacement = gameplayFile({ mode: "tabletop", scheme });
|
|
248
|
+
return await lease.replace(current.files.map((file) => file.path === gameplayPath ? replacement : file), options);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
lease.release();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
231
255
|
async acquireOperationLease(localId, options = {}) {
|
|
232
256
|
this.#packageRoot(localId);
|
|
233
257
|
await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
|
|
@@ -281,6 +305,7 @@ export class CurrentTreeContentLibrary {
|
|
|
281
305
|
throw new CurrentTreeContentPackageBusyError();
|
|
282
306
|
options.signal?.throwIfAborted();
|
|
283
307
|
const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
|
|
308
|
+
assertGameplayModeUnchanged(package_.files, normalized);
|
|
284
309
|
return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
|
|
285
310
|
},
|
|
286
311
|
release: () => {
|
|
@@ -476,7 +501,7 @@ export class CurrentTreeContentLibrary {
|
|
|
476
501
|
}
|
|
477
502
|
function detail(localId, title, files, inspection) {
|
|
478
503
|
return {
|
|
479
|
-
...summary(localId, title, inspection),
|
|
504
|
+
...summary(localId, title, readGameplay(files), inspection),
|
|
480
505
|
files: files.map((file) => ({ ...file })),
|
|
481
506
|
documents: structuredClone(inspection.documents),
|
|
482
507
|
issues: structuredClone(inspection.issues),
|
|
@@ -484,8 +509,9 @@ function detail(localId, title, files, inspection) {
|
|
|
484
509
|
control: structuredClone(inspection.control),
|
|
485
510
|
};
|
|
486
511
|
}
|
|
487
|
-
function summary(localId, title, inspection) {
|
|
512
|
+
function summary(localId, title, gameplay, inspection) {
|
|
488
513
|
return {
|
|
514
|
+
gameplay: { ...gameplay },
|
|
489
515
|
localId,
|
|
490
516
|
title,
|
|
491
517
|
status: inspection.status,
|
|
@@ -495,6 +521,7 @@ function summary(localId, title, inspection) {
|
|
|
495
521
|
}
|
|
496
522
|
function packageSummary(package_) {
|
|
497
523
|
return {
|
|
524
|
+
gameplay: { ...package_.gameplay },
|
|
498
525
|
localId: package_.localId,
|
|
499
526
|
title: package_.title,
|
|
500
527
|
status: package_.status,
|
|
@@ -547,8 +574,22 @@ export function normalizeCurrentContentTreeFiles(files, limits = defaultLimits)
|
|
|
547
574
|
...(file.encoding === undefined ? {} : { encoding: file.encoding }),
|
|
548
575
|
});
|
|
549
576
|
}
|
|
577
|
+
try {
|
|
578
|
+
readGameplay(normalized);
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
throw new InvalidContentTreeError(error instanceof Error ? error.message : "Invalid gameplay identity");
|
|
582
|
+
}
|
|
550
583
|
return normalized.sort((left, right) => compareStrings(left.path, right.path));
|
|
551
584
|
}
|
|
585
|
+
function assertGameplayModeUnchanged(before, after) {
|
|
586
|
+
try {
|
|
587
|
+
assertSameGameplayMode(before, after);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
throw new InvalidContentTreeError(error instanceof Error ? error.message : "Gameplay mode is fixed");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
552
593
|
function decodeCanonicalBase64(contents, path) {
|
|
553
594
|
if (contents.length % 4 !== 0 ||
|
|
554
595
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(contents)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gameplayPath, readGameplay } from "../../protocol/gameplay.js";
|
|
1
2
|
import { readPackageFollowups } from "./PackageFollowups.js";
|
|
2
3
|
import { parseDocument } from "yaml";
|
|
3
4
|
import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
|
|
@@ -45,7 +46,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
45
46
|
}
|
|
46
47
|
const unsupportedPaths = new Set();
|
|
47
48
|
for (const file of snapshotFiles) {
|
|
48
|
-
if (/\.json$/iu.test(file.path)) {
|
|
49
|
+
if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
|
|
49
50
|
unsupportedPaths.add(file.path);
|
|
50
51
|
issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
|
|
51
52
|
}
|
|
@@ -116,6 +117,12 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
116
117
|
issue(issues, "invalid_player_view", "control/player-views.yaml", `${formatProblems.length - maxReportedPlayerViewProblems} more player-view issues were omitted`);
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
try {
|
|
121
|
+
readGameplay(snapshotFiles);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
issue(issues, "invalid_gameplay", gameplayPath, error instanceof Error ? error.message : "Invalid gameplay");
|
|
125
|
+
}
|
|
119
126
|
try {
|
|
120
127
|
readPackageFollowups(snapshotFiles);
|
|
121
128
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function exportStory(title, messages, format, locale) {
|
|
2
|
+
const player = locale === "zh-CN" ? "我" : "Me";
|
|
3
|
+
const separator = locale === "zh-CN" ? ":" : ": ";
|
|
4
|
+
const blocks = messages.map(({ role, text }) => {
|
|
5
|
+
if (role === "narrator")
|
|
6
|
+
return text;
|
|
7
|
+
if (format === "text")
|
|
8
|
+
return `${player}${separator}${text}`;
|
|
9
|
+
return text
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map((line, index) => index === 0 ? `> **${player}**${separator}${line}` : `> ${line}`)
|
|
12
|
+
.join("\n");
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
fileName: storyFileName(title, format),
|
|
16
|
+
content: [format === "markdown" ? `# ${title}` : title, ...blocks].join("\n\n"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function storyFileName(title, format) {
|
|
20
|
+
const extension = format === "markdown" ? ".md" : ".txt";
|
|
21
|
+
let stem = title
|
|
22
|
+
.replace(/[<>:"/\\|?*\p{Cc}]/gu, "")
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/[. ]+$/u, "");
|
|
25
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(stem))
|
|
26
|
+
stem = `_${stem}`;
|
|
27
|
+
const characters = Array.from(stem).slice(0, 80 - extension.length);
|
|
28
|
+
// Also fit filesystems that limit a filename to 255 UTF-8 bytes.
|
|
29
|
+
while (Buffer.byteLength(characters.join("") + extension, "utf8") > 255)
|
|
30
|
+
characters.pop();
|
|
31
|
+
stem = characters.join("").replace(/[. ]+$/u, "");
|
|
32
|
+
return `${stem || "story"}${extension}`;
|
|
33
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
var _a;
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
|
|
3
4
|
import { dirname } from "node:path";
|
|
4
5
|
import { hasCLIProxyThinkingSuffix, modelReasoningPolicyIssue, } from "../../protocol/modelConnections.js";
|
|
5
|
-
import { ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
|
|
6
|
+
import { assertModelOutputComplete, ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
|
|
6
7
|
import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js";
|
|
7
8
|
import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
|
|
8
9
|
import { defaultRuntimeToolDefinitionStrategy, portableRuntimeToolInputSchema, } from "../prompt/FileNativeToolRegistry.js";
|
|
@@ -77,7 +78,31 @@ export class FileNativeModelHost {
|
|
|
77
78
|
.digest("hex"),
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
freezeRequest(request) {
|
|
82
|
+
const frozen = structuredClone(request);
|
|
83
|
+
if (frozen.modelConfig === undefined) {
|
|
84
|
+
const { baseUrl: _baseUrl, apiKey: _apiKey, ...config } = this.#connection;
|
|
85
|
+
void _baseUrl;
|
|
86
|
+
void _apiKey;
|
|
87
|
+
frozen.modelConfig = config;
|
|
88
|
+
}
|
|
89
|
+
return frozen;
|
|
90
|
+
}
|
|
91
|
+
#requestHost(request) {
|
|
92
|
+
if (request.modelConfig === undefined)
|
|
93
|
+
return this;
|
|
94
|
+
if (request.modelConfig.provider !== this.#connection.provider)
|
|
95
|
+
throw new ModelHostBindingMismatchError("The request protocol does not match the selected model host");
|
|
96
|
+
return new _a({
|
|
97
|
+
...request.modelConfig,
|
|
98
|
+
baseUrl: this.#connection.baseUrl,
|
|
99
|
+
apiKey: this.#connection.apiKey,
|
|
100
|
+
}, this.#fetch, this.#failureLog);
|
|
101
|
+
}
|
|
102
|
+
exchange(request, observer) {
|
|
103
|
+
return this.#requestHost(request).#exchange(request, observer);
|
|
104
|
+
}
|
|
105
|
+
async #exchange(request, observer) {
|
|
81
106
|
assertExchangeCompatibility(this.#connection, request);
|
|
82
107
|
await trace("model_host_exchange", modelHostTrace(request));
|
|
83
108
|
const signal = observer?.signal;
|
|
@@ -99,6 +124,9 @@ export class FileNativeModelHost {
|
|
|
99
124
|
}
|
|
100
125
|
/** Uses the production encoders and deliberately omits credentials. */
|
|
101
126
|
previewRequest(request) {
|
|
127
|
+
return this.#requestHost(request).#previewRequest(request);
|
|
128
|
+
}
|
|
129
|
+
#previewRequest(request) {
|
|
102
130
|
assertExchangeCompatibility(this.#connection, request);
|
|
103
131
|
const provider = this.#connection.provider;
|
|
104
132
|
const body = provider === "chat_completions"
|
|
@@ -183,6 +211,7 @@ export class FileNativeModelHost {
|
|
|
183
211
|
await trace("chat_response", payload);
|
|
184
212
|
const message = payload.choices?.[0]?.message;
|
|
185
213
|
const finishReason = payload.choices?.[0]?.finish_reason;
|
|
214
|
+
assertModelOutputComplete(finishReason);
|
|
186
215
|
if (message === undefined)
|
|
187
216
|
throw unknownProviderResponse("Chat Completions");
|
|
188
217
|
if (finishReason !== undefined &&
|
|
@@ -271,6 +300,7 @@ export class FileNativeModelHost {
|
|
|
271
300
|
if (!isRecord(rawPayload) || !Array.isArray(rawPayload.content))
|
|
272
301
|
throw unknownProviderResponse("Anthropic Messages");
|
|
273
302
|
const payload = rawPayload;
|
|
303
|
+
assertModelOutputComplete(payload.stop_reason);
|
|
274
304
|
const content = payload.content;
|
|
275
305
|
if (!validAnthropicContent(content))
|
|
276
306
|
throw unknownProviderResponse("Anthropic Messages");
|
|
@@ -310,6 +340,7 @@ export class FileNativeModelHost {
|
|
|
310
340
|
});
|
|
311
341
|
}
|
|
312
342
|
}
|
|
343
|
+
_a = FileNativeModelHost;
|
|
313
344
|
function assertExchangeCompatibility(connection, request) {
|
|
314
345
|
if (request.bootstrap.provider.protocol !== connection.provider)
|
|
315
346
|
throw new ModelHostBindingMismatchError("The compiled prompt protocol does not match the selected model host");
|
|
@@ -1048,6 +1079,7 @@ async function openAIResponsesRequest(input) {
|
|
|
1048
1079
|
await trace(`${input.tracePrefix}_response`, payload);
|
|
1049
1080
|
if (!isRecord(payload) || !Array.isArray(payload.output))
|
|
1050
1081
|
throw unknownProviderResponse("Responses API");
|
|
1082
|
+
assertModelOutputComplete(responsesStopReason(payload));
|
|
1051
1083
|
const output = cloneResponseOutput(payload.output);
|
|
1052
1084
|
if (!validResponsesOutput(output))
|
|
1053
1085
|
throw unknownProviderResponse("Responses API");
|
|
@@ -33,10 +33,21 @@ export class ModelHostFailureError extends Error {
|
|
|
33
33
|
this.details = options.details;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
36
|
+
/** Reject provider-confirmed truncation before accepting text or tool calls. */
|
|
37
|
+
export function assertModelOutputComplete(stopReason) {
|
|
38
|
+
if (stopReason === "length" ||
|
|
39
|
+
stopReason === "max_tokens" ||
|
|
40
|
+
stopReason === "max_output_tokens" ||
|
|
41
|
+
stopReason === "incomplete:max_output_tokens") {
|
|
42
|
+
throw new ModelHostFailureError("The model response reached the output token limit before completion. Increase maxOutputTokens and start a fresh context; reasoning may also consume the output budget.", { details: { code: "model_output_limit", stopReason } });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Replaying the frozen request cannot recover a context or output limit. */
|
|
37
46
|
export function modelHostFailureRequiresFreshContext(error) {
|
|
38
47
|
if (!(error instanceof ModelHostFailureError))
|
|
39
48
|
return false;
|
|
49
|
+
if (failureDetailStrings(error.details).includes("model_output_limit"))
|
|
50
|
+
return true;
|
|
40
51
|
const evidence = [error.message, ...failureDetailStrings(error.details)]
|
|
41
52
|
.join("\n")
|
|
42
53
|
.toLocaleLowerCase("und");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModelHostFailureError } from "./ModelHost.js";
|
|
1
|
+
import { assertModelOutputComplete, ModelHostFailureError, } from "./ModelHost.js";
|
|
2
2
|
import { providerStreamEvents } from "./ProviderStream.js";
|
|
3
3
|
export async function aggregateChatModelStream(body, onDelta) {
|
|
4
4
|
let content = "";
|
|
@@ -110,6 +110,7 @@ export async function aggregateChatModelStream(body, onDelta) {
|
|
|
110
110
|
}
|
|
111
111
|
if (!completed || !sawPayload)
|
|
112
112
|
throw new Error("Chat SSE ended before a complete response");
|
|
113
|
+
assertModelOutputComplete(stopReason);
|
|
113
114
|
const toolCalls = [...calls.entries()]
|
|
114
115
|
.sort(([left], [right]) => left - right)
|
|
115
116
|
.map(([, call]) => {
|
|
@@ -259,8 +260,6 @@ export async function aggregateAnthropicModelStream(body, onDelta) {
|
|
|
259
260
|
if (block === undefined || block.stopped)
|
|
260
261
|
throw new Error("Anthropic SSE content block stop is invalid");
|
|
261
262
|
block.stopped = true;
|
|
262
|
-
if (block.value.type === "tool_use" && block.partialJson !== "")
|
|
263
|
-
block.value.input = parseStreamJson(block.partialJson);
|
|
264
263
|
break;
|
|
265
264
|
}
|
|
266
265
|
case "message_delta": {
|
|
@@ -289,11 +288,14 @@ export async function aggregateAnthropicModelStream(body, onDelta) {
|
|
|
289
288
|
}
|
|
290
289
|
if (!started || !stopped)
|
|
291
290
|
throw new Error("Anthropic SSE ended before a complete response");
|
|
291
|
+
assertModelOutputComplete(stopReason);
|
|
292
292
|
const content = [...blocks.entries()]
|
|
293
293
|
.sort(([left], [right]) => left - right)
|
|
294
294
|
.map(([, block]) => {
|
|
295
295
|
if (!block.stopped)
|
|
296
296
|
throw new Error("Anthropic SSE content block did not end");
|
|
297
|
+
if (block.value.type === "tool_use" && block.partialJson !== "")
|
|
298
|
+
block.value.input = parseStreamJson(block.partialJson);
|
|
297
299
|
return block.value;
|
|
298
300
|
});
|
|
299
301
|
const text = content
|
|
@@ -364,6 +366,12 @@ export async function aggregateResponsesModelStream(body, onDelta) {
|
|
|
364
366
|
payload.type === "response.incomplete" ||
|
|
365
367
|
payload.type === "response.cancelled" ||
|
|
366
368
|
payload.type === "error") {
|
|
369
|
+
if (payload.type === "response.incomplete" &&
|
|
370
|
+
isRecord(payload.response)) {
|
|
371
|
+
const details = payload.response.incomplete_details;
|
|
372
|
+
if (isRecord(details))
|
|
373
|
+
assertModelOutputComplete(details.reason);
|
|
374
|
+
}
|
|
367
375
|
throw new ModelHostFailureError("Responses SSE returned an explicit failure");
|
|
368
376
|
}
|
|
369
377
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isNarrativeOrigin, isHistoryBackground, } from "../history/HistoryBackground.js";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -57,6 +57,33 @@ export class FileNativePlayAdvanceStore {
|
|
|
57
57
|
recordedAt: durableBase.createdAt,
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Return the advance's secret resolution seed, creating it on first need.
|
|
62
|
+
* Recovery reads the same fact, so every tool call in the advance draws from
|
|
63
|
+
* one persisted stream and a crashed batch cannot resample.
|
|
64
|
+
*/
|
|
65
|
+
async resolutionSeed(base) {
|
|
66
|
+
const durableBase = await this.#assertCurrent(base);
|
|
67
|
+
const path = this.#factPath(base, "resolution-seed.json");
|
|
68
|
+
if ((await readOptionalJson(path)) === null) {
|
|
69
|
+
try {
|
|
70
|
+
await publishImmutableJson(path, {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
stage: "resolution_seed",
|
|
73
|
+
seed: randomBytes(32).toString("hex"),
|
|
74
|
+
recordedAt: durableBase.createdAt,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A concurrent writer may have published first; the stored fact wins.
|
|
79
|
+
if ((await readOptionalJson(path)) === null)
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const fact = await readJson(path);
|
|
84
|
+
assertResolutionSeedFact(fact);
|
|
85
|
+
return fact.seed;
|
|
86
|
+
}
|
|
60
87
|
async markSettled(base, head) {
|
|
61
88
|
const durableBase = await this.#assertCurrent(base);
|
|
62
89
|
await publishImmutableJson(this.#factPath(base, "settled.json"), {
|
|
@@ -115,6 +142,15 @@ function sameAdvanceBase(left, right) {
|
|
|
115
142
|
void rightCreatedAt;
|
|
116
143
|
return isDeepStrictEqual(leftIdentity, rightIdentity);
|
|
117
144
|
}
|
|
145
|
+
function assertResolutionSeedFact(value) {
|
|
146
|
+
if (!isRecord(value) ||
|
|
147
|
+
value.schemaVersion !== 1 ||
|
|
148
|
+
value.stage !== "resolution_seed" ||
|
|
149
|
+
typeof value.seed !== "string" ||
|
|
150
|
+
!/^[a-f0-9]{64}$/.test(value.seed) ||
|
|
151
|
+
typeof value.recordedAt !== "number")
|
|
152
|
+
throw new Error("Invalid persisted resolution seed");
|
|
153
|
+
}
|
|
118
154
|
function assertBase(value) {
|
|
119
155
|
if (!isRecord(value) ||
|
|
120
156
|
value.schemaVersion !== 1 ||
|
|
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
|
|
|
3
3
|
import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
|
|
4
4
|
import { parseFollowupItems } from "./OrderedFollowups.js";
|
|
5
5
|
import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
|
|
6
|
-
import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, } from "./OrderedPlayPrompts.js";
|
|
6
|
+
import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
|
|
7
7
|
import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -203,12 +203,17 @@ export class FileNativePlayPresetStore {
|
|
|
203
203
|
};
|
|
204
204
|
this.#locale = options.locale ?? (() => defaultAppLocale);
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Startup is the only place that upgrades stored presets. Reads never write:
|
|
208
|
+
* list, bind and freeze observe the library as it was last saved.
|
|
209
|
+
*/
|
|
206
210
|
async initialize() {
|
|
207
211
|
await this.#change(async () => {
|
|
208
212
|
const document = await this.#read();
|
|
209
213
|
const migrated = this.#markLegacyBuiltinDefault(document);
|
|
210
214
|
const synchronized = this.#synchronizeBuiltinDefault(document);
|
|
211
|
-
|
|
215
|
+
const tabletop = this.#migrateTabletopPromptBuiltins(document);
|
|
216
|
+
if (migrated || synchronized || tabletop)
|
|
212
217
|
await this.#write(document);
|
|
213
218
|
});
|
|
214
219
|
}
|
|
@@ -452,7 +457,8 @@ export class FileNativePlayPresetStore {
|
|
|
452
457
|
.map((path) => ({ path, contents: binding.files[path] }));
|
|
453
458
|
}
|
|
454
459
|
async importPortable(input) {
|
|
455
|
-
|
|
460
|
+
// A released preset gains the tabletop builtins at import, as at upgrade.
|
|
461
|
+
const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
|
|
456
462
|
const parsed = parsePlayPresetFiles(files, this.#limits);
|
|
457
463
|
if (parsed.kind === "invalid")
|
|
458
464
|
throw parsed.error;
|
|
@@ -561,6 +567,45 @@ export class FileNativePlayPresetStore {
|
|
|
561
567
|
preset.builtinDefault = true;
|
|
562
568
|
return true;
|
|
563
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* One-time upgrade for ordered presets saved before the tabletop builtins
|
|
572
|
+
* existed. An affected revision is replaced by a new revision that inserts
|
|
573
|
+
* them; the old revision stays for frozen requests. A pending draft gets the
|
|
574
|
+
* same insertion, because a draft that kept the old list would fail the
|
|
575
|
+
* builtin-retention rule on save. The library is one atomically written file,
|
|
576
|
+
* so an interrupted upgrade leaves the released bytes and reruns cleanly.
|
|
577
|
+
*/
|
|
578
|
+
#migrateTabletopPromptBuiltins(document) {
|
|
579
|
+
let changed = false;
|
|
580
|
+
for (const stored of document.presets) {
|
|
581
|
+
const current = stored.revisions[stored.currentRevision];
|
|
582
|
+
if (current !== undefined) {
|
|
583
|
+
const migrated = withTabletopPromptBuiltins(current);
|
|
584
|
+
if (migrated !== current) {
|
|
585
|
+
const revision = revisionForFiles(migrated, this.#limits);
|
|
586
|
+
stored.revisions[revision] = cloneFiles(migrated);
|
|
587
|
+
stored.currentRevision = revision;
|
|
588
|
+
changed = true;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const draft = stored.draftRevision === undefined
|
|
592
|
+
? undefined
|
|
593
|
+
: stored.revisions[stored.draftRevision];
|
|
594
|
+
if (draft !== undefined) {
|
|
595
|
+
const migrated = withTabletopPromptBuiltins(draft);
|
|
596
|
+
if (migrated !== draft) {
|
|
597
|
+
const revision = revisionForFiles(migrated, this.#limits);
|
|
598
|
+
stored.revisions[revision] = cloneFiles(migrated);
|
|
599
|
+
if (revision === stored.currentRevision)
|
|
600
|
+
delete stored.draftRevision;
|
|
601
|
+
else
|
|
602
|
+
stored.draftRevision = revision;
|
|
603
|
+
changed = true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return changed;
|
|
608
|
+
}
|
|
564
609
|
#synchronizeBuiltinDefault(document) {
|
|
565
610
|
const preset = document.presets.find(({ builtinDefault }) => builtinDefault === true);
|
|
566
611
|
if (preset === undefined || preset.draftRevision !== undefined)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isResolutionReceipt } from "../../protocol/resolution.js";
|
|
2
|
+
import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
|
|
3
|
+
import { isPlayFailureKind, } from "../../protocol/playFailure.js";
|
|
1
4
|
import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
|
|
2
5
|
import { encodePlayPromptRun, decodePlayPromptRun, } from "./PlayPromptRun.js";
|
|
3
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -71,7 +74,8 @@ export class FileNativePlayTimelineStore {
|
|
|
71
74
|
const eventCount = prefixLength(input.source.events, input.target.events);
|
|
72
75
|
const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
|
|
73
76
|
const authorizationCheckpointCount = prefixLength(input.source.documentAuthorizationCheckpoints ?? [], input.target.documentAuthorizationCheckpoints ?? []);
|
|
74
|
-
if (input.
|
|
77
|
+
if (input.retainCompleteContext ||
|
|
78
|
+
input.target.events.some((event) => event.kind === "assistant" && event.status === "completed")) {
|
|
75
79
|
const encoding = await this.readInitialEncoding(input.sourceWorldId, input.source.chainId).catch(() => null);
|
|
76
80
|
if (encoding !== null)
|
|
77
81
|
await publishImmutableJson(join(targetRoot, "initial-request-encoding.json"), encoding);
|
|
@@ -207,6 +211,9 @@ export class FileNativePlayTimelineStore {
|
|
|
207
211
|
continuationDigest,
|
|
208
212
|
lastRequestAttempt: value.lastRequestAttempt,
|
|
209
213
|
lastFailure: value.lastFailure,
|
|
214
|
+
...(value.lastFailureKind === undefined
|
|
215
|
+
? {}
|
|
216
|
+
: { lastFailureKind: value.lastFailureKind }),
|
|
210
217
|
updatedAt: value.updatedAt,
|
|
211
218
|
};
|
|
212
219
|
await publishJson(join(root, "state.json"), state);
|
|
@@ -235,6 +242,20 @@ export class FileNativePlayTimelineStore {
|
|
|
235
242
|
throw new Error("Play timeline head generation is inconsistent");
|
|
236
243
|
return loaded;
|
|
237
244
|
}
|
|
245
|
+
/** Read only the small active context index, never its transcript or bootstrap. */
|
|
246
|
+
async currentContextRequiresFreshContext(worldId) {
|
|
247
|
+
assertIdentity(worldId, "World ID");
|
|
248
|
+
const head = await this.#readTimelineHead(worldId);
|
|
249
|
+
if (head === null)
|
|
250
|
+
return false;
|
|
251
|
+
const index = await readJson(join(this.#contextRoot(worldId, head.chainId), "index.json"));
|
|
252
|
+
assertContextIndex(index);
|
|
253
|
+
if (index.worldId !== worldId ||
|
|
254
|
+
index.chainId !== head.chainId ||
|
|
255
|
+
index.timelineGeneration !== head.generation)
|
|
256
|
+
throw new Error("Play context durable identity is inconsistent");
|
|
257
|
+
return index.importedFromArchive === true;
|
|
258
|
+
}
|
|
238
259
|
async readContext(worldId, chainId) {
|
|
239
260
|
assertIdentity(worldId, "World ID");
|
|
240
261
|
assertIdentity(chainId, "Call-chain ID");
|
|
@@ -277,6 +298,7 @@ export class FileNativePlayTimelineStore {
|
|
|
277
298
|
chainId,
|
|
278
299
|
previousChainId: indexValue.previousChainId,
|
|
279
300
|
timelineGeneration: indexValue.timelineGeneration,
|
|
301
|
+
...(indexValue.importedFromArchive ? { importedFromArchive: true } : {}),
|
|
280
302
|
...(baseValue.continuityContextId === undefined
|
|
281
303
|
? {}
|
|
282
304
|
: { continuityContextId: baseValue.continuityContextId }),
|
|
@@ -327,6 +349,9 @@ export class FileNativePlayTimelineStore {
|
|
|
327
349
|
lastRequest,
|
|
328
350
|
lastRequestAttempt: stateValue.lastRequestAttempt,
|
|
329
351
|
lastFailure: stateValue.lastFailure,
|
|
352
|
+
...(stateValue.lastFailureKind === undefined
|
|
353
|
+
? {}
|
|
354
|
+
: { lastFailureKind: stateValue.lastFailureKind }),
|
|
330
355
|
updatedAt: stateValue.updatedAt,
|
|
331
356
|
};
|
|
332
357
|
return {
|
|
@@ -445,7 +470,10 @@ export class FileNativePlayTimelineStore {
|
|
|
445
470
|
kind: "event",
|
|
446
471
|
chainId: pointer.chainId,
|
|
447
472
|
current: pointer.chainId === head?.chainId,
|
|
448
|
-
|
|
473
|
+
...(context.index.importedFromArchive
|
|
474
|
+
? { requiresFreshContext: true }
|
|
475
|
+
: {}),
|
|
476
|
+
event: projectLegacyModelBindingFailure(summary),
|
|
449
477
|
});
|
|
450
478
|
pointer = { ...pointer, eventId: pointer.eventId - 1 };
|
|
451
479
|
continue;
|
|
@@ -498,6 +526,9 @@ export class FileNativePlayTimelineStore {
|
|
|
498
526
|
activeStatus: active?.state.status ?? null,
|
|
499
527
|
activeCanRetry: active?.state.canRetry ?? false,
|
|
500
528
|
activeLastFailure: active?.state.lastFailure ?? null,
|
|
529
|
+
...(active?.state.lastFailureKind === undefined
|
|
530
|
+
? {}
|
|
531
|
+
: { activeLastFailureKind: active.state.lastFailureKind }),
|
|
501
532
|
items: reverse.reverse(),
|
|
502
533
|
nextCursor: pointer.chainId === null && pointer.genesisIndex < 0
|
|
503
534
|
? null
|
|
@@ -516,7 +547,7 @@ export class FileNativePlayTimelineStore {
|
|
|
516
547
|
assertPlayEvent(event);
|
|
517
548
|
if (event.id !== eventId)
|
|
518
549
|
throw new Error("Play timeline event identity is inconsistent");
|
|
519
|
-
return normalizePlayEvent(event);
|
|
550
|
+
return projectLegacyModelBindingFailure(normalizePlayEvent(event));
|
|
520
551
|
}
|
|
521
552
|
newGeneration() {
|
|
522
553
|
return `timeline:${randomUUID()}`;
|
|
@@ -597,11 +628,48 @@ export class FileNativePlayTimelineStore {
|
|
|
597
628
|
indexValue.chainId !== chainId ||
|
|
598
629
|
stateValue.chainId !== chainId)
|
|
599
630
|
throw new Error("Play context durable identity is inconsistent");
|
|
631
|
+
// Old versions persisted a configuration preflight failure as a terminal
|
|
632
|
+
// context failure. Recover only the disposition proven by its event prefix.
|
|
633
|
+
// Archive contexts and genuine provider failures retain their restrictions.
|
|
634
|
+
if (!indexValue.importedFromArchive &&
|
|
635
|
+
stateValue.status === "interrupted" &&
|
|
636
|
+
stateValue.lastFailure === legacyModelBindingFailure &&
|
|
637
|
+
(stateValue.lastFailureKind === undefined ||
|
|
638
|
+
stateValue.lastFailureKind === "context_unavailable")) {
|
|
639
|
+
for (let id = stateValue.eventCount; id > 0; id -= 1) {
|
|
640
|
+
const event = await readJson(join(root, "events", numbered(id)));
|
|
641
|
+
assertPlayEvent(event);
|
|
642
|
+
if (event.kind === "followup" ||
|
|
643
|
+
(event.kind === "failure" &&
|
|
644
|
+
event.message === legacyModelBindingFailure))
|
|
645
|
+
continue;
|
|
646
|
+
if (event.kind === "assistant" &&
|
|
647
|
+
event.status === "completed" &&
|
|
648
|
+
event.responseKind === "narrative" &&
|
|
649
|
+
event.continuation === "available" &&
|
|
650
|
+
event.committedHead === stateValue.parentHead) {
|
|
651
|
+
stateValue.status = "ready";
|
|
652
|
+
stateValue.canRetry = false;
|
|
653
|
+
stateValue.lastFailure = null;
|
|
654
|
+
delete stateValue.lastFailureKind;
|
|
655
|
+
}
|
|
656
|
+
else if (event.kind === "failure") {
|
|
657
|
+
stateValue.canRetry = event.failureKind === "retryable";
|
|
658
|
+
stateValue.lastFailure = event.message;
|
|
659
|
+
if (event.failureKind === undefined)
|
|
660
|
+
delete stateValue.lastFailureKind;
|
|
661
|
+
else
|
|
662
|
+
stateValue.lastFailureKind = event.failureKind;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
600
667
|
return { root, index: indexValue, state: stateValue };
|
|
601
668
|
}
|
|
602
669
|
}
|
|
603
670
|
function contextIndex(value) {
|
|
604
671
|
return {
|
|
672
|
+
...(value.importedFromArchive ? { importedFromArchive: true } : {}),
|
|
605
673
|
schemaVersion: 3,
|
|
606
674
|
kind: "play_context_index",
|
|
607
675
|
worldId: value.worldId,
|
|
@@ -724,6 +792,8 @@ function assertContextIndex(value) {
|
|
|
724
792
|
if (!isRecord(value) ||
|
|
725
793
|
value.schemaVersion !== 3 ||
|
|
726
794
|
value.kind !== "play_context_index" ||
|
|
795
|
+
(value.importedFromArchive !== undefined &&
|
|
796
|
+
value.importedFromArchive !== true) ||
|
|
727
797
|
typeof value.worldId !== "string" ||
|
|
728
798
|
typeof value.chainId !== "string" ||
|
|
729
799
|
(value.previousChainId !== null &&
|
|
@@ -769,6 +839,8 @@ function assertContextState(value) {
|
|
|
769
839
|
!/^[a-f0-9]{64}$/u.test(value.continuationDigest) ||
|
|
770
840
|
!validCount(value.lastRequestAttempt) ||
|
|
771
841
|
(value.lastFailure !== null && typeof value.lastFailure !== "string") ||
|
|
842
|
+
(value.lastFailureKind !== undefined &&
|
|
843
|
+
!isPlayFailureKind(value.lastFailureKind)) ||
|
|
772
844
|
typeof value.updatedAt !== "number")
|
|
773
845
|
throw new Error("Play context state has an invalid shape");
|
|
774
846
|
}
|
|
@@ -839,6 +911,8 @@ function isReleasedPlayContext(value) {
|
|
|
839
911
|
(value.lastRequest !== null && !isRecord(value.lastRequest)) ||
|
|
840
912
|
!validCount(value.lastRequestAttempt) ||
|
|
841
913
|
(value.lastFailure !== null && typeof value.lastFailure !== "string") ||
|
|
914
|
+
(value.lastFailureKind !== undefined &&
|
|
915
|
+
!isPlayFailureKind(value.lastFailureKind)) ||
|
|
842
916
|
typeof value.updatedAt !== "number")
|
|
843
917
|
return false;
|
|
844
918
|
try {
|
|
@@ -955,6 +1029,9 @@ function assertPlayEvent(value) {
|
|
|
955
1029
|
return;
|
|
956
1030
|
}
|
|
957
1031
|
if (value.kind === "tool_result") {
|
|
1032
|
+
if (value.resolution !== undefined &&
|
|
1033
|
+
!isResolutionReceipt(value.resolution))
|
|
1034
|
+
throw new Error("Invalid resolution receipt");
|
|
958
1035
|
if (typeof value.callId !== "string" ||
|
|
959
1036
|
typeof value.name !== "string" ||
|
|
960
1037
|
typeof value.ok !== "boolean" ||
|
|
@@ -976,7 +1053,10 @@ function assertPlayEvent(value) {
|
|
|
976
1053
|
return;
|
|
977
1054
|
}
|
|
978
1055
|
if ((value.kind !== "failure" && value.kind !== "cancellation") ||
|
|
979
|
-
typeof value.message !== "string"
|
|
1056
|
+
typeof value.message !== "string" ||
|
|
1057
|
+
(value.kind === "failure" &&
|
|
1058
|
+
value.failureKind !== undefined &&
|
|
1059
|
+
!isPlayFailureKind(value.failureKind)))
|
|
980
1060
|
throw new Error("Play timeline event has an invalid kind");
|
|
981
1061
|
}
|
|
982
1062
|
function assertTimelineEventSummary(value) {
|
|
@@ -1015,6 +1095,9 @@ function assertTimelineEventSummary(value) {
|
|
|
1015
1095
|
return;
|
|
1016
1096
|
}
|
|
1017
1097
|
if (value.kind === "tool_result") {
|
|
1098
|
+
if (value.resolution !== undefined &&
|
|
1099
|
+
!isResolutionReceipt(value.resolution))
|
|
1100
|
+
throw new Error("Invalid resolution receipt");
|
|
1018
1101
|
if (typeof value.callId !== "string" ||
|
|
1019
1102
|
typeof value.name !== "string" ||
|
|
1020
1103
|
typeof value.ok !== "boolean" ||
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const legacyModelBindingFailure = "The selected model connection does not match the frozen model binding; start a fresh context.";
|
|
2
|
+
/** Presentation only: immutable historical events remain unchanged on disk. */
|
|
3
|
+
export function projectLegacyModelBindingFailure(event) {
|
|
4
|
+
if (event.kind === "failure" &&
|
|
5
|
+
event.message === legacyModelBindingFailure &&
|
|
6
|
+
(event.failureKind === undefined ||
|
|
7
|
+
event.failureKind === "context_unavailable"))
|
|
8
|
+
return { ...event, failureKind: "configuration_mismatch" };
|
|
9
|
+
return event;
|
|
10
|
+
}
|