narraeon 0.7.6 → 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/FileNativeContinuityCorrection.js +12 -4
- package/dist/node/runtime/play/PlayCallChain.js +10 -21
- package/dist/node/runtime/play/PlayDocumentTools.js +72 -43
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +35 -19
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +15 -12
- package/dist/node/runtime/prompt/WorldMaterialCoverage.js +0 -67
- 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/WorldDocumentStore.js +441 -389
- package/dist/node/runtime/world/WorldLedgerAuthority.js +18 -2
- package/dist/node/runtime/world/WorldStateRevision.js +6 -3
- 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/node/shared/ordered-play-prompts.js +2 -2
- 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-Cj2JYCia.js +0 -1408
|
@@ -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,4 @@
|
|
|
1
|
-
import { WorldDocumentStore, } from "./WorldDocumentStore.js";
|
|
1
|
+
import { WorldDocumentStore, formatWorldDocumentLocator, } from "./WorldDocumentStore.js";
|
|
2
2
|
export function beginWorldStateRevision(files) {
|
|
3
3
|
const snapshot = WorldDocumentStore.open({
|
|
4
4
|
layout: "world_state",
|
|
@@ -63,8 +63,11 @@ export function fileNativeStateChanges(candidate) {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
export function worldDocumentRevisionFailureMessage(diagnostics) {
|
|
66
|
-
return (diagnostics
|
|
67
|
-
|
|
66
|
+
return (diagnostics
|
|
67
|
+
.map(({ message, locator }) => locator === undefined
|
|
68
|
+
? message
|
|
69
|
+
: `${message} (${formatWorldDocumentLocator(locator)})`)
|
|
70
|
+
.join("; ") || "The revision is invalid");
|
|
68
71
|
}
|
|
69
72
|
function revisionFileRecord(snapshot) {
|
|
70
73
|
return Object.fromEntries(snapshot.files.map((file) => {
|
|
@@ -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 {
|
|
@@ -5,9 +5,11 @@ import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringReq
|
|
|
5
5
|
import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
|
|
6
6
|
import { equalModelHostBinding } from "../model/ModelHost.js";
|
|
7
7
|
import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
|
|
8
|
+
import { authoringPreloadAuthorization, buildAuthoringPreload, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
|
|
8
9
|
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
9
10
|
import { WorldRevisionAuthoringTransaction, worldRevisionRuntimeContract, worldRevisionToolDefinitions, } from "./WorldRevisionAuthoringTransaction.js";
|
|
10
11
|
import { WorldRevisionWorkspaceError } from "./WorldRevisionWorkspace.js";
|
|
12
|
+
import { historyEntries } from "../history/HistoryTools.js";
|
|
11
13
|
/** Conversation orchestration over the shared locked revision worktree. */
|
|
12
14
|
export class WorldRevisionSession {
|
|
13
15
|
#store;
|
|
@@ -197,7 +199,7 @@ export class WorldRevisionSession {
|
|
|
197
199
|
const host = await this.#bindExistingModelHost(session.modelBinding);
|
|
198
200
|
if (!equalModelHostBinding(session.modelBinding, host.binding()))
|
|
199
201
|
throw new Error("No saved model connection matches this world-revision conversation");
|
|
200
|
-
const { playPreset, bootstrap } = await this.#compileCurrent(input.worldId, session.modelBinding);
|
|
202
|
+
const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.worldId, session.modelBinding);
|
|
201
203
|
session.schemaVersion = 2;
|
|
202
204
|
session.requests ??= [];
|
|
203
205
|
session.requests.push({
|
|
@@ -205,7 +207,12 @@ export class WorldRevisionSession {
|
|
|
205
207
|
modelItemStart: session.modelItems.length,
|
|
206
208
|
bootstrap,
|
|
207
209
|
playPreset: structuredClone(playPreset),
|
|
210
|
+
...(preload === null
|
|
211
|
+
? {}
|
|
212
|
+
: { preload: structuredClone(preload.record) }),
|
|
208
213
|
});
|
|
214
|
+
if (preload !== null)
|
|
215
|
+
session.authorization = authoringPreloadAuthorization(session.authorization, preload.record);
|
|
209
216
|
appendAuthoringUserMessage(session, message, requestId);
|
|
210
217
|
await this.#store.saveSession(session);
|
|
211
218
|
return {
|
|
@@ -262,6 +269,9 @@ export class WorldRevisionSession {
|
|
|
262
269
|
const playPreset = await this.#bindPlayPreset();
|
|
263
270
|
const locale = this.#locale();
|
|
264
271
|
const gameplay = await this.#worlds.gameplay(worldId);
|
|
272
|
+
const preload = epoch === null
|
|
273
|
+
? null
|
|
274
|
+
: await this.#preload(epoch, modelBinding, playPreset, locale);
|
|
265
275
|
const bootstrap = this.#compiler.compileWorldRevision({
|
|
266
276
|
...(epoch === null
|
|
267
277
|
? {}
|
|
@@ -273,8 +283,54 @@ export class WorldRevisionSession {
|
|
|
273
283
|
playPreset,
|
|
274
284
|
modelBinding: modelBinding,
|
|
275
285
|
tools: worldRevisionToolDefinitions(locale, gameplay),
|
|
286
|
+
...(preload === null ? {} : { preload: preload.blocks }),
|
|
287
|
+
});
|
|
288
|
+
return { bootstrap, playPreset, preload };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Preloads the control documents and the documents the world frame injects.
|
|
292
|
+
* A preview that cannot be built degrades to the directory and control files.
|
|
293
|
+
*/
|
|
294
|
+
async #preload(epoch, modelBinding, playPreset, locale) {
|
|
295
|
+
const [binding, maintenance] = await Promise.all([
|
|
296
|
+
this.#worlds.bindWorldRevision({
|
|
297
|
+
worldId: epoch.worldId,
|
|
298
|
+
epochId: epoch.epochId,
|
|
299
|
+
}),
|
|
300
|
+
this.#worlds.inspectDocumentMaintenance(epoch.worldId, epoch.baseHead),
|
|
301
|
+
]);
|
|
302
|
+
const snapshot = WorldDocumentStore.open({
|
|
303
|
+
layout: "world_state",
|
|
304
|
+
files: epoch.files,
|
|
305
|
+
});
|
|
306
|
+
let injected = [];
|
|
307
|
+
let injectedUnavailable;
|
|
308
|
+
try {
|
|
309
|
+
const preview = this.#preview({
|
|
310
|
+
snapshot,
|
|
311
|
+
modelBinding,
|
|
312
|
+
playPreset,
|
|
313
|
+
binding,
|
|
314
|
+
epoch,
|
|
315
|
+
maintenance,
|
|
316
|
+
});
|
|
317
|
+
injected = preview.compilation.coverage.flatMap(({ slot, status, readAuthorization }) => status === "resolved" && readAuthorization !== undefined
|
|
318
|
+
? [{ shortRef: readAuthorization.shortRef, slot }]
|
|
319
|
+
: []);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
injectedUnavailable =
|
|
323
|
+
error instanceof Error ? error.message : "unknown preview failure";
|
|
324
|
+
}
|
|
325
|
+
return buildAuthoringPreload({
|
|
326
|
+
snapshot,
|
|
327
|
+
surface: worldRevisionAuthoringSurface,
|
|
328
|
+
locale,
|
|
329
|
+
revision: epoch.revision,
|
|
330
|
+
sourcePrefix: "world-revision:preload",
|
|
331
|
+
injected,
|
|
332
|
+
...(injectedUnavailable === undefined ? {} : { injectedUnavailable }),
|
|
276
333
|
});
|
|
277
|
-
return { bootstrap, playPreset };
|
|
278
334
|
}
|
|
279
335
|
async cancel(sessionId) {
|
|
280
336
|
const running = this.#conversation.cancel(sessionId);
|
|
@@ -408,6 +464,10 @@ export class WorldRevisionSession {
|
|
|
408
464
|
return new WorldRevisionAuthoringTransaction({
|
|
409
465
|
baseFiles: epoch.files,
|
|
410
466
|
immutableBaseFiles: epoch.baseFiles,
|
|
467
|
+
history: {
|
|
468
|
+
scope: epoch.worldId,
|
|
469
|
+
entries: historyEntries(binding.history, binding.historyBackgrounds),
|
|
470
|
+
},
|
|
411
471
|
locale: session.locale,
|
|
412
472
|
revision: epoch.revision,
|
|
413
473
|
authorization: session.authorization,
|
|
@@ -565,8 +625,8 @@ export class WorldRevisionSession {
|
|
|
565
625
|
}
|
|
566
626
|
function retargetSession(session, epoch, locale) {
|
|
567
627
|
const markdown = locale === "zh-CN"
|
|
568
|
-
? `# 新的世界修订 epoch\n\n上一轮修订已经应用或放弃。当前工作树是 ${epoch.epochId},从世界端点 ${epoch.baseHead} 重新建立。此前全部
|
|
569
|
-
: `# New world-revision epoch\n\nThe previous revision was applied or discarded. Worktree ${epoch.epochId} was rebuilt from world endpoint ${epoch.baseHead}. Every prior
|
|
628
|
+
? `# 新的世界修订 epoch\n\n上一轮修订已经应用或放弃。当前工作树是 ${epoch.epochId},从世界端点 ${epoch.baseHead} 重新建立。此前全部 search cursor 和 read 写授权已失效;修改前必须重新读取。`
|
|
629
|
+
: `# New world-revision epoch\n\nThe previous revision was applied or discarded. Worktree ${epoch.epochId} was rebuilt from world endpoint ${epoch.baseHead}. Every prior search cursor and read authorization is invalid; re-read before writing.`;
|
|
570
630
|
session.modelItems.push({
|
|
571
631
|
kind: "prompt_delta",
|
|
572
632
|
logicalMessages: [
|
|
@@ -353,18 +353,21 @@ function assertExpectedRevision(epoch, expected) {
|
|
|
353
353
|
throw new WorldRevisionWorkspaceError("The world-revision worktree changed; refresh it before writing");
|
|
354
354
|
}
|
|
355
355
|
function stateChanges(baseFiles, files) {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
const baseStateFiles = baseFiles.filter(({ path }) => path.startsWith("state/"));
|
|
357
|
+
const base = new Map(baseStateFiles.map((file) => [file.path, file]));
|
|
358
|
+
const candidateStateFiles = files.filter(({ path }) => path.startsWith("state/"));
|
|
359
359
|
const snapshot = WorldDocumentStore.open({
|
|
360
360
|
layout: "world_state",
|
|
361
|
-
files:
|
|
361
|
+
files: candidateStateFiles,
|
|
362
362
|
});
|
|
363
363
|
if (snapshot.status !== "usable")
|
|
364
364
|
throw new WorldRevisionWorkspaceError("Only a usable world-state snapshot can be applied");
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
const baseSnapshot = WorldDocumentStore.open({
|
|
366
|
+
layout: "world_state",
|
|
367
|
+
files: baseStateFiles,
|
|
368
|
+
});
|
|
369
|
+
const candidatePaths = new Set(candidateStateFiles.map(({ path }) => path));
|
|
370
|
+
const changes = candidateStateFiles.flatMap((file) => {
|
|
368
371
|
const previous = base.get(file.path);
|
|
369
372
|
if (previous?.contents === file.contents)
|
|
370
373
|
return [];
|
|
@@ -375,10 +378,6 @@ function stateChanges(baseFiles, files) {
|
|
|
375
378
|
if (result.kind !== "read_document" || !result.ok)
|
|
376
379
|
throw new WorldRevisionWorkspaceError(`Cannot resolve changed world document: ${file.path}`);
|
|
377
380
|
if (previous !== undefined) {
|
|
378
|
-
const baseSnapshot = WorldDocumentStore.open({
|
|
379
|
-
layout: "world_state",
|
|
380
|
-
files: baseFiles.filter(({ path }) => path.startsWith("state/")),
|
|
381
|
-
});
|
|
382
381
|
const before = baseSnapshot.query({
|
|
383
382
|
kind: "read_document",
|
|
384
383
|
document: { logicalPath: file.path },
|
|
@@ -401,6 +400,28 @@ function stateChanges(baseFiles, files) {
|
|
|
401
400
|
},
|
|
402
401
|
];
|
|
403
402
|
});
|
|
403
|
+
// A base document whose path is gone was deleted or moved away; a move's new path is a create above.
|
|
404
|
+
const removals = baseStateFiles
|
|
405
|
+
.filter(({ path }) => !candidatePaths.has(path))
|
|
406
|
+
.map((file) => {
|
|
407
|
+
const before = baseSnapshot.query({
|
|
408
|
+
kind: "read_document",
|
|
409
|
+
document: { logicalPath: file.path },
|
|
410
|
+
});
|
|
411
|
+
if (before.kind !== "read_document" || !before.ok)
|
|
412
|
+
throw new WorldRevisionWorkspaceError(`Cannot resolve removed world document: ${file.path}`);
|
|
413
|
+
return {
|
|
414
|
+
kind: "delete",
|
|
415
|
+
documentId: before.document.documentId,
|
|
416
|
+
stableShortRef: before.document.shortRef,
|
|
417
|
+
relativePath: file.path.slice("state/".length),
|
|
418
|
+
codec: before.document.codec,
|
|
419
|
+
expectedPreviousHash: sha256(file.contents),
|
|
420
|
+
nextHash: null,
|
|
421
|
+
canonicalNextBytes: null,
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
return [...changes, ...removals];
|
|
404
425
|
}
|
|
405
426
|
function controlFiles(files) {
|
|
406
427
|
return files
|