narraeon 0.5.5 → 0.5.7
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/v1.js +25 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/V1Runtime.js +48 -6
- 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 +5 -0
- package/dist/node/runtime/history/HistoryBackground.js +84 -0
- package/dist/node/runtime/history/HistoryQuery.js +7 -2
- package/dist/node/runtime/history/HistoryRendering.js +31 -4
- 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/FileNativeContinuityCorrection.js +2 -0
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +5 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +91 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/PlayCallChain.js +212 -61
- 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 +10 -11
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +12 -59
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
- package/dist/node/runtime/world/FileNativeAuthorityV3.js +7 -4
- package/dist/node/runtime/world/FileNativeHistoryProjection.js +11 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +250 -55
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world/WorldYamlRendering.js +28 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +18 -8
- package/dist/node/shared/default-setting-improvement-prompt.js +4 -0
- package/dist/node/shared/history-use-guidance.js +2 -2
- package/dist/node/shared/ordered-play-prompts.js +2 -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/web/assets/index-DxNtxzLR.js +1255 -0
- package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-DTFZQan4.js +0 -1223
package/README.md
CHANGED
|
@@ -178,7 +178,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
178
178
|
|
|
179
179
|
Project contracts and architecture:
|
|
180
180
|
|
|
181
|
-
- [
|
|
181
|
+
- [GLOSSARY.md](GLOSSARY.md) — domain vocabulary
|
|
182
182
|
- [docs/product-foundation.md](docs/product-foundation.md) — current V1 product contract
|
|
183
183
|
- [docs/adr/](docs/adr/) — architectural decisions
|
|
184
184
|
|
package/README.zh-CN.md
CHANGED
|
@@ -168,7 +168,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
168
168
|
|
|
169
169
|
项目契约与架构文档:
|
|
170
170
|
|
|
171
|
-
- [
|
|
171
|
+
- [GLOSSARY.md](GLOSSARY.md) —— 领域词汇
|
|
172
172
|
- [docs/product-foundation.md](docs/product-foundation.md) —— 当前 V1 产品契约
|
|
173
173
|
- [docs/adr/](docs/adr/) —— 架构决策
|
|
174
174
|
|
|
@@ -7,6 +7,7 @@ export const defaultAppReadingPreferences = {
|
|
|
7
7
|
lineHeight: 1.7,
|
|
8
8
|
letterSpacing: 0.01,
|
|
9
9
|
measure: 48,
|
|
10
|
+
showTechnicalInfo: false,
|
|
10
11
|
};
|
|
11
12
|
export function isAppLocale(value) {
|
|
12
13
|
return appLocales.includes(value);
|
|
@@ -15,12 +16,13 @@ export function isAppReadingPreferences(value) {
|
|
|
15
16
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
16
17
|
return false;
|
|
17
18
|
const candidate = value;
|
|
18
|
-
return (Object.keys(candidate).length ===
|
|
19
|
+
return (Object.keys(candidate).length === 6 &&
|
|
19
20
|
readingDensities.includes(candidate.density) &&
|
|
20
21
|
isNumberInRange(candidate.fontSize, 15, 24) &&
|
|
21
22
|
isNumberInRange(candidate.lineHeight, 1.4, 2.4) &&
|
|
22
23
|
isNumberInRange(candidate.letterSpacing, 0, 0.12) &&
|
|
23
|
-
isNumberInRange(candidate.measure, 32, 72)
|
|
24
|
+
isNumberInRange(candidate.measure, 32, 72) &&
|
|
25
|
+
typeof candidate.showTechnicalInfo === "boolean");
|
|
24
26
|
}
|
|
25
27
|
function isNumberInRange(value, minimum, maximum) {
|
|
26
28
|
return (typeof value === "number" &&
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const playFailureKinds = [
|
|
2
|
+
"outcome_unknown",
|
|
3
|
+
"context_limit",
|
|
4
|
+
"output_limit",
|
|
5
|
+
"context_unavailable",
|
|
6
|
+
"retryable",
|
|
7
|
+
"provider_error",
|
|
8
|
+
"other",
|
|
9
|
+
"configuration_mismatch",
|
|
10
|
+
];
|
|
11
|
+
export function isPlayFailureKind(value) {
|
|
12
|
+
return playFailureKinds.some((kind) => kind === value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isProtagonistTemplateId(value) {
|
|
2
|
+
return (typeof value === "string" &&
|
|
3
|
+
/^template-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value));
|
|
4
|
+
}
|
|
5
|
+
export function isProtagonistTemplateContent(name, body) {
|
|
6
|
+
return (typeof name === "string" &&
|
|
7
|
+
name.trim().length > 0 &&
|
|
8
|
+
[...name].length <= 80 &&
|
|
9
|
+
!/[\r\n\u0085\u2028\u2029]/u.test(name) &&
|
|
10
|
+
typeof body === "string" &&
|
|
11
|
+
body.trim().length > 0 &&
|
|
12
|
+
new TextEncoder().encode(body).byteLength <= 16 * 1024);
|
|
13
|
+
}
|
package/dist/node/protocol/v1.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAppReadingPreferences, } from "./appPreferences.js";
|
|
1
2
|
import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
|
|
2
3
|
export const v1Protocol = "narraeon.runtime/v1";
|
|
3
4
|
export class V1ProtocolError extends Error {
|
|
@@ -20,11 +21,15 @@ export function parseV1Envelope(value) {
|
|
|
20
21
|
}
|
|
21
22
|
const requiredFields = {
|
|
22
23
|
"preferences.save": {},
|
|
24
|
+
"protagonist-templates.save": { name: "string", body: "string" },
|
|
25
|
+
"protagonist-templates.copy": { id: "string" },
|
|
26
|
+
"protagonist-templates.delete": { id: "string" },
|
|
23
27
|
"model.save": { connection: "object" },
|
|
24
28
|
"model.copy": { connectionId: "string", name: "string" },
|
|
25
29
|
"model.select": { connectionId: "string" },
|
|
26
30
|
"model.delete": { connectionId: "string" },
|
|
27
31
|
"model.models": { provider: "string", baseUrl: "string" },
|
|
32
|
+
"content.create-sample": { locale: "string" },
|
|
28
33
|
"content.scripts.read": { packageId: "string" },
|
|
29
34
|
"content.scripts.set": { packageId: "string", enabled: "boolean" },
|
|
30
35
|
"world.package-scripts.read": { worldId: "string" },
|
|
@@ -82,8 +87,11 @@ const requiredFields = {
|
|
|
82
87
|
packageId: "string",
|
|
83
88
|
model: "object",
|
|
84
89
|
},
|
|
90
|
+
"world.archive.export": { worldId: "string", operationId: "string" },
|
|
91
|
+
"world.archive.import": { operationId: "string", uploadId: "string" },
|
|
85
92
|
"world.read": { worldId: "string" },
|
|
86
93
|
"world.rename": { worldId: "string", name: "string" },
|
|
94
|
+
"world.story.export": { worldId: "string", format: "string" },
|
|
87
95
|
"world.delete": { worldId: "string" },
|
|
88
96
|
"artifacts.read": { worldId: "string" },
|
|
89
97
|
"artifacts.debug": { worldId: "string" },
|
|
@@ -204,6 +212,14 @@ function validateRequestFields(request) {
|
|
|
204
212
|
if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
|
|
205
213
|
throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
|
|
206
214
|
}
|
|
215
|
+
if (request.type === "world.story.export" &&
|
|
216
|
+
request.format !== "markdown" &&
|
|
217
|
+
request.format !== "text")
|
|
218
|
+
throw new V1ProtocolError("invalid_request", "world.story.export.format must be markdown or text");
|
|
219
|
+
if (request.type === "content.create-sample" &&
|
|
220
|
+
(Object.keys(request).some((key) => key !== "type" && key !== "locale") ||
|
|
221
|
+
(request.locale !== "en" && request.locale !== "zh-CN")))
|
|
222
|
+
throw new V1ProtocolError("invalid_request", "content.create-sample.locale must be en or zh-CN, with no extra fields");
|
|
207
223
|
if (request.type === "world.extensions.set" &&
|
|
208
224
|
!["on", "off", "default"].includes(String(request.value)))
|
|
209
225
|
throw new V1ProtocolError("invalid_request", "Invalid world extension choice");
|
|
@@ -216,7 +232,7 @@ function validateRequestFields(request) {
|
|
|
216
232
|
request.locale !== "zh-CN")
|
|
217
233
|
throw new V1ProtocolError("invalid_request", "preferences.save.locale must be en or zh-CN");
|
|
218
234
|
if (request.reading !== undefined &&
|
|
219
|
-
!
|
|
235
|
+
!isAppReadingPreferences(request.reading))
|
|
220
236
|
throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
|
|
221
237
|
}
|
|
222
238
|
if (request.type === "content.import") {
|
|
@@ -359,6 +375,10 @@ function validateRequestFields(request) {
|
|
|
359
375
|
const requestTypes = new Set([
|
|
360
376
|
"workspace.read",
|
|
361
377
|
"preferences.read",
|
|
378
|
+
"protagonist-templates.read",
|
|
379
|
+
"protagonist-templates.save",
|
|
380
|
+
"protagonist-templates.copy",
|
|
381
|
+
"protagonist-templates.delete",
|
|
362
382
|
"preferences.save",
|
|
363
383
|
"model.read",
|
|
364
384
|
"model.save",
|
|
@@ -367,6 +387,7 @@ const requestTypes = new Set([
|
|
|
367
387
|
"model.delete",
|
|
368
388
|
"model.models",
|
|
369
389
|
"content.create",
|
|
390
|
+
"content.create-sample",
|
|
370
391
|
"content.scripts.read",
|
|
371
392
|
"content.scripts.set",
|
|
372
393
|
"world.package-scripts.read",
|
|
@@ -401,8 +422,11 @@ const requestTypes = new Set([
|
|
|
401
422
|
"play.workbench.read",
|
|
402
423
|
"prompt.preview",
|
|
403
424
|
"world.create",
|
|
425
|
+
"world.archive.export",
|
|
426
|
+
"world.archive.import",
|
|
404
427
|
"world.read",
|
|
405
428
|
"world.rename",
|
|
429
|
+
"world.story.export",
|
|
406
430
|
"world.delete",
|
|
407
431
|
"artifacts.read",
|
|
408
432
|
"artifacts.debug",
|
|
@@ -447,21 +471,3 @@ const requestTypes = new Set([
|
|
|
447
471
|
function isRecord(value) {
|
|
448
472
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
449
473
|
}
|
|
450
|
-
function isValidReadingPreferences(value) {
|
|
451
|
-
if (!isRecord(value))
|
|
452
|
-
return false;
|
|
453
|
-
return (Object.keys(value).length === 5 &&
|
|
454
|
-
(value.density === "compact" ||
|
|
455
|
-
value.density === "standard" ||
|
|
456
|
-
value.density === "relaxed") &&
|
|
457
|
-
isFiniteRange(value.fontSize, 15, 24) &&
|
|
458
|
-
isFiniteRange(value.lineHeight, 1.4, 2.4) &&
|
|
459
|
-
isFiniteRange(value.letterSpacing, 0, 0.12) &&
|
|
460
|
-
isFiniteRange(value.measure, 32, 72));
|
|
461
|
-
}
|
|
462
|
-
function isFiniteRange(value, minimum, maximum) {
|
|
463
|
-
return (typeof value === "number" &&
|
|
464
|
-
Number.isFinite(value) &&
|
|
465
|
-
value >= minimum &&
|
|
466
|
-
value <= maximum);
|
|
467
|
-
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { WorldArchives } from "./archive/WorldArchives.js";
|
|
2
|
+
import { WorldArchiveError } from "./archive/WorldArchiveZip.js";
|
|
3
|
+
import { FileNativeWorldCreationError } from "./world/FileNativeWorldStore.js";
|
|
1
4
|
import { PackageScriptPermissions } from "./extension/PackageScriptPermissions.js";
|
|
2
5
|
import { comparePromptPrefixes } from "./prompt/WorldPromptDiagnostics.js";
|
|
3
6
|
import { join } from "node:path";
|
|
@@ -8,7 +11,7 @@ import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, }
|
|
|
8
11
|
import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
|
|
9
12
|
import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
|
|
10
13
|
import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
|
|
11
|
-
import {
|
|
14
|
+
import {} from "./model/ModelHost.js";
|
|
12
15
|
import { ModelConnectionStore } from "./model/ModelConnectionStore.js";
|
|
13
16
|
import { FileNativeContinuityCorrection } from "./play/FileNativeContinuityCorrection.js";
|
|
14
17
|
import { fingerprintControl } from "./play/PlayDocumentTools.js";
|
|
@@ -24,12 +27,14 @@ import { SettingImprovementRollbackError, SettingImprovementSession, } from "./s
|
|
|
24
27
|
import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
|
|
25
28
|
import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
|
|
26
29
|
import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
|
|
30
|
+
import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
|
|
27
31
|
import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
|
|
28
32
|
import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
|
|
29
33
|
import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
|
|
30
34
|
import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
|
|
31
35
|
import { ConversationChanges } from "./ConversationChanges.js";
|
|
32
36
|
export class V1Runtime {
|
|
37
|
+
worldArchives;
|
|
33
38
|
#conversationChanges = new ConversationChanges();
|
|
34
39
|
#content;
|
|
35
40
|
#playPresets;
|
|
@@ -40,12 +45,14 @@ export class V1Runtime {
|
|
|
40
45
|
#playCallChains;
|
|
41
46
|
#failureLog;
|
|
42
47
|
#preferences;
|
|
48
|
+
#protagonistTemplates;
|
|
43
49
|
#settingImprovements;
|
|
44
50
|
#worldRevisions;
|
|
45
51
|
#compiler = new FileNativePromptCompiler();
|
|
46
52
|
#locale = defaultAppLocale;
|
|
47
53
|
constructor(input) {
|
|
48
54
|
this.#preferences = new AppPreferencesStore(input.configRoot);
|
|
55
|
+
this.#protagonistTemplates = new ProtagonistTemplateStore(input.configRoot);
|
|
49
56
|
this.#content = new ContentWorkspace(input.dataRoot, {
|
|
50
57
|
locale: () => this.#locale,
|
|
51
58
|
});
|
|
@@ -59,6 +66,11 @@ export class V1Runtime {
|
|
|
59
66
|
? undefined
|
|
60
67
|
: new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
|
|
61
68
|
this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
|
|
69
|
+
this.worldArchives = new WorldArchives({
|
|
70
|
+
dataRoot: input.dataRoot,
|
|
71
|
+
worlds: this.#worlds,
|
|
72
|
+
chains: this.#playCallChains,
|
|
73
|
+
});
|
|
62
74
|
this.#models = new ModelConnectionStore(input.configRoot);
|
|
63
75
|
this.#corrections = new FileNativeContinuityCorrection(this.#worlds, {
|
|
64
76
|
compiler: this.#compiler,
|
|
@@ -116,6 +128,7 @@ export class V1Runtime {
|
|
|
116
128
|
controlFingerprint: fingerprintControl(Object.fromEntries(snapshot.files.map(({ path, contents }) => [path, contents]))),
|
|
117
129
|
documentSnapshot: snapshot,
|
|
118
130
|
history: structuredClone(binding.history),
|
|
131
|
+
historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
|
|
119
132
|
narrativeCheckpoint: binding.narrativeCheckpoint,
|
|
120
133
|
...maintenance,
|
|
121
134
|
additionalMaterials: structuredClone(binding.additionalMaterials),
|
|
@@ -132,16 +145,28 @@ export class V1Runtime {
|
|
|
132
145
|
this.#locale = (await this.#preferences.view()).locale;
|
|
133
146
|
this.#compiler.setLocale(this.#locale);
|
|
134
147
|
await this.#playPresets.initialize();
|
|
148
|
+
await this.worldArchives.initialize();
|
|
135
149
|
}
|
|
136
150
|
async handle(request, playCallChainObserver) {
|
|
137
151
|
try {
|
|
138
152
|
const result = await this.#dispatch(request, playCallChainObserver);
|
|
139
153
|
return { protocol: v1Protocol, result };
|
|
140
154
|
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof WorldArchiveError)
|
|
157
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
158
|
+
if (request.type === "world.archive.export" &&
|
|
159
|
+
error instanceof FileNativeWorldCreationError &&
|
|
160
|
+
(error.code === "operation_conflict" ||
|
|
161
|
+
error.code === "inconsistent_materialization"))
|
|
162
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
141
165
|
finally {
|
|
142
166
|
// Explicit commands may partially settle before failing. Readers recover
|
|
143
167
|
// through the same Runtime projection, never infer success from this signal.
|
|
144
|
-
if (
|
|
168
|
+
if (request.type !== "world.story.export" &&
|
|
169
|
+
!/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
|
|
145
170
|
if ("packageId" in request)
|
|
146
171
|
this.#conversationChanges.publish(`setting:${request.packageId}`);
|
|
147
172
|
if ("worldId" in request) {
|
|
@@ -212,6 +237,14 @@ export class V1Runtime {
|
|
|
212
237
|
await this.#playPresets.syncBuiltinDefaultLocale();
|
|
213
238
|
return preferences;
|
|
214
239
|
}
|
|
240
|
+
case "protagonist-templates.read":
|
|
241
|
+
return this.#protagonistTemplates.view();
|
|
242
|
+
case "protagonist-templates.save":
|
|
243
|
+
return this.#protagonistTemplates.save(request);
|
|
244
|
+
case "protagonist-templates.copy":
|
|
245
|
+
return this.#protagonistTemplates.copy(request.id);
|
|
246
|
+
case "protagonist-templates.delete":
|
|
247
|
+
return this.#protagonistTemplates.delete(request.id);
|
|
215
248
|
case "model.read":
|
|
216
249
|
return this.#models.view();
|
|
217
250
|
case "model.save":
|
|
@@ -226,6 +259,8 @@ export class V1Runtime {
|
|
|
226
259
|
return this.#models.listModels(request);
|
|
227
260
|
case "content.create":
|
|
228
261
|
return this.#content.createCurrentTreeContentPackage();
|
|
262
|
+
case "content.create-sample":
|
|
263
|
+
return this.#content.createSampleContentPackage(request.locale);
|
|
229
264
|
case "content.scripts.read":
|
|
230
265
|
return this.#content.packageScriptPermissions(request.packageId);
|
|
231
266
|
case "content.scripts.set":
|
|
@@ -401,12 +436,18 @@ export class V1Runtime {
|
|
|
401
436
|
return this.#worlds.getCreationOutcome(request.operationId);
|
|
402
437
|
case "world.rename":
|
|
403
438
|
return this.#worlds.renameWorld(request.worldId, request.name);
|
|
439
|
+
case "world.story.export":
|
|
440
|
+
return this.#worlds.exportStory(request.worldId, request.format, this.#locale);
|
|
404
441
|
case "world.delete":
|
|
405
442
|
return playCall(async () => {
|
|
406
443
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
407
444
|
this.#playCallChains.forgetWorld(request.worldId);
|
|
408
445
|
return this.#worlds.deleteWorld(request.worldId);
|
|
409
446
|
});
|
|
447
|
+
case "world.archive.export":
|
|
448
|
+
return this.worldArchives.exportWorld(request);
|
|
449
|
+
case "world.archive.import":
|
|
450
|
+
return this.worldArchives.importWorld(request);
|
|
410
451
|
case "world.read": {
|
|
411
452
|
const head = await this.#worlds.currentHead(request.worldId);
|
|
412
453
|
const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
|
|
@@ -434,6 +475,7 @@ export class V1Runtime {
|
|
|
434
475
|
playCallChain: null,
|
|
435
476
|
playTimeline,
|
|
436
477
|
worldRevision: revisionEpoch,
|
|
478
|
+
archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
|
|
437
479
|
};
|
|
438
480
|
}
|
|
439
481
|
case "world.surface.read":
|
|
@@ -541,7 +583,7 @@ export class V1Runtime {
|
|
|
541
583
|
hostPresetId: preset.id,
|
|
542
584
|
});
|
|
543
585
|
await this.#reconcileResultArtifacts(result);
|
|
544
|
-
return result;
|
|
586
|
+
return { outcome: result.outcome, world: result.world };
|
|
545
587
|
}
|
|
546
588
|
case "play.chain.revise-player": {
|
|
547
589
|
const base = {
|
|
@@ -633,8 +675,7 @@ export class V1Runtime {
|
|
|
633
675
|
const frozenBinding = persisted?.value.modelBinding;
|
|
634
676
|
const continueContext = currentContext !== null &&
|
|
635
677
|
!currentContext.stale &&
|
|
636
|
-
frozenBinding
|
|
637
|
-
equalModelHostBinding(frozenBinding, modelBinding);
|
|
678
|
+
frozenBinding?.provider === modelBinding.provider;
|
|
638
679
|
const baseline = continueContext
|
|
639
680
|
? await this.#worlds.bindPlayCallChainAt(request.worldId, currentContext.baselineHead)
|
|
640
681
|
: binding;
|
|
@@ -653,6 +694,7 @@ export class V1Runtime {
|
|
|
653
694
|
}),
|
|
654
695
|
additionalMaterials: structuredClone(binding.additionalMaterials),
|
|
655
696
|
history: structuredClone(binding.history),
|
|
697
|
+
historyBackgrounds: structuredClone(binding.historyBackgrounds ?? {}),
|
|
656
698
|
narrativeCheckpoint: baseline.narrativeCheckpoint,
|
|
657
699
|
historyAlreadyAppended: Object.keys(binding.history).filter((key) => !(key in baseline.history)),
|
|
658
700
|
...(await this.#worlds.inspectDocumentMaintenance(request.worldId, binding.parentHead)),
|
|
@@ -926,7 +968,7 @@ async function playCall(operation) {
|
|
|
926
968
|
}
|
|
927
969
|
catch (error) {
|
|
928
970
|
if (error instanceof PlayCallChainError)
|
|
929
|
-
throw new V1ProtocolError(
|
|
971
|
+
throw new V1ProtocolError(error.code, error.message, {
|
|
930
972
|
cause: error,
|
|
931
973
|
});
|
|
932
974
|
throw error;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import * as yauzl from "yauzl";
|
|
8
|
+
import * as yazl from "yazl";
|
|
9
|
+
export class WorldArchiveError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "WorldArchiveError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function archivePath(path) {
|
|
18
|
+
if (!path ||
|
|
19
|
+
path.startsWith("/") ||
|
|
20
|
+
Buffer.byteLength(path, "utf8") > 4096 ||
|
|
21
|
+
path.includes("\\") ||
|
|
22
|
+
Array.from(path).some((character) => character.charCodeAt(0) < 32 ||
|
|
23
|
+
character.charCodeAt(0) === 127 ||
|
|
24
|
+
'<>:"|?*'.includes(character)) ||
|
|
25
|
+
path
|
|
26
|
+
.split("/")
|
|
27
|
+
.some((part) => !part ||
|
|
28
|
+
part === "." ||
|
|
29
|
+
part === ".." ||
|
|
30
|
+
/[. ]$/u.test(part) ||
|
|
31
|
+
Buffer.byteLength(part, "utf8") > 255 ||
|
|
32
|
+
/^(?:AUX|CON|NUL|PRN|COM[1-9]|LPT[1-9])$/u.test(part.split(".", 1)[0].toUpperCase())))
|
|
33
|
+
throw new WorldArchiveError("unsafe_path", "The archive contains an unsafe path");
|
|
34
|
+
}
|
|
35
|
+
/** No whole-file buffers: hash, limit and persist in the same backpressured pipeline. */
|
|
36
|
+
export async function streamFile(source, destination, maxBytes) {
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
40
|
+
const counter = new Transform({
|
|
41
|
+
transform(chunk, _encoding, callback) {
|
|
42
|
+
bytes += chunk.length;
|
|
43
|
+
if (bytes > maxBytes)
|
|
44
|
+
callback(new WorldArchiveError("limit_exceeded", "The archive exceeds its size limit"));
|
|
45
|
+
else {
|
|
46
|
+
hash.update(chunk);
|
|
47
|
+
callback(null, chunk);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
await pipeline(source, counter, createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
52
|
+
return { bytes, sha256: hash.digest("hex") };
|
|
53
|
+
}
|
|
54
|
+
export async function inventory(root, prefix = "world") {
|
|
55
|
+
const result = [];
|
|
56
|
+
async function visit(directory, path) {
|
|
57
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
58
|
+
const full = join(directory, entry.name);
|
|
59
|
+
const relative = `${path}/${entry.name}`;
|
|
60
|
+
archivePath(relative);
|
|
61
|
+
if (entry.isDirectory())
|
|
62
|
+
await visit(full, relative);
|
|
63
|
+
else if (entry.isFile()) {
|
|
64
|
+
const hash = createHash("sha256");
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
for await (const chunk of createReadStream(full)) {
|
|
67
|
+
const buffer = chunk;
|
|
68
|
+
if (!Buffer.isBuffer(buffer))
|
|
69
|
+
throw new Error("Archive file stream must be binary");
|
|
70
|
+
bytes += buffer.length;
|
|
71
|
+
hash.update(buffer);
|
|
72
|
+
}
|
|
73
|
+
result.push({ path: relative, bytes, sha256: hash.digest("hex") });
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
throw new WorldArchiveError("unsafe_path", "Only files and directories may be archived");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(root, prefix);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
export async function writeArchive(root, files, destination, limits) {
|
|
83
|
+
const manifestBytes = (await stat(join(root, "narraeon-world.json"))).size;
|
|
84
|
+
if (manifestBytes > limits.entryBytes ||
|
|
85
|
+
files.length + 1 > limits.entries ||
|
|
86
|
+
files.some((file) => file.bytes > limits.entryBytes) ||
|
|
87
|
+
files.reduce((sum, file) => sum + file.bytes, manifestBytes) >
|
|
88
|
+
limits.extractedBytes)
|
|
89
|
+
throw new WorldArchiveError("limit_exceeded", "The world exceeds archive limits");
|
|
90
|
+
const zip = new yazl.ZipFile();
|
|
91
|
+
const output = streamFile(zip.outputStream, destination, limits.archiveBytes);
|
|
92
|
+
zip.on("error", (error) => zip.outputStream.destroy(error));
|
|
93
|
+
zip.addFile(join(root, "narraeon-world.json"), "narraeon-world.json");
|
|
94
|
+
for (const file of files)
|
|
95
|
+
zip.addFile(join(root, file.path), file.path);
|
|
96
|
+
zip.end();
|
|
97
|
+
return (await output).bytes;
|
|
98
|
+
}
|
|
99
|
+
export async function extractArchive(archive, destination, limits) {
|
|
100
|
+
if ((await stat(archive)).size > limits.archiveBytes)
|
|
101
|
+
throw new WorldArchiveError("limit_exceeded", "The ZIP exceeds its size limit");
|
|
102
|
+
let zip;
|
|
103
|
+
try {
|
|
104
|
+
zip = await new Promise((resolve, reject) => yauzl.open(archive, { lazyEntries: true, decodeStrings: false, validateEntrySizes: true }, (error, value) => (error ? reject(error) : resolve(value))));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const files = new Map();
|
|
112
|
+
const paths = new Map();
|
|
113
|
+
let total = 0;
|
|
114
|
+
let count = 0;
|
|
115
|
+
const register = (path, directory, explicit) => {
|
|
116
|
+
const key = path.normalize("NFKC").toLowerCase();
|
|
117
|
+
const previous = paths.get(key);
|
|
118
|
+
if (previous &&
|
|
119
|
+
(previous.path !== path ||
|
|
120
|
+
previous.directory !== directory ||
|
|
121
|
+
(explicit && previous.explicit)))
|
|
122
|
+
throw new WorldArchiveError("unsafe_path", "Duplicate or conflicting archive paths");
|
|
123
|
+
paths.set(key, {
|
|
124
|
+
path,
|
|
125
|
+
directory,
|
|
126
|
+
explicit: explicit || previous?.explicit === true,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (zip.entryCount > limits.entries)
|
|
131
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
zip.on("error", reject);
|
|
134
|
+
zip.on("end", resolve);
|
|
135
|
+
zip.on("entry", (entry) => {
|
|
136
|
+
void (async () => {
|
|
137
|
+
if (++count > limits.entries)
|
|
138
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(entry.fileNameRaw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new WorldArchiveError("unsafe_path", "ZIP paths must be UTF-8");
|
|
145
|
+
}
|
|
146
|
+
const directory = raw.endsWith("/");
|
|
147
|
+
const path = directory ? raw.slice(0, -1) : raw;
|
|
148
|
+
archivePath(path);
|
|
149
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
150
|
+
if ((mode !== 0 && mode !== (directory ? 0o040000 : 0o100000)) ||
|
|
151
|
+
((entry.externalFileAttributes & 0x10) !== 0 && !directory))
|
|
152
|
+
throw new WorldArchiveError("unsafe_path", "Only regular ZIP files and directories are allowed");
|
|
153
|
+
if (entry.isEncrypted() || ![0, 8].includes(entry.compressionMethod))
|
|
154
|
+
throw new WorldArchiveError("invalid_archive", "Unsupported ZIP encoding");
|
|
155
|
+
const segments = path.split("/");
|
|
156
|
+
for (let index = 1; index < segments.length; index++)
|
|
157
|
+
register(segments.slice(0, index).join("/"), true, false);
|
|
158
|
+
register(path, directory, true);
|
|
159
|
+
if (path !== "world" &&
|
|
160
|
+
!path.startsWith("world/") &&
|
|
161
|
+
path !== "narraeon-world.json")
|
|
162
|
+
throw new WorldArchiveError("invalid_archive", "Unexpected archive entry");
|
|
163
|
+
if ((path === "world" && !directory) ||
|
|
164
|
+
(path === "narraeon-world.json" && directory))
|
|
165
|
+
throw new WorldArchiveError("invalid_archive", "Invalid archive layout");
|
|
166
|
+
if (entry.uncompressedSize > limits.entryBytes ||
|
|
167
|
+
total + entry.uncompressedSize > limits.extractedBytes)
|
|
168
|
+
throw new WorldArchiveError("limit_exceeded", "Extracted ZIP exceeds its size limit");
|
|
169
|
+
if (directory) {
|
|
170
|
+
if (entry.uncompressedSize !== 0)
|
|
171
|
+
throw new WorldArchiveError("invalid_archive", "ZIP directory contains data");
|
|
172
|
+
await mkdir(join(destination, path), {
|
|
173
|
+
recursive: true,
|
|
174
|
+
mode: 0o700,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const stream = await new Promise((resolveStream, rejectStream) => zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value)));
|
|
179
|
+
const result = await streamFile(stream, join(destination, path), Math.min(limits.entryBytes, limits.extractedBytes - total));
|
|
180
|
+
if (result.bytes !== entry.uncompressedSize)
|
|
181
|
+
throw new WorldArchiveError("integrity_failed", "ZIP entry size does not match");
|
|
182
|
+
total += result.bytes;
|
|
183
|
+
files.set(path, { path, ...result });
|
|
184
|
+
}
|
|
185
|
+
zip.readEntry();
|
|
186
|
+
})().catch(reject);
|
|
187
|
+
});
|
|
188
|
+
zip.readEntry();
|
|
189
|
+
});
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof WorldArchiveError)
|
|
194
|
+
throw error;
|
|
195
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
196
|
+
cause: error,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
zip.close();
|
|
201
|
+
}
|
|
202
|
+
}
|