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.
Files changed (29) hide show
  1. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +14 -1
  2. package/dist/node/runtime/history/HistoryRendering.js +25 -6
  3. package/dist/node/runtime/history/HistoryTools.js +31 -0
  4. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +12 -4
  5. package/dist/node/runtime/play/PlayCallChain.js +10 -21
  6. package/dist/node/runtime/play/PlayDocumentTools.js +72 -43
  7. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +35 -19
  8. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +15 -12
  9. package/dist/node/runtime/prompt/WorldMaterialCoverage.js +0 -67
  10. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +326 -62
  11. package/dist/node/runtime/setting/SettingImprovementSession.js +40 -3
  12. package/dist/node/runtime/world/FileNativeWorldStore.js +13 -3
  13. package/dist/node/runtime/world/WorldDocumentMaintenance.js +9 -2
  14. package/dist/node/runtime/world/WorldDocumentStore.js +441 -389
  15. package/dist/node/runtime/world/WorldLedgerAuthority.js +18 -2
  16. package/dist/node/runtime/world/WorldStateRevision.js +6 -3
  17. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +61 -17
  18. package/dist/node/runtime/world-revision/LegacyWorldRevisionEpochUpgrade.js +46 -0
  19. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +83 -30
  20. package/dist/node/runtime/world-revision/WorldRevisionSession.js +64 -4
  21. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +32 -11
  22. package/dist/node/shared/default-setting-improvement-prompt.js +23 -8
  23. package/dist/node/shared/ordered-author-prompts.js +9 -7
  24. package/dist/node/shared/ordered-play-prompts.js +2 -2
  25. package/dist/web/assets/index-B2kbaOGe.js +1408 -0
  26. package/dist/web/assets/{index-BjxBYYQi.css → index-DH2BVsYd.css} +1 -1
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -1
  29. 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 (contents === null || sha256Hex(contents) !== change.nextHash)
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 (sha256Hex(change.canonicalNextBytes) !== change.nextHash)
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.map(({ message }) => message).join("; ") ||
67
- "The revision is invalid");
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(() => this.#readEpoch(epochId));
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
- const entries = await readdir(this.#epochsRoot, {
59
- withFileTypes: true,
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 entry of entries) {
67
- if (!entry.isFile() || !epochName.test(entry.name))
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
- const epoch = await this.#readEpoch(entry.name.slice(0, -5));
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
- const epoch = validateEpoch(value);
120
- if (epoch.epochId !== epochId)
121
- throw new Error("World-revision epoch identity does not match its file");
122
- return epoch;
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 { authoringToolDefinitions, SettingAuthoringTransaction, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
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
- return this.#inner.execute(calls);
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 authoringToolDefinitions(worldRevisionAuthoringSurface, locale, gameplay);
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
- - 修改既有文件前必须完整读取。list/search cursor 和 read 授权只属于产生它们的工作树 revision;工作树发生任何手动、AI 或回滚写入后,继续修改前必须重新读取。
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 记录世界的玩法模式与判定方案,修订中只能保持不变。" : ""}可以创建新状态文档;既有状态文档不能删除或移动,因为 Authority 保留其稳定身份。
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. List/search cursors and read authorization belong only to the worktree revision that produced them. Re-read after any manual, AI, or rollback write.
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 cannot be deleted or moved because Authority retains their stable identity.
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
- for (const base of immutableBaseFiles.filter(({ path }) => path.startsWith("state/"))) {
94
- if (!paths.has(base.path))
95
- diagnostics.push({
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: immutableBaseFiles.filter(({ path }) => path.startsWith("state/")),
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
- for (const base of immutableBaseFiles.filter(({ path }) => path.startsWith("state/"))) {
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: { logicalPath: base.path },
148
+ document: { documentId: before.document.documentId },
117
149
  });
118
- if (before.kind === "read_document" &&
119
- before.ok &&
120
- after.kind === "read_document" &&
121
- after.ok &&
122
- (before.document.documentId !== after.document.documentId ||
123
- before.document.shortRef !== after.document.shortRef))
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: "existing_state_identity_changed",
168
+ code: "existing_state_delete_blocked",
126
169
  path: base.path,
127
- message: "An existing world-state document must keep its document ID and short reference",
170
+ message: deletionBlockedMessage(before.document.shortRef, blockers),
128
171
  });
129
172
  }
130
173
  const inspection = inspectContentPackageCurrentTree(files, {
131
174
  requireOpening: false,
132
- worldDocumentSnapshot: WorldDocumentStore.open({
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} 重新建立。此前全部 list/search cursor 和 read 写授权已失效;修改前必须重新读取。`
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 list/search cursor and read authorization is invalid; re-read before writing.`;
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 base = new Map(baseFiles
357
- .filter(({ path }) => path.startsWith("state/"))
358
- .map((file) => [file.path, file]));
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: files.filter(({ path }) => path.startsWith("state/")),
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
- return files
366
- .filter(({ path }) => path.startsWith("state/"))
367
- .flatMap((file) => {
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