narraeon 0.7.4 → 0.7.6
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 +4 -0
- package/dist/node/runtime/V1Runtime.js +13 -1
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +64 -163
- package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
- package/dist/node/runtime/model/ToolArgumentCoercion.js +138 -0
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +34 -18
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +6 -2
- package/dist/node/runtime/play/OrderedPlayPrompts.js +3 -6
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
- package/dist/node/runtime/play/PlayCallChain.js +276 -398
- package/dist/node/runtime/play/PlayDocumentTools.js +19 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +3 -3
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +15 -0
- package/dist/node/runtime/prompt/ToolNameAlias.js +14 -0
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +370 -169
- package/dist/node/runtime/world/FileNativeWorldStore.js +151 -109
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
- package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
- package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
- package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
- package/dist/node/server/createServer.js +1 -4
- package/dist/node/shared/ordered-play-prompts.js +5 -0
- package/dist/node/shared/turn-guide.js +27 -2
- package/dist/web/assets/index-Cj2JYCia.js +1408 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-9N9kO8kY.js +0 -1408
package/dist/node/protocol/v1.js
CHANGED
|
@@ -353,6 +353,10 @@ function validateRequestFields(request) {
|
|
|
353
353
|
if (request.type === "play.timeline.detail" &&
|
|
354
354
|
(!Number.isSafeInteger(request.eventId) || Number(request.eventId) < 1))
|
|
355
355
|
throw new V1ProtocolError("invalid_request", "play.timeline.detail.eventId is invalid");
|
|
356
|
+
if ("turnNote" in request &&
|
|
357
|
+
request.turnNote !== undefined &&
|
|
358
|
+
typeof request.turnNote !== "string")
|
|
359
|
+
throw new V1ProtocolError("invalid_request", "turnNote is invalid");
|
|
356
360
|
if (request.type === "play.chain.revise-player" &&
|
|
357
361
|
request.continuation !== "continue_context" &&
|
|
358
362
|
request.continuation !== "fresh_context")
|
|
@@ -197,7 +197,7 @@ export class V1Runtime {
|
|
|
197
197
|
const chain = result;
|
|
198
198
|
if (chain?.status !== "interrupted" ||
|
|
199
199
|
typeof chain.lastFailure !== "string" ||
|
|
200
|
-
chain.
|
|
200
|
+
chain.lastCancelled === true)
|
|
201
201
|
return;
|
|
202
202
|
await this.#errorLog.recordPlayInterruption(request, {
|
|
203
203
|
message: chain.lastFailure,
|
|
@@ -458,6 +458,9 @@ export class V1Runtime {
|
|
|
458
458
|
}), `content:${request.packageId}.message.genesis.narrator`, "content-preview"),
|
|
459
459
|
playerInputPlacement: "append",
|
|
460
460
|
playerInput: request.playerInput,
|
|
461
|
+
...(request.turnNote === undefined
|
|
462
|
+
? {}
|
|
463
|
+
: { turnNote: request.turnNote }),
|
|
461
464
|
modelBinding: modelHost.binding(),
|
|
462
465
|
}, playPreset);
|
|
463
466
|
const tools = structuredClone(preview.compilation.toolUniverse ?? preview.compilation.tools);
|
|
@@ -669,6 +672,9 @@ export class V1Runtime {
|
|
|
669
672
|
eventId: request.eventId,
|
|
670
673
|
replacementExchangeId: request.replacementExchangeId,
|
|
671
674
|
replacementText: request.replacementText,
|
|
675
|
+
...(request.turnNote === undefined
|
|
676
|
+
? {}
|
|
677
|
+
: { turnNote: request.turnNote }),
|
|
672
678
|
};
|
|
673
679
|
const result = await playCall(async () => {
|
|
674
680
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
@@ -695,6 +701,9 @@ export class V1Runtime {
|
|
|
695
701
|
chainId: request.chainId,
|
|
696
702
|
exchangeId: request.exchangeId,
|
|
697
703
|
playerText: request.playerText,
|
|
704
|
+
...(request.turnNote === undefined
|
|
705
|
+
? {}
|
|
706
|
+
: { turnNote: request.turnNote }),
|
|
698
707
|
hostBinding,
|
|
699
708
|
playPreset,
|
|
700
709
|
modelBinding,
|
|
@@ -712,6 +721,9 @@ export class V1Runtime {
|
|
|
712
721
|
chainId: request.chainId,
|
|
713
722
|
exchangeId: request.exchangeId,
|
|
714
723
|
playerText: request.playerText,
|
|
724
|
+
...(request.turnNote === undefined
|
|
725
|
+
? {}
|
|
726
|
+
: { turnNote: request.turnNote }),
|
|
715
727
|
modelHost: await this.#modelHost(),
|
|
716
728
|
resolvePrompt: () => this.#continuousBinding(),
|
|
717
729
|
...(playCallChainObserver === undefined
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { readFileSync, unlinkSync } from "node:fs";
|
|
4
3
|
import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
5
4
|
import { linkExclusive } from "../FileNativeExclusiveLink.js";
|
|
6
5
|
import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
|
|
@@ -54,15 +53,16 @@ export class InvalidContentTreeError extends Error {
|
|
|
54
53
|
* single replaceable envelope so saving cannot expose half of two file sets.
|
|
55
54
|
*/
|
|
56
55
|
export class CurrentTreeContentLibrary {
|
|
57
|
-
#
|
|
56
|
+
#legacyLocksRoot;
|
|
58
57
|
#packagesRoot;
|
|
59
58
|
#stagingRoot;
|
|
60
59
|
#limits;
|
|
61
60
|
#beforePackagePublish;
|
|
62
61
|
#beforeCurrentTreeReplace;
|
|
63
62
|
#mutationTail = Promise.resolve();
|
|
63
|
+
#legacyLocksRemoval;
|
|
64
64
|
constructor(dataRoot, options = {}) {
|
|
65
|
-
this.#
|
|
65
|
+
this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
|
|
66
66
|
this.#packagesRoot = join(dataRoot, "content", "packages");
|
|
67
67
|
this.#stagingRoot = join(dataRoot, "content", ".package-staging");
|
|
68
68
|
this.#limits = normalizeLimits(options.limits);
|
|
@@ -272,48 +272,16 @@ export class CurrentTreeContentLibrary {
|
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
async acquireOperationLease(localId, options = {}) {
|
|
275
|
-
this.#packageRoot(localId);
|
|
276
|
-
await
|
|
277
|
-
|
|
278
|
-
const reclaimPath = this.#packageReclaimPath(localId);
|
|
279
|
-
await clearDeadPackageLockMarker(reclaimPath);
|
|
280
|
-
if (await pathExists(reclaimPath)) {
|
|
281
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
282
|
-
}
|
|
283
|
-
const marker = createPackageLockMarker();
|
|
284
|
-
const temporaryPath = join(this.#locksRoot, `.${localId}.${marker.token}.tmp`);
|
|
285
|
-
let handle;
|
|
286
|
-
try {
|
|
287
|
-
handle = await open(temporaryPath, "wx", 0o600);
|
|
288
|
-
await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
|
|
289
|
-
await handle.close();
|
|
290
|
-
handle = undefined;
|
|
291
|
-
try {
|
|
292
|
-
await linkExclusive(temporaryPath, lockPath);
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
if (!isNodeErrorCode(error, "EEXIST"))
|
|
296
|
-
throw error;
|
|
297
|
-
if (options.wait === true) {
|
|
298
|
-
await this.#waitForPackageLock(lockPath, reclaimPath, temporaryPath);
|
|
299
|
-
}
|
|
300
|
-
else if (!(await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, temporaryPath))) {
|
|
301
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
catch (error) {
|
|
306
|
-
await handle?.close().catch(() => undefined);
|
|
307
|
-
if (error instanceof CurrentTreeContentPackageBusyError)
|
|
308
|
-
throw error;
|
|
309
|
-
throw new CurrentTreeContentLibraryError("Unable to acquire the source-package save owner", {
|
|
310
|
-
cause: error instanceof Error ? error : undefined,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
finally {
|
|
314
|
-
await unlink(temporaryPath).catch(() => undefined);
|
|
315
|
-
}
|
|
275
|
+
const packageRoot = this.#packageRoot(localId);
|
|
276
|
+
await this.#removeLegacyPackageLocks();
|
|
277
|
+
await acquireInProcessPackageLease(packageRoot, options.wait === true);
|
|
316
278
|
let released = false;
|
|
279
|
+
const release = () => {
|
|
280
|
+
if (released)
|
|
281
|
+
return;
|
|
282
|
+
released = true;
|
|
283
|
+
releaseInProcessPackageLease(packageRoot);
|
|
284
|
+
};
|
|
317
285
|
try {
|
|
318
286
|
const package_ = (await this.#readPackageSnapshot(localId)).package;
|
|
319
287
|
return {
|
|
@@ -326,19 +294,25 @@ export class CurrentTreeContentLibrary {
|
|
|
326
294
|
assertGameplayModeUnchanged(package_.files, normalized);
|
|
327
295
|
return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
|
|
328
296
|
},
|
|
329
|
-
release
|
|
330
|
-
if (released)
|
|
331
|
-
return;
|
|
332
|
-
released = true;
|
|
333
|
-
releasePackageOperationLockSync(lockPath, marker.token);
|
|
334
|
-
},
|
|
297
|
+
release,
|
|
335
298
|
};
|
|
336
299
|
}
|
|
337
300
|
catch (error) {
|
|
338
|
-
|
|
301
|
+
release();
|
|
339
302
|
throw error;
|
|
340
303
|
}
|
|
341
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Lock files written by versions that leased packages across processes. No
|
|
307
|
+
* code reads them any more, so the first lease in a library removes them.
|
|
308
|
+
*/
|
|
309
|
+
#removeLegacyPackageLocks() {
|
|
310
|
+
this.#legacyLocksRemoval ??= rm(this.#legacyLocksRoot, {
|
|
311
|
+
recursive: true,
|
|
312
|
+
force: true,
|
|
313
|
+
}).catch(() => undefined);
|
|
314
|
+
return this.#legacyLocksRemoval;
|
|
315
|
+
}
|
|
342
316
|
async #replaceNormalizedCurrentTree(localId, title, normalized, signal) {
|
|
343
317
|
const packageRoot = this.#packageRoot(localId);
|
|
344
318
|
const temporaryPath = join(packageRoot, `.current-tree-${randomUUID()}.tmp`);
|
|
@@ -427,70 +401,6 @@ export class CurrentTreeContentLibrary {
|
|
|
427
401
|
}
|
|
428
402
|
return join(this.#packagesRoot, localId);
|
|
429
403
|
}
|
|
430
|
-
#packageLockPath(localId) {
|
|
431
|
-
return join(this.#locksRoot, `${localId}.mutation`);
|
|
432
|
-
}
|
|
433
|
-
#packageReclaimPath(localId) {
|
|
434
|
-
return join(this.#locksRoot, `${localId}.reclaim`);
|
|
435
|
-
}
|
|
436
|
-
async #tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath) {
|
|
437
|
-
const current = await readPackageLockMarker(lockPath);
|
|
438
|
-
if (current === null || isProcessAlive(current.pid))
|
|
439
|
-
return false;
|
|
440
|
-
const reclaim = createPackageLockMarker();
|
|
441
|
-
const temporaryPath = join(this.#locksRoot, `.reclaim.${reclaim.token}.tmp`);
|
|
442
|
-
let handle;
|
|
443
|
-
try {
|
|
444
|
-
handle = await open(temporaryPath, "wx", 0o600);
|
|
445
|
-
await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
|
|
446
|
-
await handle.close();
|
|
447
|
-
handle = undefined;
|
|
448
|
-
await linkExclusive(temporaryPath, reclaimPath);
|
|
449
|
-
}
|
|
450
|
-
catch (error) {
|
|
451
|
-
await handle?.close().catch(() => undefined);
|
|
452
|
-
if (isNodeErrorCode(error, "EEXIST"))
|
|
453
|
-
return false;
|
|
454
|
-
throw error;
|
|
455
|
-
}
|
|
456
|
-
finally {
|
|
457
|
-
await unlink(temporaryPath).catch(() => undefined);
|
|
458
|
-
}
|
|
459
|
-
try {
|
|
460
|
-
const verified = await readPackageLockMarker(lockPath);
|
|
461
|
-
if (verified?.token !== current.token || isProcessAlive(current.pid)) {
|
|
462
|
-
return false;
|
|
463
|
-
}
|
|
464
|
-
await unlink(lockPath).catch((error) => {
|
|
465
|
-
if (!isNodeErrorCode(error, "ENOENT"))
|
|
466
|
-
throw error;
|
|
467
|
-
});
|
|
468
|
-
await linkExclusive(replacementPath, lockPath);
|
|
469
|
-
return true;
|
|
470
|
-
}
|
|
471
|
-
finally {
|
|
472
|
-
const owner = await readPackageLockMarker(reclaimPath);
|
|
473
|
-
if (owner?.token === reclaim.token) {
|
|
474
|
-
await unlink(reclaimPath).catch(() => undefined);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
|
|
479
|
-
for (let attempt = 0; attempt < 2_000; attempt += 1) {
|
|
480
|
-
if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
|
|
481
|
-
(await linkExclusive(replacementPath, lockPath)
|
|
482
|
-
.then(() => true)
|
|
483
|
-
.catch((error) => {
|
|
484
|
-
if (isNodeErrorCode(error, "EEXIST"))
|
|
485
|
-
return false;
|
|
486
|
-
throw error;
|
|
487
|
-
}))) {
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
491
|
-
}
|
|
492
|
-
throw new CurrentTreeContentPackageBusyError();
|
|
493
|
-
}
|
|
494
404
|
async #mutate(operation) {
|
|
495
405
|
const previous = this.#mutationTail;
|
|
496
406
|
let release = () => undefined;
|
|
@@ -763,47 +673,47 @@ function isPlainObject(value) {
|
|
|
763
673
|
function serialize(value) {
|
|
764
674
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
765
675
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
function parsePackageLockMarker(value) {
|
|
779
|
-
if (isPlainObject(value) &&
|
|
780
|
-
value.schemaVersion === 1 &&
|
|
781
|
-
typeof value.pid === "number" &&
|
|
782
|
-
Number.isSafeInteger(value.pid) &&
|
|
783
|
-
value.pid > 0 &&
|
|
784
|
-
typeof value.token === "string" &&
|
|
785
|
-
/^[0-9a-f-]{36}$/u.test(value.token)) {
|
|
786
|
-
return { schemaVersion: 1, pid: value.pid, token: value.token };
|
|
787
|
-
}
|
|
788
|
-
return null;
|
|
789
|
-
}
|
|
790
|
-
function releasePackageOperationLockSync(path, token) {
|
|
791
|
-
try {
|
|
792
|
-
const current = parsePackageLockMarker(JSON.parse(readFileSync(path, "utf8")));
|
|
793
|
-
if (current?.token === token)
|
|
794
|
-
unlinkSync(path);
|
|
795
|
-
}
|
|
796
|
-
catch (error) {
|
|
797
|
-
if (!isNodeErrorCode(error, "ENOENT"))
|
|
798
|
-
throw error;
|
|
676
|
+
const packageLeaseWaitMs = 10_000;
|
|
677
|
+
/**
|
|
678
|
+
* Package leases held by this process, keyed by package root. A key is present
|
|
679
|
+
* while its lease is held; its array queues waiters in arrival order, and a
|
|
680
|
+
* release hands the lease straight to the first of them.
|
|
681
|
+
*/
|
|
682
|
+
const heldPackageLeases = new Map();
|
|
683
|
+
async function acquireInProcessPackageLease(packageRoot, wait) {
|
|
684
|
+
const queue = heldPackageLeases.get(packageRoot);
|
|
685
|
+
if (queue === undefined) {
|
|
686
|
+
heldPackageLeases.set(packageRoot, []);
|
|
687
|
+
return;
|
|
799
688
|
}
|
|
689
|
+
if (!wait)
|
|
690
|
+
throw new CurrentTreeContentPackageBusyError();
|
|
691
|
+
await new Promise((resolve, reject) => {
|
|
692
|
+
const waiter = {
|
|
693
|
+
grant: () => {
|
|
694
|
+
clearTimeout(timer);
|
|
695
|
+
resolve();
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
const timer = setTimeout(() => {
|
|
699
|
+
const index = queue.indexOf(waiter);
|
|
700
|
+
if (index === -1)
|
|
701
|
+
return;
|
|
702
|
+
queue.splice(index, 1);
|
|
703
|
+
reject(new CurrentTreeContentPackageBusyError());
|
|
704
|
+
}, packageLeaseWaitMs);
|
|
705
|
+
queue.push(waiter);
|
|
706
|
+
});
|
|
800
707
|
}
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
if (
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
708
|
+
function releaseInProcessPackageLease(packageRoot) {
|
|
709
|
+
const queue = heldPackageLeases.get(packageRoot);
|
|
710
|
+
if (queue === undefined)
|
|
711
|
+
return;
|
|
712
|
+
const next = queue.shift();
|
|
713
|
+
if (next === undefined)
|
|
714
|
+
heldPackageLeases.delete(packageRoot);
|
|
715
|
+
else
|
|
716
|
+
next.grant();
|
|
807
717
|
}
|
|
808
718
|
async function pathExists(path) {
|
|
809
719
|
try {
|
|
@@ -816,15 +726,6 @@ async function pathExists(path) {
|
|
|
816
726
|
throw error;
|
|
817
727
|
}
|
|
818
728
|
}
|
|
819
|
-
function isProcessAlive(pid) {
|
|
820
|
-
try {
|
|
821
|
-
process.kill(pid, 0);
|
|
822
|
-
return true;
|
|
823
|
-
}
|
|
824
|
-
catch (error) {
|
|
825
|
-
return !isNodeErrorCode(error, "ESRCH");
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
729
|
function isNodeErrorCode(error, code) {
|
|
829
730
|
return (typeof error === "object" &&
|
|
830
731
|
error !== null &&
|
|
@@ -14,8 +14,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
14
14
|
layout: "content_package",
|
|
15
15
|
files,
|
|
16
16
|
});
|
|
17
|
-
if (worldDocumentSnapshot.layout
|
|
18
|
-
throw new Error("
|
|
17
|
+
if (worldDocumentSnapshot.layout === "world_state")
|
|
18
|
+
throw new Error("Authoring inspection accepts only content_package or world_revision document snapshots");
|
|
19
|
+
const subject = worldDocumentSnapshot.layout === "world_revision"
|
|
20
|
+
? "world revision"
|
|
21
|
+
: "content package";
|
|
19
22
|
const snapshotFiles = worldDocumentSnapshot.files;
|
|
20
23
|
const issues = [];
|
|
21
24
|
const documents = worldDocumentSnapshotDocuments(worldDocumentSnapshot);
|
|
@@ -26,11 +29,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
26
29
|
let opening = "missing";
|
|
27
30
|
if (openingFiles.length === 0) {
|
|
28
31
|
if (options.requireOpening !== false)
|
|
29
|
-
issue(issues, "missing_opening", "opening.md",
|
|
32
|
+
issue(issues, "missing_opening", "opening.md", `The ${subject} is missing a root-level opening.md introduction`);
|
|
30
33
|
}
|
|
31
34
|
else if (openingFiles.length > 1) {
|
|
32
35
|
opening = "invalid";
|
|
33
|
-
issue(issues, "invalid_opening", "opening.md",
|
|
36
|
+
issue(issues, "invalid_opening", "opening.md", `A ${subject} may contain only one root-level opening.md file`);
|
|
34
37
|
}
|
|
35
38
|
else {
|
|
36
39
|
const openingFile = openingFiles[0];
|
|
@@ -48,19 +51,19 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
48
51
|
for (const file of snapshotFiles) {
|
|
49
52
|
if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
|
|
50
53
|
unsupportedPaths.add(file.path);
|
|
51
|
-
issue(issues, "unsupported_content_file", file.path,
|
|
54
|
+
issue(issues, "unsupported_content_file", file.path, `The current ${subject} does not accept manifest, seven-field JSON, schema, record, or revision files`);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
issues.push(...worldDocumentSnapshot.diagnostics
|
|
55
58
|
.filter(({ logicalPath }) => logicalPath === undefined || !unsupportedPaths.has(logicalPath))
|
|
56
|
-
.map(worldDocumentIssue));
|
|
59
|
+
.map((diagnostic) => worldDocumentIssue(diagnostic, worldDocumentSnapshot.logicalRoot)));
|
|
57
60
|
const index = documentIndex(documents);
|
|
58
61
|
const frameSource = textFiles.get("control/frame.yaml");
|
|
59
62
|
let worldFrame = "missing";
|
|
60
63
|
let currentSituation = "missing";
|
|
61
64
|
if (frameSource === undefined) {
|
|
62
|
-
issue(issues, "missing_world_frame", "control/frame.yaml",
|
|
63
|
-
issue(issues, "missing_current_situation", "control/frame.yaml",
|
|
65
|
+
issue(issues, "missing_world_frame", "control/frame.yaml", `The ${subject} is missing its world prompt frame`);
|
|
66
|
+
issue(issues, "missing_current_situation", "control/frame.yaml", `The ${subject} has not bound a current-situation document`);
|
|
64
67
|
}
|
|
65
68
|
else {
|
|
66
69
|
const frame = parseRestrictedYaml("control/frame.yaml", frameSource, issues);
|
|
@@ -97,7 +100,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
|
|
|
97
100
|
const playerSource = textFiles.get("control/player-views.yaml");
|
|
98
101
|
let playerView = "missing";
|
|
99
102
|
if (playerSource === undefined) {
|
|
100
|
-
issue(issues, "missing_player_view", "control/player-views.yaml",
|
|
103
|
+
issue(issues, "missing_player_view", "control/player-views.yaml", `The ${subject} is missing its player-view control file`);
|
|
101
104
|
}
|
|
102
105
|
else {
|
|
103
106
|
const player = parseRestrictedYaml("control/player-views.yaml", playerSource, issues);
|
|
@@ -218,10 +221,10 @@ function contentDocument(document) {
|
|
|
218
221
|
codec: document.codec,
|
|
219
222
|
};
|
|
220
223
|
}
|
|
221
|
-
function worldDocumentIssue(diagnostic) {
|
|
224
|
+
function worldDocumentIssue(diagnostic, root) {
|
|
222
225
|
return {
|
|
223
226
|
code: contentIssueCode(diagnostic),
|
|
224
|
-
path: diagnostic.logicalPath ??
|
|
227
|
+
path: diagnostic.logicalPath ?? root,
|
|
225
228
|
message: diagnostic.code === "document_reference_invalid"
|
|
226
229
|
? "An explicit $ref points to a missing document, or the target identity is not unique in the snapshot"
|
|
227
230
|
: diagnostic.message,
|
|
@@ -430,9 +433,9 @@ function validateFrameContext(value, index, snapshot, issues) {
|
|
|
430
433
|
}
|
|
431
434
|
if (entry.slot.kind === "catalog" &&
|
|
432
435
|
entry.slot.required !== false &&
|
|
433
|
-
catalogDocuments(entry.slot.directory, index.documents(),
|
|
436
|
+
catalogDocuments(entry.slot.directory, index.documents(), snapshot.logicalRoot).length === 0) {
|
|
434
437
|
const directory = entry.slot.directory;
|
|
435
|
-
issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named
|
|
438
|
+
issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named ${snapshot.logicalRoot}/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
|
|
436
439
|
continue;
|
|
437
440
|
}
|
|
438
441
|
kinds.push(entry.slot.kind);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Root arguments are depth 0; parsing and descent stop after two levels. */
|
|
2
|
+
const maxCoercionDepth = 2;
|
|
3
|
+
export function coerceToolArguments(schema, args) {
|
|
4
|
+
const coerced = [];
|
|
5
|
+
const value = coerceValue(schema, args, "", 0, coerced);
|
|
6
|
+
return { arguments: value, coerced };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Renders the Runtime note appended to a tool result whose arguments were
|
|
10
|
+
* parsed. It tells the model its arguments were repaired, so the next call
|
|
11
|
+
* can send native JSON values.
|
|
12
|
+
*/
|
|
13
|
+
export function renderCoercionNote(paths, locale) {
|
|
14
|
+
const names = paths.map((path) => `"${path}"`).join(", ");
|
|
15
|
+
return locale === "zh-CN"
|
|
16
|
+
? `Runtime 说明:以下参数以 JSON 字符串到达,本次已解析后执行:${names}。请以 JSON 数组或对象的原生值发送,不要用字符串。`
|
|
17
|
+
: `Runtime note: these arguments arrived as JSON strings and were parsed for this call: ${names}. Send array and object arguments as native JSON values, not strings.`;
|
|
18
|
+
}
|
|
19
|
+
function coerceValue(schema, value, path, depth, coerced) {
|
|
20
|
+
const type = definiteType(schema);
|
|
21
|
+
let current = value;
|
|
22
|
+
if ((type === "array" || type === "object") &&
|
|
23
|
+
typeof current === "string" &&
|
|
24
|
+
depth > 0 &&
|
|
25
|
+
depth <= maxCoercionDepth) {
|
|
26
|
+
const parsed = parseJson(current);
|
|
27
|
+
if (matchesType(type, parsed)) {
|
|
28
|
+
current = parsed;
|
|
29
|
+
coerced.push(path);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (depth >= maxCoercionDepth)
|
|
33
|
+
return current;
|
|
34
|
+
if (type === "array" && isArray(current)) {
|
|
35
|
+
const items = itemsSchema(schema);
|
|
36
|
+
if (items === undefined)
|
|
37
|
+
return current;
|
|
38
|
+
let next = current;
|
|
39
|
+
current.forEach((element, index) => {
|
|
40
|
+
const repaired = coerceValue(items, element, `${path}[${index}]`, depth + 1, coerced);
|
|
41
|
+
if (repaired !== element) {
|
|
42
|
+
if (next === current)
|
|
43
|
+
next = [...current];
|
|
44
|
+
next[index] = repaired;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
if (type === "object" && isRecord(current)) {
|
|
50
|
+
let next = current;
|
|
51
|
+
for (const key of Object.keys(current)) {
|
|
52
|
+
const property = propertySchema(schema, key);
|
|
53
|
+
if (property === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
const repaired = coerceValue(property, current[key], path === "" ? key : `${path}.${key}`, depth + 1, coerced);
|
|
56
|
+
if (repaired !== current[key]) {
|
|
57
|
+
if (next === current)
|
|
58
|
+
next = { ...current };
|
|
59
|
+
next[key] = repaired;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
/** The single type a schema position admits, or undefined when it is not definite. */
|
|
67
|
+
function definiteType(schema) {
|
|
68
|
+
if (!isRecord(schema))
|
|
69
|
+
return undefined;
|
|
70
|
+
if (schema.type !== undefined) {
|
|
71
|
+
if (typeof schema.type === "string")
|
|
72
|
+
return schema.type;
|
|
73
|
+
if (Array.isArray(schema.type) &&
|
|
74
|
+
schema.type.length === 1 &&
|
|
75
|
+
typeof schema.type[0] === "string")
|
|
76
|
+
return schema.type[0];
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const branches = Array.isArray(schema.oneOf)
|
|
80
|
+
? schema.oneOf
|
|
81
|
+
: Array.isArray(schema.anyOf)
|
|
82
|
+
? schema.anyOf
|
|
83
|
+
: undefined;
|
|
84
|
+
if (branches === undefined || branches.length === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
const types = branches.map(definiteType);
|
|
87
|
+
const first = types[0];
|
|
88
|
+
return first !== undefined && types.every((type) => type === first)
|
|
89
|
+
? first
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
function itemsSchema(schema) {
|
|
93
|
+
return isRecord(schema.items) && !Array.isArray(schema.items)
|
|
94
|
+
? schema.items
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A property schema is taken directly from `properties`, or from oneOf/anyOf
|
|
99
|
+
* branches only when every branch that declares the key declares it the same.
|
|
100
|
+
*/
|
|
101
|
+
function propertySchema(schema, key) {
|
|
102
|
+
if (isRecord(schema.properties) && isRecord(schema.properties[key]))
|
|
103
|
+
return schema.properties[key];
|
|
104
|
+
const branches = Array.isArray(schema.oneOf)
|
|
105
|
+
? schema.oneOf
|
|
106
|
+
: Array.isArray(schema.anyOf)
|
|
107
|
+
? schema.anyOf
|
|
108
|
+
: [];
|
|
109
|
+
const declared = branches
|
|
110
|
+
.map((branch) => isRecord(branch) && isRecord(branch.properties)
|
|
111
|
+
? branch.properties[key]
|
|
112
|
+
: undefined)
|
|
113
|
+
.filter((property) => property !== undefined);
|
|
114
|
+
if (declared.length === 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const serialized = declared.map((property) => JSON.stringify(property));
|
|
117
|
+
return serialized.every((text) => text === serialized[0]) &&
|
|
118
|
+
isRecord(declared[0])
|
|
119
|
+
? declared[0]
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
|
122
|
+
function parseJson(text) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function matchesType(type, value) {
|
|
131
|
+
return type === "array" ? Array.isArray(value) : isRecord(value);
|
|
132
|
+
}
|
|
133
|
+
function isArray(value) {
|
|
134
|
+
return Array.isArray(value);
|
|
135
|
+
}
|
|
136
|
+
function isRecord(value) {
|
|
137
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
138
|
+
}
|
|
@@ -2,7 +2,6 @@ import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { isDeepStrictEqual } from "node:util";
|
|
6
5
|
/** Written last by the released-record cleanup (ADR-0034); its absence means "not yet run". */
|
|
7
6
|
const LAYOUT_MARKER = "play-advance-layout.json";
|
|
8
7
|
/**
|
|
@@ -19,15 +18,16 @@ export class FileNativePlayAdvanceStore {
|
|
|
19
18
|
constructor(dataRoot) {
|
|
20
19
|
this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
|
|
21
20
|
}
|
|
22
|
-
async begin(
|
|
23
|
-
assertBase(
|
|
24
|
-
const
|
|
21
|
+
async begin(advance) {
|
|
22
|
+
assertBase(advance);
|
|
23
|
+
const marker = persistedAdvance(advance);
|
|
24
|
+
const current = await this.readCurrent(marker.worldId, marker.chainId);
|
|
25
25
|
if (current !== null) {
|
|
26
|
-
if (!
|
|
26
|
+
if (!sameAdvance(current, marker))
|
|
27
27
|
throw new Error("Another play advance is still awaiting settlement");
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
-
await publishJson(this.#markerPath(
|
|
30
|
+
await publishJson(this.#markerPath(marker.worldId, marker.chainId), marker);
|
|
31
31
|
}
|
|
32
32
|
async readCurrent(worldId, chainId) {
|
|
33
33
|
assertIdentity(worldId, "World ID");
|
|
@@ -40,13 +40,15 @@ export class FileNativePlayAdvanceStore {
|
|
|
40
40
|
throw new Error("Play advance marker belongs to another context");
|
|
41
41
|
return value;
|
|
42
42
|
}
|
|
43
|
-
async markSettled(
|
|
44
|
-
const current = await this.readCurrent(
|
|
43
|
+
async markSettled(advance) {
|
|
44
|
+
const current = await this.readCurrent(advance.worldId, advance.chainId);
|
|
45
45
|
if (current === null)
|
|
46
46
|
return;
|
|
47
|
-
if (!
|
|
47
|
+
if (!sameAdvance(current, advance))
|
|
48
48
|
throw new Error("Play advance is no longer current");
|
|
49
|
-
await rm(this.#markerPath(
|
|
49
|
+
await rm(this.#markerPath(advance.worldId, advance.chainId), {
|
|
50
|
+
force: true,
|
|
51
|
+
});
|
|
50
52
|
}
|
|
51
53
|
/** Remove the in-progress marker and all advance records of a discarded chain. */
|
|
52
54
|
async discardChain(worldId, chainId) {
|
|
@@ -103,12 +105,25 @@ export class FileNativePlayAdvanceStore {
|
|
|
103
105
|
return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
108
|
+
/**
|
|
109
|
+
* Whether two advances are the same one. The marker is confirmed by these
|
|
110
|
+
* identity fields alone; the frozen request is never compared (ADR-0044).
|
|
111
|
+
*/
|
|
112
|
+
function sameAdvance(left, right) {
|
|
113
|
+
return (left.advanceKind === right.advanceKind &&
|
|
114
|
+
left.worldId === right.worldId &&
|
|
115
|
+
left.chainId === right.chainId &&
|
|
116
|
+
left.advanceId === right.advanceId &&
|
|
117
|
+
left.operationId === right.operationId &&
|
|
118
|
+
left.eventId === right.eventId);
|
|
119
|
+
}
|
|
120
|
+
/** The marker form of an advance: a player advance's frozen request stays in memory. */
|
|
121
|
+
function persistedAdvance(advance) {
|
|
122
|
+
if (!("nextRequest" in advance))
|
|
123
|
+
return advance;
|
|
124
|
+
const { nextRequest: _nextRequest, ...marker } = advance;
|
|
125
|
+
void _nextRequest;
|
|
126
|
+
return marker;
|
|
112
127
|
}
|
|
113
128
|
function assertBase(value) {
|
|
114
129
|
if (!isRecord(value) ||
|
|
@@ -137,9 +152,10 @@ function assertBase(value) {
|
|
|
137
152
|
!Number.isSafeInteger(value.transcriptStart) ||
|
|
138
153
|
Number(value.transcriptStart) < 0 ||
|
|
139
154
|
!Number.isSafeInteger(value.exchange) ||
|
|
140
|
-
Number(value.exchange) < 1
|
|
141
|
-
!isRecord(value.nextRequest))
|
|
155
|
+
Number(value.exchange) < 1)
|
|
142
156
|
throw new Error("Player advance has an invalid shape");
|
|
157
|
+
// A marker released before ADR-0044 may still carry `nextRequest`. Recovery
|
|
158
|
+
// never reads it, so the field is tolerated and not validated.
|
|
143
159
|
}
|
|
144
160
|
else if (!Number.isSafeInteger(value.exchange) ||
|
|
145
161
|
Number(value.exchange) < 0 ||
|