narraeon 0.7.7 → 0.7.9
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/runtime/authoring/AuthoringRequestSnapshot.js +14 -1
- package/dist/node/runtime/history/HistoryRendering.js +25 -6
- package/dist/node/runtime/history/HistoryTools.js +31 -0
- package/dist/node/runtime/play/PlayCallChain.js +4 -9
- package/dist/node/runtime/play/PlayDocumentTools.js +35 -37
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +33 -17
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +5 -9
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +326 -62
- package/dist/node/runtime/setting/SettingImprovementSession.js +40 -3
- package/dist/node/runtime/world/FileNativeWorldStore.js +13 -3
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +9 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +18 -2
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +61 -17
- package/dist/node/runtime/world-revision/LegacyWorldRevisionEpochUpgrade.js +46 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +83 -30
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +64 -4
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +32 -11
- package/dist/node/shared/default-setting-improvement-prompt.js +23 -8
- package/dist/node/shared/ordered-author-prompts.js +9 -7
- package/dist/web/assets/index-B2kbaOGe.js +1408 -0
- package/dist/web/assets/{index-BjxBYYQi.css → index-DH2BVsYd.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Da0tdDij.js +0 -1408
|
@@ -4,9 +4,10 @@ import { InvalidContentTreeError, } from "../content/ContentWorkspace.js";
|
|
|
4
4
|
import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
|
|
5
5
|
import { equalModelHostBinding } from "../model/ModelHost.js";
|
|
6
6
|
import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
|
|
7
|
+
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
7
8
|
import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
|
|
8
9
|
import { summarizeStoredSettingImprovementSession, } from "./FileNativeSettingImprovementStore.js";
|
|
9
|
-
import { SettingAuthoringTransaction, settingImprovementRuntimeContract, settingImprovementToolDefinitions, } from "./SettingAuthoringTransaction.js";
|
|
10
|
+
import { SettingAuthoringTransaction, settingImprovementRuntimeContract, settingImprovementToolDefinitions, authoringPreloadAuthorization, buildAuthoringPreload, contentPackageAuthoringSurface, } from "./SettingAuthoringTransaction.js";
|
|
10
11
|
class RecoveryWriteConflictError extends Error {
|
|
11
12
|
}
|
|
12
13
|
export class SettingImprovementRollbackError extends Error {
|
|
@@ -186,7 +187,7 @@ export class SettingImprovementSession {
|
|
|
186
187
|
assertPackage(session, input.packageId);
|
|
187
188
|
}
|
|
188
189
|
return this.#startRequest(session, requestId, async (next) => {
|
|
189
|
-
const { playPreset, bootstrap } = await this.#compileCurrent(input.packageId, next.modelBinding);
|
|
190
|
+
const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.packageId, next.modelBinding);
|
|
190
191
|
next.schemaVersion = 3;
|
|
191
192
|
next.requests ??= [];
|
|
192
193
|
next.requests.push({
|
|
@@ -194,7 +195,9 @@ export class SettingImprovementSession {
|
|
|
194
195
|
modelItemStart: next.modelItems.length,
|
|
195
196
|
bootstrap,
|
|
196
197
|
playPreset: structuredClone(playPreset),
|
|
198
|
+
preload: structuredClone(preload.record),
|
|
197
199
|
});
|
|
200
|
+
next.authorization = authoringPreloadAuthorization(next.authorization, preload.record);
|
|
198
201
|
appendAuthoringUserMessage(next, message, requestId);
|
|
199
202
|
});
|
|
200
203
|
});
|
|
@@ -244,6 +247,7 @@ export class SettingImprovementSession {
|
|
|
244
247
|
const playPreset = await this.#bindPlayPreset();
|
|
245
248
|
const locale = this.#locale();
|
|
246
249
|
const package_ = await this.#content.readCurrentTreeContentPackage(packageId);
|
|
250
|
+
const preload = this.#preload(package_.files, modelBinding, playPreset, locale);
|
|
247
251
|
const bootstrap = this.#compiler.compileSettingImprovement({
|
|
248
252
|
contentPackageTitle: package_.title,
|
|
249
253
|
gameplay: package_.gameplay,
|
|
@@ -252,8 +256,41 @@ export class SettingImprovementSession {
|
|
|
252
256
|
playPreset,
|
|
253
257
|
modelBinding: modelBinding,
|
|
254
258
|
tools: settingImprovementToolDefinitions(locale, package_.gameplay),
|
|
259
|
+
preload: preload.blocks,
|
|
260
|
+
});
|
|
261
|
+
return { bootstrap, playPreset, preload };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Preloads the control documents and the documents the package frame injects.
|
|
265
|
+
* A preview that cannot be built degrades to the directory and control files.
|
|
266
|
+
*/
|
|
267
|
+
#preload(files, modelBinding, playPreset, locale) {
|
|
268
|
+
const revision = contentTreeFingerprint(files);
|
|
269
|
+
const snapshot = WorldDocumentStore.open({
|
|
270
|
+
layout: "content_package",
|
|
271
|
+
files: [...files],
|
|
272
|
+
});
|
|
273
|
+
let injected = [];
|
|
274
|
+
let injectedUnavailable;
|
|
275
|
+
try {
|
|
276
|
+
const preview = this.#preview(snapshot, modelBinding, playPreset);
|
|
277
|
+
injected = preview.compilation.coverage.flatMap(({ slot, status, readAuthorization }) => status === "resolved" && readAuthorization !== undefined
|
|
278
|
+
? [{ shortRef: readAuthorization.shortRef, slot }]
|
|
279
|
+
: []);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
injectedUnavailable =
|
|
283
|
+
error instanceof Error ? error.message : "unknown preview failure";
|
|
284
|
+
}
|
|
285
|
+
return buildAuthoringPreload({
|
|
286
|
+
snapshot,
|
|
287
|
+
surface: contentPackageAuthoringSurface,
|
|
288
|
+
locale,
|
|
289
|
+
revision,
|
|
290
|
+
sourcePrefix: "content-package:preload",
|
|
291
|
+
injected,
|
|
292
|
+
...(injectedUnavailable === undefined ? {} : { injectedUnavailable }),
|
|
255
293
|
});
|
|
256
|
-
return { bootstrap, playPreset };
|
|
257
294
|
}
|
|
258
295
|
async cancel(sessionId) {
|
|
259
296
|
const running = this.#conversation.cancel(sessionId);
|
|
@@ -1114,10 +1114,20 @@ export class FileNativeWorldStore {
|
|
|
1114
1114
|
assertRelativePath(change.relativePath);
|
|
1115
1115
|
const previous = candidateState.get(change.relativePath);
|
|
1116
1116
|
const previousHash = previous === undefined ? null : sha256(previous);
|
|
1117
|
-
|
|
1118
|
-
|
|
1117
|
+
const nextBytes = change.canonicalNextBytes;
|
|
1118
|
+
const consistent = change.kind === "delete"
|
|
1119
|
+
? previous !== undefined &&
|
|
1120
|
+
nextBytes === null &&
|
|
1121
|
+
change.nextHash === null
|
|
1122
|
+
: nextBytes !== null &&
|
|
1123
|
+
change.nextHash !== null &&
|
|
1124
|
+
sha256(nextBytes) === change.nextHash;
|
|
1125
|
+
if (previousHash !== change.expectedPreviousHash || !consistent)
|
|
1119
1126
|
throw new FileNativeWorldCreationError("operation_conflict", `State-change hash conflicts: ${change.relativePath}`);
|
|
1120
|
-
|
|
1127
|
+
if (nextBytes === null)
|
|
1128
|
+
candidateState.delete(change.relativePath);
|
|
1129
|
+
else
|
|
1130
|
+
candidateState.set(change.relativePath, nextBytes);
|
|
1121
1131
|
}
|
|
1122
1132
|
const inspection = inspectContentPackageCurrentTree([
|
|
1123
1133
|
...[...candidateState].map(([path, contents]) => ({
|
|
@@ -87,10 +87,15 @@ export async function readDocumentMaintenanceFacts(authority, head, cache) {
|
|
|
87
87
|
continue;
|
|
88
88
|
const changed = await Promise.all(commit.stateChanges.map(async (change) => ({
|
|
89
89
|
change,
|
|
90
|
-
contents:
|
|
90
|
+
contents: change.kind === "delete"
|
|
91
|
+
? null
|
|
92
|
+
: await authority.readStateChangeContents(commit, change),
|
|
91
93
|
})));
|
|
92
94
|
for (const { change, contents } of changed)
|
|
93
|
-
|
|
95
|
+
if (contents === null)
|
|
96
|
+
files.delete(change.relativePath);
|
|
97
|
+
else
|
|
98
|
+
files.set(change.relativePath, contents);
|
|
94
99
|
const next = snapshot();
|
|
95
100
|
const position = {
|
|
96
101
|
kind: isCorrectionCommit(commit.mode)
|
|
@@ -103,6 +108,8 @@ export async function readDocumentMaintenanceFacts(authority, head, cache) {
|
|
|
103
108
|
worldTime: commit.worldClock ?? null,
|
|
104
109
|
};
|
|
105
110
|
for (const { change } of changed) {
|
|
111
|
+
if (change.kind === "delete")
|
|
112
|
+
continue;
|
|
106
113
|
const ref = change.stableShortRef;
|
|
107
114
|
const before = previous.query({
|
|
108
115
|
kind: "read_document",
|
|
@@ -213,7 +213,14 @@ export class WorldLedgerAuthority {
|
|
|
213
213
|
/** 某一轮修改的状态文件内容:即该轮结束时的文件,并校验哈希。 */
|
|
214
214
|
async readStateChangeContents(commit, change) {
|
|
215
215
|
const contents = await readFileAt(this.#root, commit.sequence, `state/${change.relativePath}`);
|
|
216
|
-
if (
|
|
216
|
+
if (change.kind === "delete") {
|
|
217
|
+
if (contents !== null)
|
|
218
|
+
throw new WorldAuthorityError("corrupt", `Deleted state document still exists in its commit: ${change.relativePath}`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
if (change.nextHash === null ||
|
|
222
|
+
contents === null ||
|
|
223
|
+
sha256Hex(contents) !== change.nextHash)
|
|
217
224
|
throw new WorldAuthorityError("corrupt", `State change content conflicts with its commit: ${change.relativePath}`);
|
|
218
225
|
return contents;
|
|
219
226
|
}
|
|
@@ -297,7 +304,16 @@ export class WorldLedgerAuthority {
|
|
|
297
304
|
if ((previous === undefined ? null : sha256Hex(previous)) !==
|
|
298
305
|
change.expectedPreviousHash)
|
|
299
306
|
throw new WorldAuthorityError("conflict", `State-change previous hash conflicts: ${change.relativePath}`);
|
|
300
|
-
if (
|
|
307
|
+
if (change.kind === "delete") {
|
|
308
|
+
if (previous === undefined ||
|
|
309
|
+
change.nextHash !== null ||
|
|
310
|
+
change.canonicalNextBytes !== null)
|
|
311
|
+
throw new WorldAuthorityError("conflict", `State-change delete conflicts: ${change.relativePath}`);
|
|
312
|
+
changes.push({ path: `state/${change.relativePath}`, content: null });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (change.canonicalNextBytes === null ||
|
|
316
|
+
sha256Hex(change.canonicalNextBytes) !== change.nextHash)
|
|
301
317
|
throw new WorldAuthorityError("conflict", `State-change next hash conflicts: ${change.relativePath}`);
|
|
302
318
|
changes.push({
|
|
303
319
|
path: `state/${change.relativePath}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validLogicalMessages } from "../prompt/PromptCompilationCodec.js";
|
|
2
|
+
import { upgradeLegacyEpochs } from "./LegacyWorldRevisionEpochUpgrade.js";
|
|
2
3
|
import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
|
|
3
4
|
import { validAuthoringRequests, } from "../authoring/AuthoringRequestSnapshot.js";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -15,11 +16,25 @@ const fingerprintPattern = /^sha256:[0-9a-f]{64}$/u;
|
|
|
15
16
|
const treeRevisionPattern = /^[0-9a-f]{64}$/u;
|
|
16
17
|
const epochName = new RegExp(`^revision-${uuidV4Pattern}\\.json$`, "u");
|
|
17
18
|
const sessionName = new RegExp(`^world-session-${uuidV4Pattern}\\.json$`, "u");
|
|
19
|
+
/**
|
|
20
|
+
* A world holds an apply that v0.7.4 interrupted after its state commit but
|
|
21
|
+
* before control was published. Current code cannot finish it without replaying
|
|
22
|
+
* the operation, so the world must be opened once with v0.7.4.
|
|
23
|
+
*/
|
|
24
|
+
export class UnsupportedLegacyWorldRevisionError extends Error {
|
|
25
|
+
code = "unsupported_legacy_world_revision";
|
|
26
|
+
constructor(worldId) {
|
|
27
|
+
super(`World ${worldId} has a world-revision apply interrupted by v0.7.4; open it once with v0.7.4 to finish the apply`);
|
|
28
|
+
this.name = "UnsupportedLegacyWorldRevisionError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
18
31
|
/** File-native persistence for revision worktrees and their conversations. */
|
|
19
32
|
export class FileNativeWorldRevisionStore {
|
|
20
33
|
#epochsRoot;
|
|
21
34
|
#sessionsRoot;
|
|
22
35
|
#mutationTail = Promise.resolve();
|
|
36
|
+
/** Epoch ID → world ID for v0.7.4 epochs that cannot be upgraded here. */
|
|
37
|
+
#legacyUpgrade = null;
|
|
23
38
|
constructor(dataRoot) {
|
|
24
39
|
const root = join(resolve(dataRoot), "world-revisions");
|
|
25
40
|
this.#epochsRoot = join(root, "epochs");
|
|
@@ -35,7 +50,12 @@ export class FileNativeWorldRevisionStore {
|
|
|
35
50
|
return `change-set:${randomUUID()}`;
|
|
36
51
|
}
|
|
37
52
|
readEpoch(epochId) {
|
|
38
|
-
return this.#afterMutations(() =>
|
|
53
|
+
return this.#afterMutations(async () => {
|
|
54
|
+
const blocked = (await this.#upgradeLegacyEpochs()).get(epochId);
|
|
55
|
+
if (blocked !== undefined)
|
|
56
|
+
throw new UnsupportedLegacyWorldRevisionError(blocked);
|
|
57
|
+
return this.#readEpoch(epochId);
|
|
58
|
+
});
|
|
39
59
|
}
|
|
40
60
|
async readEpochIfPresent(epochId) {
|
|
41
61
|
try {
|
|
@@ -55,20 +75,15 @@ export class FileNativeWorldRevisionStore {
|
|
|
55
75
|
}
|
|
56
76
|
async listEpochs(worldId) {
|
|
57
77
|
return this.#afterMutations(async () => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}).catch((error) => {
|
|
61
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
62
|
-
return [];
|
|
63
|
-
throw error;
|
|
64
|
-
});
|
|
78
|
+
if ([...(await this.#upgradeLegacyEpochs()).values()].includes(worldId))
|
|
79
|
+
throw new UnsupportedLegacyWorldRevisionError(worldId);
|
|
65
80
|
const epochs = [];
|
|
66
|
-
for (const
|
|
67
|
-
|
|
81
|
+
for (const epochId of await epochIds(this.#epochsRoot)) {
|
|
82
|
+
// Only this world's epochs are validated, so a damaged epoch elsewhere does not fail it.
|
|
83
|
+
const value = JSON.parse(await readFile(this.#epochPath(epochId), "utf8"));
|
|
84
|
+
if (!isRecord(value) || value.worldId !== worldId)
|
|
68
85
|
continue;
|
|
69
|
-
|
|
70
|
-
if (epoch.worldId === worldId)
|
|
71
|
-
epochs.push(epoch);
|
|
86
|
+
epochs.push(epochFromValue(epochId, value));
|
|
72
87
|
}
|
|
73
88
|
return epochs.sort(compareUpdated);
|
|
74
89
|
});
|
|
@@ -116,10 +131,23 @@ export class FileNativeWorldRevisionStore {
|
|
|
116
131
|
async #readEpoch(epochId) {
|
|
117
132
|
assertEpochId(epochId);
|
|
118
133
|
const value = JSON.parse(await readFile(this.#epochPath(epochId), "utf8"));
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
return epochFromValue(epochId, value);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Runs once per store before its first epoch read or list, and is shared
|
|
138
|
+
* after that. Every v0.7.4 epoch is upgraded in place; others are untouched.
|
|
139
|
+
*/
|
|
140
|
+
#upgradeLegacyEpochs() {
|
|
141
|
+
this.#legacyUpgrade ??= this.#mutate(async () => upgradeLegacyEpochs({
|
|
142
|
+
epochsRoot: this.#epochsRoot,
|
|
143
|
+
epochIds: await epochIds(this.#epochsRoot),
|
|
144
|
+
validate: validateEpoch,
|
|
145
|
+
publish: publishJson,
|
|
146
|
+
})).catch((error) => {
|
|
147
|
+
this.#legacyUpgrade = null;
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
150
|
+
return this.#legacyUpgrade;
|
|
123
151
|
}
|
|
124
152
|
async #readSession(sessionId) {
|
|
125
153
|
assertSessionId(sessionId);
|
|
@@ -156,6 +184,22 @@ export class FileNativeWorldRevisionStore {
|
|
|
156
184
|
return join(this.#sessionsRoot, `${sessionId}.json`);
|
|
157
185
|
}
|
|
158
186
|
}
|
|
187
|
+
async function epochIds(epochsRoot) {
|
|
188
|
+
const entries = await readdir(epochsRoot, { withFileTypes: true }).catch((error) => {
|
|
189
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
190
|
+
return [];
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
return entries
|
|
194
|
+
.filter((entry) => entry.isFile() && epochName.test(entry.name))
|
|
195
|
+
.map((entry) => entry.name.slice(0, -5));
|
|
196
|
+
}
|
|
197
|
+
function epochFromValue(epochId, value) {
|
|
198
|
+
const epoch = validateEpoch(value);
|
|
199
|
+
if (epoch.epochId !== epochId)
|
|
200
|
+
throw new Error("World-revision epoch identity does not match its file");
|
|
201
|
+
return epoch;
|
|
202
|
+
}
|
|
159
203
|
function validateEpoch(value) {
|
|
160
204
|
if (!isRecord(value) ||
|
|
161
205
|
!hasExactKeys(value, [
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Rewrites v0.7.4 epochs whose control was published into the current
|
|
5
|
+
* `committed` phase. Returns the world of each v0.7.4 epoch left in
|
|
6
|
+
* `state_committed`, which cannot be finished here. Files that do not match a
|
|
7
|
+
* v0.7.4 schema, or cannot be parsed, are left as they are.
|
|
8
|
+
*/
|
|
9
|
+
export async function upgradeLegacyEpochs(input) {
|
|
10
|
+
const blocked = new Map();
|
|
11
|
+
for (const epochId of input.epochIds) {
|
|
12
|
+
const path = join(input.epochsRoot, `${epochId}.json`);
|
|
13
|
+
const value = await readFile(path, "utf8")
|
|
14
|
+
.then((text) => JSON.parse(text))
|
|
15
|
+
.catch(() => null);
|
|
16
|
+
if (!isRecord(value) || !isRecord(value.apply))
|
|
17
|
+
continue;
|
|
18
|
+
if (value.apply.phase === "control_published") {
|
|
19
|
+
const upgraded = epochWithApplyPhase(input, value, "committed");
|
|
20
|
+
if (upgraded !== null)
|
|
21
|
+
await input.publish(path, upgraded);
|
|
22
|
+
}
|
|
23
|
+
else if (value.apply.phase === "state_committed" &&
|
|
24
|
+
value.lifecycle === "applying") {
|
|
25
|
+
const legacy = epochWithApplyPhase(input, value, "committed");
|
|
26
|
+
if (legacy !== null)
|
|
27
|
+
blocked.set(epochId, legacy.worldId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return blocked;
|
|
31
|
+
}
|
|
32
|
+
/** The epoch with its apply phase replaced, if that makes it a valid current epoch. */
|
|
33
|
+
function epochWithApplyPhase(input, value, phase) {
|
|
34
|
+
try {
|
|
35
|
+
return input.validate({
|
|
36
|
+
...value,
|
|
37
|
+
apply: { ...value.apply, phase },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
|
|
2
|
+
import { defaultAppLocale, } from "../../protocol/appPreferences.js";
|
|
2
3
|
import { assertSameGameplay, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
|
|
3
4
|
import { inspectContentPackageCurrentTree } from "../content/FileNativeContentTree.js";
|
|
4
|
-
import {
|
|
5
|
+
import { createHistoryQuery, historyToolNames, isHistoryToolName, runHistoryTool, } from "../history/HistoryTools.js";
|
|
6
|
+
import { runtimeToolsForNames } from "../prompt/FileNativeToolRegistry.js";
|
|
7
|
+
import { authoringToolDefinitions, SettingAuthoringTransaction, settingDocumentDeletionBlockers, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
|
|
5
8
|
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
6
9
|
/**
|
|
7
10
|
* The authoring transaction over a playing world's state/control worktree.
|
|
@@ -10,10 +13,15 @@ import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
|
10
13
|
*/
|
|
11
14
|
export class WorldRevisionAuthoringTransaction {
|
|
12
15
|
#inner;
|
|
16
|
+
#history;
|
|
17
|
+
#locale;
|
|
13
18
|
constructor(input) {
|
|
19
|
+
this.#locale = input.locale ?? defaultAppLocale;
|
|
20
|
+
this.#history = createHistoryQuery(input.history.scope, input.history.entries);
|
|
14
21
|
this.#inner = new SettingAuthoringTransaction({
|
|
15
22
|
baseFiles: input.baseFiles,
|
|
16
23
|
surface: worldRevisionAuthoringSurface,
|
|
24
|
+
additionalToolNames: historyToolNames,
|
|
17
25
|
...(input.locale === undefined ? {} : { locale: input.locale }),
|
|
18
26
|
...(input.revision === undefined ? {} : { revision: input.revision }),
|
|
19
27
|
...(input.authorization === undefined
|
|
@@ -35,12 +43,33 @@ export class WorldRevisionAuthoringTransaction {
|
|
|
35
43
|
authorization(revision) {
|
|
36
44
|
return this.#inner.authorization(revision);
|
|
37
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* History calls are read-only: they never reach the worktree, the change
|
|
48
|
+
* history or the read authorizations. Write calls run as one batch so the
|
|
49
|
+
* automatic review still follows the last successful write, and their
|
|
50
|
+
* results return to the positions of their calls.
|
|
51
|
+
*/
|
|
38
52
|
execute(calls) {
|
|
39
|
-
|
|
53
|
+
const settled = this.#inner.execute(calls.filter(({ name }) => !isHistoryToolName(name)));
|
|
54
|
+
let next = 0;
|
|
55
|
+
return calls.map((call) => {
|
|
56
|
+
if (!isHistoryToolName(call.name))
|
|
57
|
+
return settled[next++];
|
|
58
|
+
const result = runHistoryTool(this.#history, call, this.#locale);
|
|
59
|
+
return {
|
|
60
|
+
toolCallId: call.id,
|
|
61
|
+
markdown: result.markdown,
|
|
62
|
+
isError: !result.ok,
|
|
63
|
+
changes: [],
|
|
64
|
+
};
|
|
65
|
+
});
|
|
40
66
|
}
|
|
41
67
|
}
|
|
42
68
|
export function worldRevisionToolDefinitions(locale, gameplay) {
|
|
43
|
-
return
|
|
69
|
+
return [
|
|
70
|
+
...authoringToolDefinitions(worldRevisionAuthoringSurface, locale, gameplay),
|
|
71
|
+
...runtimeToolsForNames(historyToolNames, locale),
|
|
72
|
+
];
|
|
44
73
|
}
|
|
45
74
|
export function worldRevisionRuntimeContract(locale, gameplay) {
|
|
46
75
|
// A world's mode and scheme are fixed, so only tabletop worlds mention the file.
|
|
@@ -51,18 +80,20 @@ export function worldRevisionRuntimeContract(locale, gameplay) {
|
|
|
51
80
|
- 你正在修改一个已经开始游玩的世界,而不是创建世界所用的内容包。修订期间 Runtime 持有持久独占锁,游玩和其他世界写入均被禁止。
|
|
52
81
|
- state/* 与 control/* 共同组成唯一的修订工作树;手动编辑和 AI 工具写入落到同一棵树,也进入同一份可回滚改动历史。opening.md 已经作为创世叙事提交,不能读取或修改。
|
|
53
82
|
- 工具调用修改的是尚未应用的工作树。只有玩家点击“应用修订”才会把当前整棵树提交为新的世界状态和控制并解除锁;“放弃修订”会丢弃工作树并解除锁。你不得宣称自己已经应用或放弃修订。
|
|
83
|
+
- history_list、history_search、history_read 只读导航这个世界已提交的历史原文(开场白如存在也包括在内)。历史是已经发生的证据,用于核对故事中的事实与因果,不能通过修订改写、删除或追加;历史调用不产生改动、不进入回滚历史,也不改变读取授权。修改 state/* 与 control/* 时以当前工作树为准,明确的当前修订优先于旧叙事。
|
|
54
84
|
- 每个完整工具响应按调用顺序结算。成功调用留下逐文件 before-image,可以由玩家单独回滚;失败调用不会撤销同一响应内其他成功调用。
|
|
55
|
-
- 修改既有文件前必须完整读取。
|
|
56
|
-
- state/* 只接受 .yaml 或 .md 世界文档;control/* 接受 frame.yaml、player-views.yaml、blocks/*.md,以及 followups.yaml 与后置请求引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。${tabletop ? "control/gameplay.json 记录世界的玩法模式与判定方案,修订中只能保持不变。" : ""}
|
|
85
|
+
- 修改既有文件前必须完整读取。search cursor 和 read 授权只属于产生它们的工作树 revision;工作树发生任何手动、AI 或回滚写入后,继续修改前必须重新读取。bootstrap 中 Runtime 预载的完整文档视为已在该 revision 完整读取,可直接修改;目录不算读取。
|
|
86
|
+
- state/* 只接受 .yaml 或 .md 世界文档;control/* 接受 frame.yaml、player-views.yaml、blocks/*.md,以及 followups.yaml 与后置请求引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/* 文本资源。${tabletop ? "control/gameplay.json 记录世界的玩法模式与判定方案,修订中只能保持不变。" : ""}可以创建新状态文档;可以删除或移动既有状态文档:删除前必须完整读取,且 currentSituation、frame slot、player-view selector 与其他文档 $ref 都不能再指向它,否则 Runtime 会列出全部阻挡位置;移动保留文档身份与内容,且不覆盖已有路径。
|
|
57
87
|
- 对话跨越“应用”或“放弃”继续时,Runtime 会明确宣布一个新修订 epoch。此前读取授权全部失效;先重新读取新世界,再继续修改。`
|
|
58
88
|
: `# Runtime world-revision conversation contract
|
|
59
89
|
|
|
60
90
|
- You are revising a world that is already in play, not its source content package. Runtime holds a durable exclusive lock during the revision, blocking play and every other world write.
|
|
61
91
|
- state/* and control/* form one revision worktree. Manual edits and AI tool writes share that tree and one rollback history. opening.md was already committed as genesis narrative and cannot be read or changed.
|
|
62
92
|
- Tools change the unapplied worktree. Only the player can Apply the complete tree to world state/control and unlock it, or Discard the tree and unlock it. Never claim that you applied or discarded the revision yourself.
|
|
93
|
+
- history_list, history_search and history_read only navigate the world's committed history originals (the opening included when present). History is evidence of what already happened for checking facts and causality; a revision cannot rewrite, delete or append to it. History calls produce no changes, never enter rollback history and do not alter read authorization. When editing state/* or control/*, the current worktree governs; an explicit current revision takes precedence over old narrative.
|
|
63
94
|
- Calls in each complete tool response settle in order. Every successful call retains per-file before-images for selective rollback; a failed sibling does not undo successful calls.
|
|
64
|
-
- Completely read an existing file before changing it.
|
|
65
|
-
- state/* accepts only YAML or Markdown world documents. control/* accepts frame.yaml, player-views.yaml, blocks/*.md, plus followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. ${tabletop ? "control/gameplay.json records the world's gameplay mode and resolution scheme; a revision must leave both unchanged. " : ""}New state documents are allowed; existing state documents
|
|
95
|
+
- Completely read an existing file before changing it. Search cursors and read authorization belong only to the worktree revision that produced them. Re-read after any manual, AI, or rollback write. Runtime-preloaded complete documents in the bootstrap count as completely read at that revision and may be changed directly; the directory does not count as a read.
|
|
96
|
+
- state/* accepts only YAML or Markdown world documents. control/* accepts frame.yaml, player-views.yaml, blocks/*.md, plus followups.yaml and its referenced prompts/*.md, renderers/*.html, scripts/*.js, regex/*.yaml and assets/* text resources. ${tabletop ? "control/gameplay.json records the world's gameplay mode and resolution scheme; a revision must leave both unchanged. " : ""}New state documents are allowed; existing state documents may be deleted or moved: a deletion requires a complete read and no remaining currentSituation, frame slot, player-view selector, or other document $ref pointing to it, otherwise Runtime lists every blocking location; a move keeps the document identity and contents and never overwrites an existing path.
|
|
66
97
|
- When a conversation continues after Apply or Discard, Runtime announces a new revision epoch and invalidates every prior read authorization. Re-read the new world before editing.`;
|
|
67
98
|
}
|
|
68
99
|
export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
@@ -90,49 +121,58 @@ export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
|
90
121
|
message: "Only state/* and supported control/* files are writable",
|
|
91
122
|
});
|
|
92
123
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
code: "existing_state_delete_forbidden",
|
|
97
|
-
path: base.path,
|
|
98
|
-
message: "An existing world-state document cannot be deleted",
|
|
99
|
-
});
|
|
100
|
-
}
|
|
124
|
+
// Existing documents are matched by document ID: a path change is a move, and only a
|
|
125
|
+
// missing ID is a deletion. Deletion is refused while any reference still points to it.
|
|
126
|
+
const baseStateFiles = immutableBaseFiles.filter(({ path }) => path.startsWith("state/"));
|
|
101
127
|
const baseSnapshot = WorldDocumentStore.open({
|
|
102
128
|
layout: "world_state",
|
|
103
|
-
files:
|
|
129
|
+
files: baseStateFiles,
|
|
104
130
|
});
|
|
105
131
|
const candidateSnapshot = WorldDocumentStore.open({
|
|
106
132
|
layout: "world_state",
|
|
107
133
|
files: files.filter(({ path }) => path.startsWith("state/")),
|
|
108
134
|
});
|
|
109
|
-
|
|
135
|
+
const candidateWorldSnapshot = WorldDocumentStore.open({
|
|
136
|
+
layout: "world_revision",
|
|
137
|
+
files,
|
|
138
|
+
});
|
|
139
|
+
for (const base of baseStateFiles) {
|
|
110
140
|
const before = baseSnapshot.query({
|
|
111
141
|
kind: "read_document",
|
|
112
142
|
document: { logicalPath: base.path },
|
|
113
143
|
});
|
|
144
|
+
if (before.kind !== "read_document" || !before.ok)
|
|
145
|
+
continue;
|
|
114
146
|
const after = candidateSnapshot.query({
|
|
115
147
|
kind: "read_document",
|
|
116
|
-
document: {
|
|
148
|
+
document: { documentId: before.document.documentId },
|
|
117
149
|
});
|
|
118
|
-
if (
|
|
119
|
-
before.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
if (after.kind === "read_document" && after.ok) {
|
|
151
|
+
if (after.document.shortRef !== before.document.shortRef)
|
|
152
|
+
diagnostics.push(identityChangedDiagnostic(base.path));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (paths.has(base.path)) {
|
|
156
|
+
diagnostics.push(identityChangedDiagnostic(base.path));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// References to the deleted document no longer resolve in the candidate, so the
|
|
160
|
+
// blocker scan reads a probe that restores the base document for resolution only.
|
|
161
|
+
const probe = WorldDocumentStore.open({
|
|
162
|
+
layout: "world_revision",
|
|
163
|
+
files: [...files, base],
|
|
164
|
+
});
|
|
165
|
+
const blockers = settingDocumentDeletionBlockers(probe, before.document);
|
|
166
|
+
if (blockers.length > 0)
|
|
124
167
|
diagnostics.push({
|
|
125
|
-
code: "
|
|
168
|
+
code: "existing_state_delete_blocked",
|
|
126
169
|
path: base.path,
|
|
127
|
-
message:
|
|
170
|
+
message: deletionBlockedMessage(before.document.shortRef, blockers),
|
|
128
171
|
});
|
|
129
172
|
}
|
|
130
173
|
const inspection = inspectContentPackageCurrentTree(files, {
|
|
131
174
|
requireOpening: false,
|
|
132
|
-
worldDocumentSnapshot:
|
|
133
|
-
layout: "world_revision",
|
|
134
|
-
files,
|
|
135
|
-
}),
|
|
175
|
+
worldDocumentSnapshot: candidateWorldSnapshot,
|
|
136
176
|
});
|
|
137
177
|
if (inspection.status !== "usable")
|
|
138
178
|
diagnostics.push(...inspection.issues.map(({ code, path, message }) => ({
|
|
@@ -145,6 +185,19 @@ export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
|
145
185
|
diagnostics,
|
|
146
186
|
};
|
|
147
187
|
}
|
|
188
|
+
function identityChangedDiagnostic(path) {
|
|
189
|
+
return {
|
|
190
|
+
code: "existing_state_identity_changed",
|
|
191
|
+
path,
|
|
192
|
+
message: "An existing world-state document must keep its document ID and short reference",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function deletionBlockedMessage(shortRef, blockers) {
|
|
196
|
+
const locations = blockers
|
|
197
|
+
.map(({ path, locator }) => `${path} · ${locator}`)
|
|
198
|
+
.join("; ");
|
|
199
|
+
return `Cannot delete @${shortRef}; remove or redirect every reference first: ${locations}`;
|
|
200
|
+
}
|
|
148
201
|
/** A malformed gameplay file is reported by content inspection, not as a scheme change. */
|
|
149
202
|
function gameplayDiagnostics(immutableBaseFiles, files) {
|
|
150
203
|
try {
|