narraeon 0.7.2 → 0.7.4
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/dist/node/protocol/v1.js +8 -2
- package/dist/node/runtime/V1Runtime.js +11 -2
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +34 -2
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +70 -3
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2 -1
- package/dist/node/server/createServer.js +43 -16
- package/dist/web/assets/index-9N9kO8kY.js +1408 -0
- package/dist/web/assets/index-BjxBYYQi.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
package/dist/node/protocol/v1.js
CHANGED
|
@@ -4,10 +4,12 @@ import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
|
|
|
4
4
|
export const v1Protocol = "narraeon.runtime/v1";
|
|
5
5
|
export class V1ProtocolError extends Error {
|
|
6
6
|
code;
|
|
7
|
-
|
|
7
|
+
details;
|
|
8
|
+
constructor(code, message, options = {}) {
|
|
8
9
|
super(message, options);
|
|
9
10
|
this.name = "V1ProtocolError";
|
|
10
11
|
this.code = code;
|
|
12
|
+
this.details = options.details;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
15
|
export function parseV1Envelope(value) {
|
|
@@ -37,7 +39,11 @@ const requiredFields = {
|
|
|
37
39
|
"world.package-scripts.set": { worldId: "string", enabled: "boolean" },
|
|
38
40
|
"content.gameplay.set": { packageId: "string", scheme: "string" },
|
|
39
41
|
"content.read": { packageId: "string" },
|
|
40
|
-
"content.replace": {
|
|
42
|
+
"content.replace": {
|
|
43
|
+
packageId: "string",
|
|
44
|
+
files: "array",
|
|
45
|
+
baseTreeRevision: "string",
|
|
46
|
+
},
|
|
41
47
|
"content.copy": { packageId: "string" },
|
|
42
48
|
"content.delete": { packageId: "string" },
|
|
43
49
|
"content.rename": { packageId: "string", name: "string" },
|
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { maxPortableContentArchiveBase64Characters, maxPortableContentArchiveBytes, } from "../protocol/contentTree.js";
|
|
8
8
|
import { V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
|
|
9
9
|
import { defaultAppLocale, } from "../protocol/appPreferences.js";
|
|
10
|
-
import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
|
|
10
|
+
import { ContentPackageImportError, ContentTreeConflictError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
|
|
11
11
|
import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
|
|
12
12
|
import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
|
|
13
13
|
import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
|
|
@@ -215,6 +215,10 @@ export class V1Runtime {
|
|
|
215
215
|
: {}),
|
|
216
216
|
}, error);
|
|
217
217
|
}
|
|
218
|
+
/** Archive routes sit outside `handle`, so they record their own failures. */
|
|
219
|
+
async recordArchiveFailure(target, error) {
|
|
220
|
+
await this.#errorLog?.recordRequestFailure(target, error);
|
|
221
|
+
}
|
|
218
222
|
subscribeConversation(target, changed) {
|
|
219
223
|
return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
|
|
220
224
|
}
|
|
@@ -324,13 +328,18 @@ export class V1Runtime {
|
|
|
324
328
|
return this.#content.readCurrentTreeContentPackage(request.packageId);
|
|
325
329
|
case "content.replace":
|
|
326
330
|
try {
|
|
327
|
-
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
|
|
331
|
+
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files, { baseTreeRevision: request.baseTreeRevision });
|
|
328
332
|
}
|
|
329
333
|
catch (error) {
|
|
330
334
|
if (error instanceof InvalidContentTreeError)
|
|
331
335
|
throw new V1ProtocolError("invalid_request", error.message, {
|
|
332
336
|
cause: error,
|
|
333
337
|
});
|
|
338
|
+
if (error instanceof ContentTreeConflictError)
|
|
339
|
+
throw new V1ProtocolError("content_conflict", error.message, {
|
|
340
|
+
cause: error,
|
|
341
|
+
details: { current: error.current },
|
|
342
|
+
});
|
|
334
343
|
throw error;
|
|
335
344
|
}
|
|
336
345
|
case "content.copy":
|
|
@@ -108,7 +108,23 @@ export class WorldArchives {
|
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Confirms that `download` can serve this export without consuming it, so the
|
|
113
|
+
* browser can check before it starts the one-time download.
|
|
114
|
+
*/
|
|
115
|
+
async checkDownload(archiveId) {
|
|
116
|
+
const { root } = await this.#readExport(archiveId);
|
|
117
|
+
await stat(join(root, "archive.zip"));
|
|
118
|
+
}
|
|
111
119
|
async download(archiveId) {
|
|
120
|
+
const { root, metadata } = await this.#readExport(archiveId);
|
|
121
|
+
return {
|
|
122
|
+
metadata,
|
|
123
|
+
stream: createReadStream(join(root, "archive.zip")),
|
|
124
|
+
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async #readExport(archiveId) {
|
|
112
128
|
identity(archiveId);
|
|
113
129
|
const root = join(this.#root, "exports", archiveId);
|
|
114
130
|
const metadata = JSON.parse(await readFile(join(root, "download.json"), "utf8"));
|
|
@@ -117,11 +133,7 @@ export class WorldArchives {
|
|
|
117
133
|
await rm(root, { recursive: true, force: true });
|
|
118
134
|
throw new WorldArchiveError("invalid_archive", "The download has expired; export the world again");
|
|
119
135
|
}
|
|
120
|
-
return {
|
|
121
|
-
metadata,
|
|
122
|
-
stream: createReadStream(join(root, "archive.zip")),
|
|
123
|
-
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
124
|
-
};
|
|
136
|
+
return { root, metadata };
|
|
125
137
|
}
|
|
126
138
|
async importWorld(input) {
|
|
127
139
|
identity(input.operationId);
|
|
@@ -7,7 +7,7 @@ import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } fr
|
|
|
7
7
|
import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
|
|
8
8
|
import { sampleContentPackageForLocale } from "../../shared/sample-packages/index.js";
|
|
9
9
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
|
|
10
|
-
export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
10
|
+
export { ContentTreeConflictError, CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
11
11
|
const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
|
|
12
12
|
fatal: true,
|
|
13
13
|
ignoreBOM: true,
|
|
@@ -78,7 +78,7 @@ export class ContentWorkspace {
|
|
|
78
78
|
validateCurrentTreeContentPackage(files) {
|
|
79
79
|
normalizeCurrentContentTreeFiles(files, this.#limits);
|
|
80
80
|
}
|
|
81
|
-
replaceCurrentTreeContentPackage(localId, files, options
|
|
81
|
+
replaceCurrentTreeContentPackage(localId, files, options) {
|
|
82
82
|
return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
|
|
83
83
|
}
|
|
84
84
|
setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { readFileSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
5
5
|
import { linkExclusive } from "../FileNativeExclusiveLink.js";
|
|
@@ -31,6 +31,18 @@ export class CurrentTreeContentPackageBusyError extends CurrentTreeContentLibrar
|
|
|
31
31
|
this.name = "CurrentTreeContentPackageBusyError";
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The current tree changed after the caller's base revision. Nothing was
|
|
36
|
+
* written; `current` is the tree the caller must reload before saving again.
|
|
37
|
+
*/
|
|
38
|
+
export class ContentTreeConflictError extends CurrentTreeContentLibraryError {
|
|
39
|
+
current;
|
|
40
|
+
constructor(current) {
|
|
41
|
+
super("The content package changed after this draft was opened; load the latest tree before saving");
|
|
42
|
+
this.name = "ContentTreeConflictError";
|
|
43
|
+
this.current = current;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
34
46
|
export class InvalidContentTreeError extends Error {
|
|
35
47
|
constructor(message) {
|
|
36
48
|
super(message);
|
|
@@ -216,12 +228,19 @@ export class CurrentTreeContentLibrary {
|
|
|
216
228
|
}
|
|
217
229
|
});
|
|
218
230
|
}
|
|
219
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Replace the whole tree only if it is still the revision the draft was
|
|
233
|
+
* based on. The comparison runs under the package lease, so a publish that
|
|
234
|
+
* landed after the draft opened cannot be overwritten.
|
|
235
|
+
*/
|
|
236
|
+
replaceCurrentTree(localId, files, options) {
|
|
220
237
|
return this.#mutate(async () => {
|
|
221
238
|
const lease = await this.acquireOperationLease(localId, {
|
|
222
239
|
wait: options.waitForLease !== false,
|
|
223
240
|
});
|
|
224
241
|
try {
|
|
242
|
+
if (lease.package.treeRevision !== options.baseTreeRevision)
|
|
243
|
+
throw new ContentTreeConflictError(lease.package);
|
|
225
244
|
return await lease.replace(files, options);
|
|
226
245
|
}
|
|
227
246
|
finally {
|
|
@@ -490,6 +509,7 @@ export class CurrentTreeContentLibrary {
|
|
|
490
509
|
function detail(localId, title, files, inspection) {
|
|
491
510
|
return {
|
|
492
511
|
...summary(localId, title, readGameplay(files), inspection),
|
|
512
|
+
treeRevision: treeRevisionOf(files),
|
|
493
513
|
files: files.map((file) => ({ ...file })),
|
|
494
514
|
documents: structuredClone(inspection.documents),
|
|
495
515
|
issues: structuredClone(inspection.issues),
|
|
@@ -497,6 +517,18 @@ function detail(localId, title, files, inspection) {
|
|
|
497
517
|
control: structuredClone(inspection.control),
|
|
498
518
|
};
|
|
499
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Digest of every file's path, encoding and contents, independent of file
|
|
522
|
+
* order. Equal trees always share a revision; a tree that returns to earlier
|
|
523
|
+
* content is equal to it, so a draft based on that content loses nothing.
|
|
524
|
+
*/
|
|
525
|
+
function treeRevisionOf(files) {
|
|
526
|
+
const hash = createHash("sha256");
|
|
527
|
+
for (const file of [...files].sort((left, right) => compareStrings(left.path, right.path))) {
|
|
528
|
+
hash.update(JSON.stringify([file.path, file.encoding ?? null, file.contents]));
|
|
529
|
+
}
|
|
530
|
+
return hash.digest("hex");
|
|
531
|
+
}
|
|
500
532
|
function summary(localId, title, gameplay, inspection) {
|
|
501
533
|
return {
|
|
502
534
|
gameplay: { ...gameplay },
|
|
@@ -8,6 +8,8 @@ import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js
|
|
|
8
8
|
import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
|
|
9
9
|
import { defaultRuntimeToolDefinitionStrategy, portableRuntimeToolInputSchema, } from "../prompt/FileNativeToolRegistry.js";
|
|
10
10
|
import { AiExchangeCapture, errorDescription, } from "./AiFailureLog.js";
|
|
11
|
+
/** Anthropic allows four cache_control breakpoints per request. */
|
|
12
|
+
const explicitCacheBreakpointLimit = 4;
|
|
11
13
|
const providerContinuationCodecVersion = 1;
|
|
12
14
|
function resolveConnection(connection) {
|
|
13
15
|
const resolved = structuredClone({
|
|
@@ -17,6 +19,7 @@ function resolveConnection(connection) {
|
|
|
17
19
|
reasoningSummary: connection.reasoningSummary ?? "provider_default",
|
|
18
20
|
thinkingMode: connection.thinkingMode ?? "provider_default",
|
|
19
21
|
thinkingBudgetTokens: connection.thinkingBudgetTokens ?? null,
|
|
22
|
+
cacheSuffixBreakpoint: connection.cacheSuffixBreakpoint ?? true,
|
|
20
23
|
});
|
|
21
24
|
const issue = modelReasoningPolicyIssue({
|
|
22
25
|
provider: resolved.provider,
|
|
@@ -95,6 +98,8 @@ export class FileNativeModelHost {
|
|
|
95
98
|
throw new ModelHostBindingMismatchError("The request protocol does not match the selected model host");
|
|
96
99
|
return new _a({
|
|
97
100
|
...request.modelConfig,
|
|
101
|
+
// A frozen request without this choice predates the transcript breakpoint.
|
|
102
|
+
cacheSuffixBreakpoint: request.modelConfig.cacheSuffixBreakpoint === true,
|
|
98
103
|
baseUrl: this.#connection.baseUrl,
|
|
99
104
|
apiKey: this.#connection.apiKey,
|
|
100
105
|
}, this.#fetch, this.#failureLog);
|
|
@@ -458,19 +463,16 @@ async function trace(kind, value) {
|
|
|
458
463
|
// Explicit acceptance diagnostics must never change provider semantics.
|
|
459
464
|
}
|
|
460
465
|
}
|
|
461
|
-
function items(request) {
|
|
462
|
-
return request.appended;
|
|
463
|
-
}
|
|
464
466
|
function chatRequestBody(connection, request) {
|
|
465
467
|
const tools = providerToolDefinitions(request);
|
|
466
468
|
const allowedTools = providerAllowedTools(request);
|
|
467
469
|
const hasPolicy = hasFrozenToolPolicy(request);
|
|
468
|
-
return {
|
|
470
|
+
return checkedCacheBreakpoints({
|
|
469
471
|
model: connection.modelId,
|
|
470
472
|
...chatReasoningRequest(connection),
|
|
471
473
|
messages: [
|
|
472
474
|
...chatBootstrapMessages(connection, request),
|
|
473
|
-
...chatAppend(request),
|
|
475
|
+
...chatAppend(connection, request),
|
|
474
476
|
],
|
|
475
477
|
...(connection.dialect === "cliproxyapi"
|
|
476
478
|
? { prompt_cache_key: promptCacheKey(connection, request) }
|
|
@@ -495,17 +497,17 @@ function chatRequestBody(connection, request) {
|
|
|
495
497
|
max_tokens: request.maxOutputTokens,
|
|
496
498
|
stream: true,
|
|
497
499
|
stream_options: { include_usage: true },
|
|
498
|
-
};
|
|
500
|
+
});
|
|
499
501
|
}
|
|
500
502
|
function anthropicRequestBody(connection, request) {
|
|
501
503
|
const tools = providerToolDefinitions(request);
|
|
502
|
-
return {
|
|
504
|
+
return checkedCacheBreakpoints({
|
|
503
505
|
model: connection.modelId,
|
|
504
506
|
...anthropicReasoningRequest(connection),
|
|
505
507
|
system: request.bootstrap.provider.system,
|
|
506
508
|
messages: [
|
|
507
509
|
...anthropicBootstrapMessages(request),
|
|
508
|
-
...anthropicAppend(request),
|
|
510
|
+
...anthropicAppend(connection, request),
|
|
509
511
|
],
|
|
510
512
|
...(tools.length === 0
|
|
511
513
|
? {}
|
|
@@ -522,14 +524,14 @@ function anthropicRequestBody(connection, request) {
|
|
|
522
524
|
: {}),
|
|
523
525
|
max_tokens: request.maxOutputTokens,
|
|
524
526
|
stream: true,
|
|
525
|
-
};
|
|
527
|
+
});
|
|
526
528
|
}
|
|
527
529
|
function responsesRequestInput(connection, request) {
|
|
528
530
|
return {
|
|
529
531
|
connection,
|
|
530
532
|
input: [
|
|
531
533
|
...responsesBootstrapMessages(connection, request),
|
|
532
|
-
...responsesAppend(request),
|
|
534
|
+
...responsesAppend(connection, request),
|
|
533
535
|
],
|
|
534
536
|
tools: providerToolDefinitions(request).map((tool) => ({
|
|
535
537
|
name: tool.name,
|
|
@@ -555,7 +557,7 @@ function responsesRequestBody(input) {
|
|
|
555
557
|
tools: allowedTools.map((name) => ({ type: "function", name })),
|
|
556
558
|
}
|
|
557
559
|
: "auto";
|
|
558
|
-
return {
|
|
560
|
+
return checkedCacheBreakpoints({
|
|
559
561
|
model: input.connection.modelId,
|
|
560
562
|
...responsesReasoningRequest(resolveConnection(input.connection)),
|
|
561
563
|
input: input.input,
|
|
@@ -582,7 +584,7 @@ function responsesRequestBody(input) {
|
|
|
582
584
|
store: false,
|
|
583
585
|
include: ["reasoning.encrypted_content"],
|
|
584
586
|
stream: true,
|
|
585
|
-
};
|
|
587
|
+
});
|
|
586
588
|
}
|
|
587
589
|
/**
|
|
588
590
|
* A frozen policy is present when the caller supplies the logical operation's
|
|
@@ -758,8 +760,88 @@ function promptCacheKey(connection, request) {
|
|
|
758
760
|
.update(`${connection.provider}\0${connection.dialect}\0${connection.modelId}\0${scope}`)
|
|
759
761
|
.digest("hex");
|
|
760
762
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
+
/**
|
|
764
|
+
* Explicit-breakpoint encodings mark the last transcript item before the
|
|
765
|
+
* request-level suffix, so the next dispatch reads back the transcript it sent.
|
|
766
|
+
* A request without a suffix keeps its request end as the transcript's entry,
|
|
767
|
+
* so it is encoded exactly as before.
|
|
768
|
+
*/
|
|
769
|
+
function appendWithSuffixBreakpoint(connection, request, encode, markLast) {
|
|
770
|
+
const { transcript, suffix } = splitRequestSuffix(request.appended);
|
|
771
|
+
const encoded = encode(transcript);
|
|
772
|
+
const breakpoint = suffix.length > 0 &&
|
|
773
|
+
encoded.length > 0 &&
|
|
774
|
+
connection.cacheSuffixBreakpoint &&
|
|
775
|
+
modelCacheStrategy(connection) !== "provider_managed";
|
|
776
|
+
return [...(breakpoint ? markLast(encoded) : encoded), ...encode(suffix)];
|
|
777
|
+
}
|
|
778
|
+
/** Request-level suffix items are recompiled for every dispatch and never stored. */
|
|
779
|
+
function splitRequestSuffix(appended) {
|
|
780
|
+
let end = appended.length;
|
|
781
|
+
while (end > 0 && isRequestSuffix(appended[end - 1]))
|
|
782
|
+
end -= 1;
|
|
783
|
+
return { transcript: appended.slice(0, end), suffix: appended.slice(end) };
|
|
784
|
+
}
|
|
785
|
+
function isRequestSuffix(item) {
|
|
786
|
+
return item.kind === "runtime_notice" && item.notice === "turn_guide";
|
|
787
|
+
}
|
|
788
|
+
/** Message-level cache_control, the form the CLIProxyAPI prefix breakpoints use. */
|
|
789
|
+
function markLastMessage(messages) {
|
|
790
|
+
const last = messages.at(-1);
|
|
791
|
+
if (!isRecord(last))
|
|
792
|
+
throw new Error("The transcript tail cannot carry a cache breakpoint.");
|
|
793
|
+
return [
|
|
794
|
+
...messages.slice(0, -1),
|
|
795
|
+
{ ...last, cache_control: { type: "ephemeral" } },
|
|
796
|
+
];
|
|
797
|
+
}
|
|
798
|
+
/** Thinking blocks reject cache_control, so the breakpoint lands on the last block that accepts it. */
|
|
799
|
+
function markLastAnthropicBlock(messages) {
|
|
800
|
+
const last = messages.at(-1);
|
|
801
|
+
if (!isRecord(last))
|
|
802
|
+
throw new Error("The transcript tail cannot carry a cache breakpoint.");
|
|
803
|
+
const content = typeof last.content === "string"
|
|
804
|
+
? [{ type: "text", text: last.content }]
|
|
805
|
+
: Array.isArray(last.content)
|
|
806
|
+
? last.content
|
|
807
|
+
: [];
|
|
808
|
+
const index = content.findLastIndex((block) => isRecord(block) &&
|
|
809
|
+
block.type !== "thinking" &&
|
|
810
|
+
block.type !== "redacted_thinking");
|
|
811
|
+
if (index < 0)
|
|
812
|
+
throw new Error("The Anthropic transcript tail has no block that can carry a cache breakpoint.");
|
|
813
|
+
return [
|
|
814
|
+
...messages.slice(0, -1),
|
|
815
|
+
{
|
|
816
|
+
...last,
|
|
817
|
+
content: content.map((block, position) => position === index
|
|
818
|
+
? {
|
|
819
|
+
...block,
|
|
820
|
+
cache_control: { type: "ephemeral" },
|
|
821
|
+
}
|
|
822
|
+
: block),
|
|
823
|
+
},
|
|
824
|
+
];
|
|
825
|
+
}
|
|
826
|
+
/** Refuses a request over the breakpoint limit instead of silently dropping one. */
|
|
827
|
+
function checkedCacheBreakpoints(body) {
|
|
828
|
+
const count = countCacheBreakpoints(body);
|
|
829
|
+
if (count > explicitCacheBreakpointLimit)
|
|
830
|
+
throw new Error(`The request has ${count} cache breakpoints; the Anthropic limit is ${explicitCacheBreakpointLimit}.`);
|
|
831
|
+
return body;
|
|
832
|
+
}
|
|
833
|
+
function countCacheBreakpoints(value) {
|
|
834
|
+
if (Array.isArray(value))
|
|
835
|
+
return value.reduce((total, item) => total + countCacheBreakpoints(item), 0);
|
|
836
|
+
if (!isRecord(value))
|
|
837
|
+
return 0;
|
|
838
|
+
return Object.entries(value).reduce((total, [key, item]) => total + (key === "cache_control" ? 1 : 0) + countCacheBreakpoints(item), 0);
|
|
839
|
+
}
|
|
840
|
+
function chatAppend(connection, request) {
|
|
841
|
+
return appendWithSuffixBreakpoint(connection, request, chatItems, markLastMessage);
|
|
842
|
+
}
|
|
843
|
+
function chatItems(items) {
|
|
844
|
+
return items.flatMap((item) => {
|
|
763
845
|
if (item.kind === "player" ||
|
|
764
846
|
item.kind === "user" ||
|
|
765
847
|
item.kind === "runtime_notice")
|
|
@@ -782,7 +864,10 @@ function chatAppend(request) {
|
|
|
782
864
|
throw continuationError("Chat Completions");
|
|
783
865
|
});
|
|
784
866
|
}
|
|
785
|
-
function anthropicAppend(request) {
|
|
867
|
+
function anthropicAppend(connection, request) {
|
|
868
|
+
return appendWithSuffixBreakpoint(connection, request, anthropicItems, markLastAnthropicBlock);
|
|
869
|
+
}
|
|
870
|
+
function anthropicItems(items) {
|
|
786
871
|
const messages = [];
|
|
787
872
|
let toolResults = [];
|
|
788
873
|
const flushToolResults = () => {
|
|
@@ -791,7 +876,7 @@ function anthropicAppend(request) {
|
|
|
791
876
|
messages.push({ role: "user", content: toolResults });
|
|
792
877
|
toolResults = [];
|
|
793
878
|
};
|
|
794
|
-
for (const item of items
|
|
879
|
+
for (const item of items) {
|
|
795
880
|
if (item.kind === "tool") {
|
|
796
881
|
toolResults.push({
|
|
797
882
|
type: "tool_result",
|
|
@@ -825,8 +910,11 @@ function anthropicAppend(request) {
|
|
|
825
910
|
flushToolResults();
|
|
826
911
|
return messages;
|
|
827
912
|
}
|
|
828
|
-
function responsesAppend(request) {
|
|
829
|
-
return
|
|
913
|
+
function responsesAppend(connection, request) {
|
|
914
|
+
return appendWithSuffixBreakpoint(connection, request, responsesItems, markLastMessage);
|
|
915
|
+
}
|
|
916
|
+
function responsesItems(items) {
|
|
917
|
+
return items.flatMap((item) => {
|
|
830
918
|
if (item.kind === "player" ||
|
|
831
919
|
item.kind === "user" ||
|
|
832
920
|
item.kind === "runtime_notice")
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
/** Written last by the released-record cleanup (ADR-0034); its absence means "not yet run". */
|
|
7
|
+
const LAYOUT_MARKER = "play-advance-layout.json";
|
|
6
8
|
/**
|
|
7
9
|
* The single in-progress play advance of a call chain, if any.
|
|
8
10
|
*
|
|
9
11
|
* It exists only between `begin` and `markSettled`. A marker that survives a
|
|
10
12
|
* restart means the advance was interrupted; the runtime marks the chain
|
|
11
|
-
* interrupted and the player retries.
|
|
12
|
-
*
|
|
13
|
+
* interrupted and the player retries. Released records from earlier layouts
|
|
14
|
+
* never block a world from opening, and the storage upgrade removes them
|
|
15
|
+
* (see `migrateLayout`).
|
|
13
16
|
*/
|
|
14
17
|
export class FileNativePlayAdvanceStore {
|
|
15
18
|
#worldsRoot;
|
|
@@ -52,6 +55,50 @@ export class FileNativePlayAdvanceStore {
|
|
|
52
55
|
force: true,
|
|
53
56
|
});
|
|
54
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether the world's advance records are free of the pre-ADR-0044 layout
|
|
60
|
+
* (ADR-0034). Reads never write: a world with no released records is current
|
|
61
|
+
* without a marker.
|
|
62
|
+
*/
|
|
63
|
+
async layoutCurrent(worldId) {
|
|
64
|
+
assertIdentity(worldId, "World ID");
|
|
65
|
+
if ((await readOptionalJson(this.#layoutMarkerPath(worldId))) !==
|
|
66
|
+
null)
|
|
67
|
+
return true;
|
|
68
|
+
return (await this.#releasedChainRoots(worldId)).length === 0;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Remove the released `records/` and `current.json` of every chain. Each
|
|
72
|
+
* chain's `in-progress.json` stays as it is, and a chain left empty is removed.
|
|
73
|
+
* The marker is written last, so an interrupted run simply runs again.
|
|
74
|
+
*/
|
|
75
|
+
async migrateLayout(worldId) {
|
|
76
|
+
assertIdentity(worldId, "World ID");
|
|
77
|
+
if (await this.layoutCurrent(worldId))
|
|
78
|
+
return;
|
|
79
|
+
for (const chainRoot of await this.#releasedChainRoots(worldId)) {
|
|
80
|
+
await rm(join(chainRoot, "records"), { recursive: true, force: true });
|
|
81
|
+
await rm(join(chainRoot, "current.json"), { force: true });
|
|
82
|
+
if ((await readdir(chainRoot)).length === 0)
|
|
83
|
+
await rm(chainRoot, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
await publishJson(this.#layoutMarkerPath(worldId), layoutMarker());
|
|
86
|
+
}
|
|
87
|
+
/** Chain directories that still hold a released record or pointer. */
|
|
88
|
+
async #releasedChainRoots(worldId) {
|
|
89
|
+
const advancesRoot = join(this.#worldsRoot, worldId, "runtime", "play-advances");
|
|
90
|
+
const roots = [];
|
|
91
|
+
for (const name of await readDirectoryNames(advancesRoot)) {
|
|
92
|
+
const chainRoot = join(advancesRoot, name);
|
|
93
|
+
if ((await pathExists(join(chainRoot, "records"))) ||
|
|
94
|
+
(await pathExists(join(chainRoot, "current.json"))))
|
|
95
|
+
roots.push(chainRoot);
|
|
96
|
+
}
|
|
97
|
+
return roots;
|
|
98
|
+
}
|
|
99
|
+
#layoutMarkerPath(worldId) {
|
|
100
|
+
return join(this.#worldsRoot, worldId, "runtime", LAYOUT_MARKER);
|
|
101
|
+
}
|
|
55
102
|
#markerPath(worldId, chainId) {
|
|
56
103
|
return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
|
|
57
104
|
}
|
|
@@ -120,6 +167,26 @@ async function readOptionalJson(path) {
|
|
|
120
167
|
throw error;
|
|
121
168
|
}
|
|
122
169
|
}
|
|
170
|
+
async function readDirectoryNames(directory) {
|
|
171
|
+
try {
|
|
172
|
+
return await readdir(directory);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
176
|
+
return [];
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function pathExists(path) {
|
|
181
|
+
return stat(path).then(() => true, (error) => {
|
|
182
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
183
|
+
return false;
|
|
184
|
+
throw error;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function layoutMarker() {
|
|
188
|
+
return { schemaVersion: 1, kind: "play_advance_layout" };
|
|
189
|
+
}
|
|
123
190
|
/** Atomic publish: write a temporary file, then rename over the target. */
|
|
124
191
|
async function publishJson(path, value) {
|
|
125
192
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
1
2
|
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -21,9 +22,11 @@ export class UnsupportedLegacyStorageError extends Error {
|
|
|
21
22
|
export class FileNativeWorldStorageMigrator {
|
|
22
23
|
#worldsRoot;
|
|
23
24
|
#playTimeline;
|
|
25
|
+
#playAdvances;
|
|
24
26
|
constructor(dataRoot) {
|
|
25
27
|
this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
|
|
26
28
|
this.#playTimeline = new FileNativePlayTimelineStore(dataRoot);
|
|
29
|
+
this.#playAdvances = new FileNativePlayAdvanceStore(dataRoot);
|
|
27
30
|
}
|
|
28
31
|
async migrate(worldId) {
|
|
29
32
|
assertIdentity(worldId, "World ID");
|
|
@@ -39,6 +42,7 @@ export class FileNativeWorldStorageMigrator {
|
|
|
39
42
|
])
|
|
40
43
|
await rm(residue, { recursive: true, force: true });
|
|
41
44
|
await this.#playTimeline.migrateLayout(worldId);
|
|
45
|
+
await this.#playAdvances.migrateLayout(worldId);
|
|
42
46
|
const head = await ledger.readHead();
|
|
43
47
|
return { outcome: "already_current", authorityCommits: head.sequence };
|
|
44
48
|
}
|
|
@@ -46,6 +50,7 @@ export class FileNativeWorldStorageMigrator {
|
|
|
46
50
|
if (await authority.exists()) {
|
|
47
51
|
const { commits } = await convertAuthorityV3ToLedger(worldRoot);
|
|
48
52
|
await this.#playTimeline.migrateLayout(worldId);
|
|
53
|
+
await this.#playAdvances.migrateLayout(worldId);
|
|
49
54
|
return { outcome: "migrated", authorityCommits: commits };
|
|
50
55
|
}
|
|
51
56
|
throw new UnsupportedLegacyStorageError(worldId);
|
|
@@ -106,7 +106,8 @@ export class FileNativeWorldStore {
|
|
|
106
106
|
const root = join(this.#worldsRoot, worldId);
|
|
107
107
|
const authority = new WorldLedgerAuthority(root);
|
|
108
108
|
if ((await authority.exists()) &&
|
|
109
|
-
(await this.playTimeline.layoutCurrent(worldId))
|
|
109
|
+
(await this.playTimeline.layoutCurrent(worldId)) &&
|
|
110
|
+
(await this.playAdvances.layoutCurrent(worldId))) {
|
|
110
111
|
await authority.readHead();
|
|
111
112
|
this.#currentStorage.add(worldId);
|
|
112
113
|
return;
|
|
@@ -91,6 +91,7 @@ export async function createServer(input) {
|
|
|
91
91
|
return await input.runtime.worldArchives.upload(request.body);
|
|
92
92
|
}
|
|
93
93
|
catch (error) {
|
|
94
|
+
await input.runtime.recordArchiveFailure({ type: "world.archive.upload" }, error);
|
|
94
95
|
if (error instanceof WorldArchiveError)
|
|
95
96
|
return reply
|
|
96
97
|
.status(error.code === "limit_exceeded" ? 413 : 400)
|
|
@@ -98,6 +99,21 @@ export async function createServer(input) {
|
|
|
98
99
|
throw error;
|
|
99
100
|
}
|
|
100
101
|
});
|
|
102
|
+
// The browser checks this before it starts the one-time download, so a
|
|
103
|
+
// missing or failing export is reported on the page instead of a blank file.
|
|
104
|
+
archives.get("/api/runtime/v1/world-archives/:archiveId/check", async (request, reply) => {
|
|
105
|
+
try {
|
|
106
|
+
await input.runtime.worldArchives.checkDownload(request.params.archiveId);
|
|
107
|
+
return reply.status(204).send();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await input.runtime.recordArchiveFailure({
|
|
111
|
+
type: "world.archive.download",
|
|
112
|
+
archiveId: request.params.archiveId,
|
|
113
|
+
}, error);
|
|
114
|
+
return archiveDownloadFailure(reply, error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
101
117
|
archives.get("/api/runtime/v1/world-archives/:archiveId", async (request, reply) => {
|
|
102
118
|
try {
|
|
103
119
|
const archive = await input.runtime.worldArchives.download(request.params.archiveId);
|
|
@@ -111,20 +127,11 @@ export async function createServer(input) {
|
|
|
111
127
|
.send(archive.stream);
|
|
112
128
|
}
|
|
113
129
|
catch (error) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"code" in error &&
|
|
120
|
-
error.code === "ENOENT")
|
|
121
|
-
return reply.status(404).send({
|
|
122
|
-
error: {
|
|
123
|
-
code: "invalid_archive",
|
|
124
|
-
message: "The archive download is unavailable",
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
throw error;
|
|
130
|
+
await input.runtime.recordArchiveFailure({
|
|
131
|
+
type: "world.archive.download",
|
|
132
|
+
archiveId: request.params.archiveId,
|
|
133
|
+
}, error);
|
|
134
|
+
return archiveDownloadFailure(reply, error);
|
|
128
135
|
}
|
|
129
136
|
});
|
|
130
137
|
done();
|
|
@@ -160,13 +167,19 @@ export async function createServer(input) {
|
|
|
160
167
|
}
|
|
161
168
|
catch (error) {
|
|
162
169
|
if (error instanceof V1ProtocolError) {
|
|
163
|
-
//
|
|
164
|
-
const status = error.code === "endpoint_discarded"
|
|
170
|
+
// 端点已被回退丢弃或内容包已被其他操作修改:客户端应重新读取,不是服务端错误。
|
|
171
|
+
const status = error.code === "endpoint_discarded" ||
|
|
172
|
+
error.code === "content_conflict"
|
|
173
|
+
? 409
|
|
174
|
+
: 400;
|
|
165
175
|
return reply.status(status).send({
|
|
166
176
|
protocol: "narraeon.runtime/v1",
|
|
167
177
|
error: {
|
|
168
178
|
code: error.code,
|
|
169
179
|
message: error.message,
|
|
180
|
+
...(error.details === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { details: error.details }),
|
|
170
183
|
},
|
|
171
184
|
});
|
|
172
185
|
}
|
|
@@ -182,6 +195,20 @@ export async function createServer(input) {
|
|
|
182
195
|
server.get("/index.html", index);
|
|
183
196
|
return server;
|
|
184
197
|
}
|
|
198
|
+
function archiveDownloadFailure(reply, error) {
|
|
199
|
+
if (error instanceof WorldArchiveError)
|
|
200
|
+
return reply
|
|
201
|
+
.status(400)
|
|
202
|
+
.send({ error: { code: error.code, message: error.message } });
|
|
203
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
204
|
+
return reply.status(404).send({
|
|
205
|
+
error: {
|
|
206
|
+
code: "invalid_archive",
|
|
207
|
+
message: "The archive download is unavailable",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
185
212
|
function acceptsPlayCallChainStream(request) {
|
|
186
213
|
const accept = request.headers.accept;
|
|
187
214
|
return (typeof accept === "string" &&
|