narraeon 0.5.7 → 0.6.1
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/README.md +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- package/dist/web/assets/index-DzOnu9uv.css +0 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
4
|
+
export class WorldTurnLedgerError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "WorldTurnLedgerError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const headFile = join("runtime", "head.json");
|
|
13
|
+
const turnsDirectory = join("runtime", "turns");
|
|
14
|
+
function turnFileName(sequence) {
|
|
15
|
+
return join(turnsDirectory, `${String(sequence).padStart(8, "0")}.json`);
|
|
16
|
+
}
|
|
17
|
+
function assertSafePath(relativePath) {
|
|
18
|
+
const normalized = normalize(relativePath);
|
|
19
|
+
if (isAbsolute(relativePath) ||
|
|
20
|
+
normalized.startsWith(`..${sep}`) ||
|
|
21
|
+
normalized === ".." ||
|
|
22
|
+
normalized.length === 0 ||
|
|
23
|
+
normalized === headFile ||
|
|
24
|
+
normalized === turnsDirectory ||
|
|
25
|
+
normalized.startsWith(turnsDirectory + sep)) {
|
|
26
|
+
throw new WorldTurnLedgerError("invalid_path", `世界文件路径不合法:${relativePath}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function writeFileAtomic(absolute, content) {
|
|
30
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
31
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
32
|
+
await writeFile(temporary, content, "utf8");
|
|
33
|
+
await rename(temporary, absolute);
|
|
34
|
+
}
|
|
35
|
+
async function readTextOrNull(absolute) {
|
|
36
|
+
try {
|
|
37
|
+
return await readFile(absolute, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (isMissing(error))
|
|
41
|
+
return null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function isMissing(error) {
|
|
46
|
+
return (typeof error === "object" &&
|
|
47
|
+
error !== null &&
|
|
48
|
+
"code" in error &&
|
|
49
|
+
error.code === "ENOENT");
|
|
50
|
+
}
|
|
51
|
+
export async function initializeLedger(root) {
|
|
52
|
+
forgetLedger(root);
|
|
53
|
+
await writeFileAtomic(join(root, headFile), `${JSON.stringify({ sequence: 0 }, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
export async function readHead(root) {
|
|
56
|
+
const text = await readTextOrNull(join(root, headFile));
|
|
57
|
+
if (text === null) {
|
|
58
|
+
throw new WorldTurnLedgerError("no_head", "世界缺少轮账本端点");
|
|
59
|
+
}
|
|
60
|
+
const parsed = JSON.parse(text);
|
|
61
|
+
if (typeof parsed.sequence !== "number" ||
|
|
62
|
+
!Number.isInteger(parsed.sequence) ||
|
|
63
|
+
parsed.sequence < 0) {
|
|
64
|
+
throw new WorldTurnLedgerError("corrupt", "世界轮账本端点格式不正确");
|
|
65
|
+
}
|
|
66
|
+
return parsed.sequence;
|
|
67
|
+
}
|
|
68
|
+
async function publishHead(root, sequence) {
|
|
69
|
+
await writeFileAtomic(join(root, headFile), `${JSON.stringify({ sequence }, null, 2)}\n`);
|
|
70
|
+
}
|
|
71
|
+
async function readAllRecords(root) {
|
|
72
|
+
let names;
|
|
73
|
+
try {
|
|
74
|
+
names = await readdir(join(root, turnsDirectory));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (isMissing(error))
|
|
78
|
+
return [];
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
const records = [];
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
if (!name.endsWith(".json"))
|
|
84
|
+
continue;
|
|
85
|
+
const text = await readFile(join(root, turnsDirectory, name), "utf8");
|
|
86
|
+
records.push(JSON.parse(text));
|
|
87
|
+
}
|
|
88
|
+
return records.sort((left, right) => left.sequence - right.sequence);
|
|
89
|
+
}
|
|
90
|
+
// 被某个 rewind 丢弃的轮是 (restores, rewind) 之间的位置;它们可能仍在磁盘上,
|
|
91
|
+
// 直到清理完成,但读取时一律忽略。
|
|
92
|
+
function viewOf(head, records) {
|
|
93
|
+
const byPosition = new Map();
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
if (record.sequence <= head)
|
|
96
|
+
byPosition.set(record.sequence, record);
|
|
97
|
+
}
|
|
98
|
+
const discarded = new Set();
|
|
99
|
+
for (const record of byPosition.values()) {
|
|
100
|
+
if (record.restoresSequence === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
for (let position = record.restoresSequence + 1; position < record.sequence; position += 1) {
|
|
103
|
+
discarded.add(position);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { head, byPosition, discarded };
|
|
107
|
+
}
|
|
108
|
+
// 进程内视图缓存,按端点失效。一个数据目录只被一个进程操作(ADR-0044),
|
|
109
|
+
// 所以 head 相同即记录相同:任何改变可见记录的写入都会先推进 head。
|
|
110
|
+
// 只有 beginTurn 与 recoverIncomplete 会在 head 之上写入或删除记录,它们不影响视图
|
|
111
|
+
// (视图只包含序号不大于 head 的记录,且 recoverIncomplete 本身会在结束时失效缓存)。
|
|
112
|
+
const viewCache = new Map();
|
|
113
|
+
async function loadView(root) {
|
|
114
|
+
const head = await readHead(root);
|
|
115
|
+
const cached = viewCache.get(resolve(root));
|
|
116
|
+
if (cached?.head === head)
|
|
117
|
+
return cached;
|
|
118
|
+
const view = viewOf(head, await readAllRecords(root));
|
|
119
|
+
viewCache.set(resolve(root), view);
|
|
120
|
+
return view;
|
|
121
|
+
}
|
|
122
|
+
// 世界目录被删除、归档导入或分叉发布到已存在路径之前调用:下一次读取从磁盘重建视图。
|
|
123
|
+
export function forgetLedger(root) {
|
|
124
|
+
viewCache.delete(resolve(root));
|
|
125
|
+
}
|
|
126
|
+
// 把一个端点解析到它的逻辑位置:rewind 位置等价于它所恢复的位置。
|
|
127
|
+
function resolvePosition(view, sequence) {
|
|
128
|
+
if (!Number.isInteger(sequence) || sequence < 0 || sequence > view.head) {
|
|
129
|
+
throw new WorldTurnLedgerError("unreachable", `端点 ${sequence} 不在当前账本范围内`);
|
|
130
|
+
}
|
|
131
|
+
let position = sequence;
|
|
132
|
+
for (;;) {
|
|
133
|
+
if (position === 0)
|
|
134
|
+
return 0;
|
|
135
|
+
if (view.discarded.has(position)) {
|
|
136
|
+
throw new WorldTurnLedgerError("unreachable", `端点 ${sequence} 已被回退丢弃`);
|
|
137
|
+
}
|
|
138
|
+
const record = view.byPosition.get(position);
|
|
139
|
+
if (!record) {
|
|
140
|
+
throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
|
|
141
|
+
}
|
|
142
|
+
if (record.mode !== "rewind")
|
|
143
|
+
return position;
|
|
144
|
+
if (record.restoresSequence === undefined) {
|
|
145
|
+
throw new WorldTurnLedgerError("corrupt", `第 ${position} 轮缺少回退目标`);
|
|
146
|
+
}
|
|
147
|
+
position = record.restoresSequence;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 从 sequence 之后的全部可见改动倒推:每个路径取最早一次修改前像。
|
|
151
|
+
function overlayAfter(view, sequence) {
|
|
152
|
+
const overlay = new Map();
|
|
153
|
+
for (let position = sequence + 1; position <= view.head; position += 1) {
|
|
154
|
+
if (view.discarded.has(position))
|
|
155
|
+
continue;
|
|
156
|
+
const record = view.byPosition.get(position);
|
|
157
|
+
if (!record) {
|
|
158
|
+
throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
|
|
159
|
+
}
|
|
160
|
+
if (record.mode === "rewind")
|
|
161
|
+
continue;
|
|
162
|
+
for (const file of record.files) {
|
|
163
|
+
if (!overlay.has(file.path))
|
|
164
|
+
overlay.set(file.path, file.before);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return overlay;
|
|
168
|
+
}
|
|
169
|
+
async function captureBefore(root, paths) {
|
|
170
|
+
const files = [];
|
|
171
|
+
for (const path of paths) {
|
|
172
|
+
assertSafePath(path);
|
|
173
|
+
files.push({ path, before: await readTextOrNull(join(root, path)) });
|
|
174
|
+
}
|
|
175
|
+
return files;
|
|
176
|
+
}
|
|
177
|
+
// 第一步:写入本轮记录(序号为 head + 1)。记录原子落盘,之后任何一步崩溃都可由 recoverIncomplete 回滚。
|
|
178
|
+
export async function beginTurn(root, input) {
|
|
179
|
+
const head = await readHead(root);
|
|
180
|
+
const record = {
|
|
181
|
+
sequence: head + 1,
|
|
182
|
+
mode: input.mode,
|
|
183
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
184
|
+
...(input.restoresSequence === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { restoresSequence: input.restoresSequence }),
|
|
187
|
+
...(input.payload === undefined ? {} : { payload: input.payload }),
|
|
188
|
+
files: await captureBefore(root, input.changes.map((change) => change.path)),
|
|
189
|
+
};
|
|
190
|
+
await writeFileAtomic(join(root, turnFileName(record.sequence)), `${JSON.stringify(record, null, 2)}\n`);
|
|
191
|
+
return record;
|
|
192
|
+
}
|
|
193
|
+
// 第二步:逐文件原子替换(写临时文件后 rename;删除即 rm)。
|
|
194
|
+
export async function applyChanges(root, changes) {
|
|
195
|
+
for (const change of changes) {
|
|
196
|
+
assertSafePath(change.path);
|
|
197
|
+
const absolute = join(root, change.path);
|
|
198
|
+
if (change.content === null) {
|
|
199
|
+
await rm(absolute, { force: true });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
await writeFileAtomic(absolute, change.content);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// 第三步:发布端点。只有这一步之后,本轮才算完成。
|
|
207
|
+
export async function commitTurnHead(root, record) {
|
|
208
|
+
await publishHead(root, record.sequence);
|
|
209
|
+
}
|
|
210
|
+
export async function appendTurn(root, input) {
|
|
211
|
+
const record = await beginTurn(root, input);
|
|
212
|
+
await applyChanges(root, input.changes);
|
|
213
|
+
await commitTurnHead(root, record);
|
|
214
|
+
return record;
|
|
215
|
+
}
|
|
216
|
+
// 启动时调用:序号大于 head 的记录说明上一次推进未完成,逆序恢复其修改前像。
|
|
217
|
+
// 被丢弃的轮记录(读取时已忽略)在这里清理。返回是否回滚了未完成的轮。
|
|
218
|
+
export async function recoverIncomplete(root) {
|
|
219
|
+
const head = await readHead(root);
|
|
220
|
+
const records = await readAllRecords(root);
|
|
221
|
+
const incomplete = records
|
|
222
|
+
.filter((record) => record.sequence > head)
|
|
223
|
+
.sort((left, right) => right.sequence - left.sequence);
|
|
224
|
+
for (const record of incomplete) {
|
|
225
|
+
for (const file of [...record.files].reverse()) {
|
|
226
|
+
assertSafePath(file.path);
|
|
227
|
+
const absolute = join(root, file.path);
|
|
228
|
+
if (file.before === null) {
|
|
229
|
+
await rm(absolute, { force: true });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
await writeFileAtomic(absolute, file.before);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
await rm(join(root, turnFileName(record.sequence)), { force: true });
|
|
236
|
+
}
|
|
237
|
+
const view = viewOf(head, records);
|
|
238
|
+
for (const position of view.discarded) {
|
|
239
|
+
await rm(join(root, turnFileName(position)), { force: true });
|
|
240
|
+
}
|
|
241
|
+
forgetLedger(root);
|
|
242
|
+
return incomplete.length > 0;
|
|
243
|
+
}
|
|
244
|
+
export async function listTurns(root) {
|
|
245
|
+
const view = await loadView(root);
|
|
246
|
+
const turns = [];
|
|
247
|
+
for (let position = 1; position <= view.head; position += 1) {
|
|
248
|
+
if (view.discarded.has(position))
|
|
249
|
+
continue;
|
|
250
|
+
const record = view.byPosition.get(position);
|
|
251
|
+
if (!record) {
|
|
252
|
+
throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
|
|
253
|
+
}
|
|
254
|
+
turns.push(record);
|
|
255
|
+
}
|
|
256
|
+
return turns;
|
|
257
|
+
}
|
|
258
|
+
// 把端点解析为它所表示的逻辑位置(rewind 位置等价于它恢复的位置)。
|
|
259
|
+
export async function resolveEndpoint(root, sequence) {
|
|
260
|
+
return resolvePosition(await loadView(root), sequence);
|
|
261
|
+
}
|
|
262
|
+
// 第 sequence 轮记录文件的 sha256;0 轮没有记录,返回 null。
|
|
263
|
+
export async function readTurnDigest(root, sequence) {
|
|
264
|
+
if (sequence === 0)
|
|
265
|
+
return null;
|
|
266
|
+
const text = await readFile(join(root, turnFileName(sequence)), "utf8");
|
|
267
|
+
return createHash("sha256").update(text).digest("hex");
|
|
268
|
+
}
|
|
269
|
+
// 读取端点 sequence 处某个文件的内容;不存在返回 null。
|
|
270
|
+
export async function readFileAt(root, sequence, relativePath) {
|
|
271
|
+
assertSafePath(relativePath);
|
|
272
|
+
const view = await loadView(root);
|
|
273
|
+
const position = resolvePosition(view, sequence);
|
|
274
|
+
const overlay = overlayAfter(view, position);
|
|
275
|
+
if (overlay.has(relativePath))
|
|
276
|
+
return overlay.get(relativePath) ?? null;
|
|
277
|
+
return readTextOrNull(join(root, relativePath));
|
|
278
|
+
}
|
|
279
|
+
// 读取端点 sequence 处 dirs 下的全部文件(相对路径 → 内容)。
|
|
280
|
+
export async function readTreeAt(root, sequence, dirs) {
|
|
281
|
+
const view = await loadView(root);
|
|
282
|
+
const position = resolvePosition(view, sequence);
|
|
283
|
+
const overlay = overlayAfter(view, position);
|
|
284
|
+
const tree = new Map();
|
|
285
|
+
for (const dir of dirs) {
|
|
286
|
+
assertSafePath(dir);
|
|
287
|
+
for (const path of await listFiles(root, dir)) {
|
|
288
|
+
if (!overlay.has(path)) {
|
|
289
|
+
const text = await readTextOrNull(join(root, path));
|
|
290
|
+
if (text !== null)
|
|
291
|
+
tree.set(path, text);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const [path, before] of overlay) {
|
|
296
|
+
const inDir = dirs.some((dir) => path === dir || path.startsWith(dir + "/"));
|
|
297
|
+
if (!inDir || before === null)
|
|
298
|
+
continue;
|
|
299
|
+
tree.set(path, before);
|
|
300
|
+
}
|
|
301
|
+
return tree;
|
|
302
|
+
}
|
|
303
|
+
async function listFiles(root, dir) {
|
|
304
|
+
const found = [];
|
|
305
|
+
const visit = async (relative) => {
|
|
306
|
+
let entries;
|
|
307
|
+
try {
|
|
308
|
+
entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (isMissing(error))
|
|
312
|
+
return;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
const child = `${relative}/${entry.name}`;
|
|
317
|
+
if (entry.isDirectory()) {
|
|
318
|
+
await visit(child);
|
|
319
|
+
}
|
|
320
|
+
else if (entry.isFile() && !entry.name.endsWith(".tmp")) {
|
|
321
|
+
found.push(child);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
await visit(dir);
|
|
326
|
+
return found;
|
|
327
|
+
}
|
|
328
|
+
// 回退到端点 sequence:追加一轮 rewind(前像为回退前的文件),之后丢弃 (sequence, head] 的记录。
|
|
329
|
+
// 已经处于 sequence 的端点是空操作。返回新轮记录,或 null。
|
|
330
|
+
export async function rewindTo(root, sequence) {
|
|
331
|
+
const view = await loadView(root);
|
|
332
|
+
const target = resolvePosition(view, sequence);
|
|
333
|
+
if (target === view.head)
|
|
334
|
+
return null;
|
|
335
|
+
const overlay = overlayAfter(view, target);
|
|
336
|
+
const changes = [];
|
|
337
|
+
for (const [path, before] of overlay) {
|
|
338
|
+
const current = await readTextOrNull(join(root, path));
|
|
339
|
+
if (current === before)
|
|
340
|
+
continue;
|
|
341
|
+
changes.push({ path, content: before });
|
|
342
|
+
}
|
|
343
|
+
const record = await appendTurn(root, {
|
|
344
|
+
mode: "rewind",
|
|
345
|
+
changes,
|
|
346
|
+
restoresSequence: sequence,
|
|
347
|
+
});
|
|
348
|
+
// 端点已发布,丢弃的记录即可删除;失败可忽略,读取已把它们当作不可达,下次回退或恢复会再清理。
|
|
349
|
+
for (let position = target + 1; position <= view.head; position += 1) {
|
|
350
|
+
await rm(join(root, turnFileName(position)), { force: true }).catch(() => undefined);
|
|
351
|
+
}
|
|
352
|
+
return record;
|
|
353
|
+
}
|
|
@@ -4,6 +4,7 @@ import { validAuthoringRequests, } from "../authoring/AuthoringRequestSnapshot.j
|
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { gameplayPath } from "../../protocol/gameplay.js";
|
|
7
8
|
import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
|
|
8
9
|
import { isPlayPresetBinding, } from "../play/FileNativePlayPresetStore.js";
|
|
9
10
|
const uuidV4Pattern = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
|
|
@@ -110,7 +111,6 @@ export class FileNativeWorldRevisionStore {
|
|
|
110
111
|
if (session.worldId !== worldId)
|
|
111
112
|
throw new Error("World-revision conversation belongs to another world");
|
|
112
113
|
await rm(this.#sessionPath(sessionId));
|
|
113
|
-
await syncDirectory(this.#sessionsRoot);
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
async #readEpoch(epochId) {
|
|
@@ -604,6 +604,7 @@ function validRevisionPath(path) {
|
|
|
604
604
|
return (safe &&
|
|
605
605
|
(isPackageFollowupControlPath(path) ||
|
|
606
606
|
/^state\/.+\.(?:ya?ml|md)$/u.test(path) ||
|
|
607
|
+
path === gameplayPath ||
|
|
607
608
|
path === "control/frame.yaml" ||
|
|
608
609
|
path === "control/player-views.yaml" ||
|
|
609
610
|
/^control\/blocks\/.+\.md$/u.test(path)));
|
|
@@ -635,27 +636,16 @@ async function publishJson(path, value) {
|
|
|
635
636
|
const handle = await open(temporary, "wx", 0o600);
|
|
636
637
|
try {
|
|
637
638
|
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
638
|
-
await handle.sync();
|
|
639
639
|
}
|
|
640
640
|
finally {
|
|
641
641
|
await handle.close();
|
|
642
642
|
}
|
|
643
643
|
await rename(temporary, path);
|
|
644
|
-
await syncDirectory(dirname(path));
|
|
645
644
|
}
|
|
646
645
|
finally {
|
|
647
646
|
await rm(temporary, { force: true });
|
|
648
647
|
}
|
|
649
648
|
}
|
|
650
|
-
async function syncDirectory(path) {
|
|
651
|
-
const handle = await open(path, "r");
|
|
652
|
-
try {
|
|
653
|
-
await handle.sync();
|
|
654
|
-
}
|
|
655
|
-
finally {
|
|
656
|
-
await handle.close();
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
649
|
function count(value) {
|
|
660
650
|
return Number.isSafeInteger(value) && Number(value) >= 0;
|
|
661
651
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
|
|
2
2
|
import { defaultAppLocale } from "../../protocol/appPreferences.js";
|
|
3
|
+
import { assertSameGameplay, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
|
|
3
4
|
import { inspectContentPackageCurrentTree } from "../content/FileNativeContentTree.js";
|
|
4
5
|
import { SettingAuthoringTransaction, settingImprovementToolDefinitions, } from "../setting/SettingAuthoringTransaction.js";
|
|
5
6
|
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
@@ -71,8 +72,8 @@ export class WorldRevisionAuthoringTransaction {
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
export function worldRevisionToolDefinitions(locale
|
|
75
|
-
return settingImprovementToolDefinitions(locale).map((definition) => {
|
|
75
|
+
export function worldRevisionToolDefinitions(locale, gameplay) {
|
|
76
|
+
return settingImprovementToolDefinitions(locale, gameplay).map((definition) => {
|
|
76
77
|
const name = settingToRevisionTool.get(definition.name);
|
|
77
78
|
if (name === undefined)
|
|
78
79
|
throw new Error(`Unsupported setting authoring tool: ${definition.name}`);
|
|
@@ -84,7 +85,9 @@ export function worldRevisionToolDefinitions(locale = defaultAppLocale) {
|
|
|
84
85
|
};
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
|
-
export function worldRevisionRuntimeContract(locale) {
|
|
88
|
+
export function worldRevisionRuntimeContract(locale, gameplay) {
|
|
89
|
+
// A world's mode and scheme are fixed, so only tabletop worlds mention the file.
|
|
90
|
+
const tabletop = gameplay.mode === "tabletop";
|
|
88
91
|
return locale === "zh-CN"
|
|
89
92
|
? `# Runtime 世界修订对话契约
|
|
90
93
|
|
|
@@ -93,7 +96,7 @@ export function worldRevisionRuntimeContract(locale) {
|
|
|
93
96
|
- 工具调用修改的是尚未应用的工作树。只有玩家点击“应用修订”才会把当前整棵树提交为新的世界状态和控制并解除锁;“放弃修订”会丢弃工作树并解除锁。你不得宣称自己已经应用或放弃修订。
|
|
94
97
|
- 每个完整工具响应按调用顺序结算。成功调用留下逐文件 before-image,可以由玩家单独回滚;失败调用不会撤销同一响应内其他成功调用。
|
|
95
98
|
- 修改既有文件前必须完整读取。list/search cursor 和 read 授权只属于产生它们的工作树 revision;工作树发生任何手动、AI 或回滚写入后,继续修改前必须重新读取。
|
|
96
|
-
- state/* 只接受 .yaml 或 .md 世界文档;control/* 接受 frame.yaml、player-views.yaml、blocks/*.md,以及 followups.yaml 与后置请求引用的 prompts/*.md、renderers/*.html、scripts/*.js、regex/*.yaml、assets/*
|
|
99
|
+
- 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 保留其稳定身份。
|
|
97
100
|
- 对话跨越“应用”或“放弃”继续时,Runtime 会明确宣布一个新修订 epoch。此前读取授权全部失效;先重新读取新世界,再继续修改。`
|
|
98
101
|
: `# Runtime world-revision conversation contract
|
|
99
102
|
|
|
@@ -102,11 +105,12 @@ export function worldRevisionRuntimeContract(locale) {
|
|
|
102
105
|
- 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.
|
|
103
106
|
- 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.
|
|
104
107
|
- 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.
|
|
105
|
-
- 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. New state documents are allowed; existing state documents cannot be deleted or moved because Authority retains their stable identity.
|
|
108
|
+
- 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.
|
|
106
109
|
- 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.`;
|
|
107
110
|
}
|
|
108
111
|
export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
109
112
|
const diagnostics = [];
|
|
113
|
+
diagnostics.push(...gameplayDiagnostics(immutableBaseFiles, files));
|
|
110
114
|
const paths = new Set();
|
|
111
115
|
for (const file of files) {
|
|
112
116
|
if (paths.has(file.path))
|
|
@@ -188,6 +192,30 @@ export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
|
188
192
|
diagnostics,
|
|
189
193
|
};
|
|
190
194
|
}
|
|
195
|
+
/** A malformed gameplay file is reported by content inspection, not as a scheme change. */
|
|
196
|
+
function gameplayDiagnostics(immutableBaseFiles, files) {
|
|
197
|
+
try {
|
|
198
|
+
readGameplay(files);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
assertSameGameplay(immutableBaseFiles, files);
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return [
|
|
209
|
+
{
|
|
210
|
+
code: "gameplay_change_forbidden",
|
|
211
|
+
path: gameplayPath,
|
|
212
|
+
message: error instanceof Error
|
|
213
|
+
? error.message
|
|
214
|
+
: "World gameplay mode and resolution scheme are fixed",
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
191
219
|
export function assertUsableWorldRevisionFiles(files, immutableBaseFiles) {
|
|
192
220
|
const inspection = inspectWorldRevisionFiles(files, immutableBaseFiles);
|
|
193
221
|
if (inspection.status !== "usable")
|
|
@@ -370,7 +398,8 @@ function mapSchema(schema) {
|
|
|
370
398
|
function isWritableWorldRevisionPath(path) {
|
|
371
399
|
if (/^state\/.+\.(?:ya?ml|md)$/u.test(path))
|
|
372
400
|
return true;
|
|
373
|
-
return (
|
|
401
|
+
return (path === gameplayPath ||
|
|
402
|
+
isPackageFollowupControlPath(path) ||
|
|
374
403
|
path === "control/frame.yaml" ||
|
|
375
404
|
path === "control/player-views.yaml" ||
|
|
376
405
|
/^control\/blocks\/.+\.md$/u.test(path));
|
|
@@ -233,16 +233,18 @@ export class WorldRevisionSession {
|
|
|
233
233
|
const worlds = await this.#worlds.listWorlds();
|
|
234
234
|
const playPreset = await this.#bindPlayPreset();
|
|
235
235
|
const locale = this.#locale();
|
|
236
|
+
const gameplay = await this.#worlds.gameplay(worldId);
|
|
236
237
|
const bootstrap = this.#compiler.compileWorldRevision({
|
|
237
238
|
...(epoch === null
|
|
238
239
|
? {}
|
|
239
240
|
: { epoch: { id: epoch.epochId, baseHead: epoch.baseHead } }),
|
|
240
241
|
worldTitle: worlds.find((world) => world.worldId === worldId)?.title ?? worldId,
|
|
241
|
-
|
|
242
|
+
gameplay,
|
|
243
|
+
runtimeContract: worldRevisionRuntimeContract(locale, gameplay),
|
|
242
244
|
authorPrompt: settingImprovementPromptForBinding(playPreset, locale),
|
|
243
245
|
playPreset,
|
|
244
246
|
modelBinding: modelBinding,
|
|
245
|
-
tools: worldRevisionToolDefinitions(locale),
|
|
247
|
+
tools: worldRevisionToolDefinitions(locale, gameplay),
|
|
246
248
|
});
|
|
247
249
|
return { bootstrap, playPreset };
|
|
248
250
|
}
|
|
@@ -253,19 +255,21 @@ export class WorldRevisionSession {
|
|
|
253
255
|
return this.#mutate(async () => this.#view(await this.#recoverIfNeeded(await this.#store.readSession(sessionId))));
|
|
254
256
|
}
|
|
255
257
|
async #create(worldId, epoch, creationRequestId) {
|
|
256
|
-
const [host, playPreset, worlds] = await Promise.all([
|
|
258
|
+
const [host, playPreset, worlds, gameplay] = await Promise.all([
|
|
257
259
|
this.#bindModelHost(),
|
|
258
260
|
this.#bindPlayPreset(),
|
|
259
261
|
this.#worlds.listWorlds(),
|
|
262
|
+
this.#worlds.gameplay(worldId),
|
|
260
263
|
]);
|
|
261
264
|
const worldTitle = worlds.find((world) => world.worldId === worldId)?.title ?? worldId;
|
|
262
265
|
const locale = this.#locale();
|
|
263
266
|
const modelBinding = host.binding();
|
|
264
|
-
const tools = worldRevisionToolDefinitions(locale);
|
|
267
|
+
const tools = worldRevisionToolDefinitions(locale, gameplay);
|
|
265
268
|
const bootstrap = this.#compiler.compileWorldRevision({
|
|
266
269
|
epoch: { id: epoch.epochId, baseHead: epoch.baseHead },
|
|
267
270
|
worldTitle,
|
|
268
|
-
|
|
271
|
+
gameplay,
|
|
272
|
+
runtimeContract: worldRevisionRuntimeContract(locale, gameplay),
|
|
269
273
|
authorPrompt: settingImprovementPromptForBinding(playPreset, locale),
|
|
270
274
|
playPreset,
|
|
271
275
|
modelBinding,
|
|
@@ -68,6 +68,8 @@ export function registerConversationStream(server, runtime) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
catch (error) {
|
|
71
|
+
request.log.error({ err: error, conversationTarget: target }, "conversation observation failed");
|
|
72
|
+
await runtime.recordConversationFailure?.(target, error);
|
|
71
73
|
write(`event: failure\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : "Conversation observation failed" })}\n\n`);
|
|
72
74
|
stream.end();
|
|
73
75
|
cleanup();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveAppPaths } from "../runtime/config/appPaths.js";
|
|
2
|
+
import { parseWebPort, startWebServer } from "./startWebServer.js";
|
|
3
|
+
// Mobile entry: started inside the Android app's Node runtime. The native shell
|
|
4
|
+
// owns the process lifecycle, so this module never registers signal handlers and
|
|
5
|
+
// never calls process.exit(); a startup failure rejects and the native side reads
|
|
6
|
+
// stderr.
|
|
7
|
+
const staticRoot = process.env.NARRAEON_STATIC_ROOT?.trim();
|
|
8
|
+
if (!staticRoot)
|
|
9
|
+
throw new Error("NARRAEON_STATIC_ROOT is required for the mobile entry");
|
|
10
|
+
const port = parseWebPort(process.env.NARRAEON_PORT);
|
|
11
|
+
const paths = resolveAppPaths(process.env);
|
|
12
|
+
const server = await startWebServer({
|
|
13
|
+
paths,
|
|
14
|
+
staticRoot,
|
|
15
|
+
host: "127.0.0.1",
|
|
16
|
+
port,
|
|
17
|
+
});
|
|
18
|
+
const address = server.server.address();
|
|
19
|
+
const listeningPort = typeof address === "object" && address !== null ? address.port : port;
|
|
20
|
+
process.stdout.write(`NARRAEON_READY ${listeningPort}\n`);
|
|
@@ -1,22 +1,29 @@
|
|
|
1
|
+
import { acquireDataRootLock } from "../runtime/config/dataRootLock.js";
|
|
1
2
|
import { createRuntime } from "./createRuntime.js";
|
|
2
3
|
import { createServer } from "./createServer.js";
|
|
3
4
|
export const defaultWebPort = 4317;
|
|
4
5
|
export const defaultWebHost = "127.0.0.1";
|
|
5
6
|
export const containerWebHost = "0.0.0.0";
|
|
6
7
|
export async function startWebServer(input) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
runtime,
|
|
10
|
-
staticRoot: input.staticRoot,
|
|
11
|
-
port: input.port,
|
|
12
|
-
...(input.logger === undefined ? {} : { logger: input.logger }),
|
|
13
|
-
});
|
|
8
|
+
const lock = await acquireDataRootLock(input.paths.dataRoot);
|
|
9
|
+
let server;
|
|
14
10
|
try {
|
|
11
|
+
const runtime = await createRuntime(input.paths);
|
|
12
|
+
server = await createServer({
|
|
13
|
+
runtime,
|
|
14
|
+
staticRoot: input.staticRoot,
|
|
15
|
+
port: input.port,
|
|
16
|
+
...(input.logger === undefined ? {} : { logger: input.logger }),
|
|
17
|
+
});
|
|
18
|
+
server.addHook("onClose", () => lock.release());
|
|
15
19
|
await server.listen({ host: input.host, port: input.port });
|
|
16
20
|
return server;
|
|
17
21
|
}
|
|
18
22
|
catch (error) {
|
|
19
|
-
|
|
23
|
+
if (server !== undefined)
|
|
24
|
+
await server.close().catch(() => undefined);
|
|
25
|
+
else
|
|
26
|
+
await lock.release();
|
|
20
27
|
throw error;
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defaultSettingImprovementPromptForLocale } from "./default-setting-improvement-prompt.js";
|
|
2
|
+
import { tabletopAuthorBody } from "./tabletop-guidance.js";
|
|
2
3
|
/** Authoring uses the same permissions as play, with target-specific dynamic references. */
|
|
3
4
|
export function builtinAuthorPrompts(locale) {
|
|
4
5
|
const zh = locale === "zh-CN";
|
|
@@ -17,6 +18,13 @@ export function builtinAuthorPrompts(locale) {
|
|
|
17
18
|
required: false,
|
|
18
19
|
body: defaultSettingImprovementPromptForLocale(locale),
|
|
19
20
|
},
|
|
21
|
+
{
|
|
22
|
+
id: "author.tabletop",
|
|
23
|
+
name: zh ? "跑团创作建议" : "Tabletop authoring guidance",
|
|
24
|
+
required: false,
|
|
25
|
+
body: tabletopAuthorBody(locale),
|
|
26
|
+
gameplay: "tabletop",
|
|
27
|
+
},
|
|
20
28
|
{
|
|
21
29
|
id: "author.target",
|
|
22
30
|
name: zh
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
import { historyUseGuidance } from "./history-use-guidance.js";
|
|
2
2
|
import { defaultNarrationPromptForLocale } from "./default-play-prompts.js";
|
|
3
3
|
import { defaultPresetHostFilesForLocale } from "./default-preset-host.js";
|
|
4
|
+
import { tabletopPlayBody } from "./tabletop-guidance.js";
|
|
4
5
|
export function isPromptMessageRole(value) {
|
|
5
6
|
return value === "system" || value === "user" || value === "assistant";
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Unscoped prompts always apply. A scoped prompt applies only when the compiled
|
|
10
|
+
* world or package is tabletop, so fiction requests never see it.
|
|
11
|
+
*/
|
|
12
|
+
export function promptScopeApplies(scope, gameplay) {
|
|
13
|
+
return scope === undefined || gameplay.mode === "tabletop";
|
|
14
|
+
}
|
|
15
|
+
/** The scope of an ordered entry: a user entry's own scope or its builtin's. */
|
|
16
|
+
export function orderedPromptScope(entry, catalog) {
|
|
17
|
+
if (entry.kind === "user")
|
|
18
|
+
return entry.gameplay;
|
|
19
|
+
if (entry.kind === "builtin")
|
|
20
|
+
return catalog.find((item) => item.id === entry.builtin)?.gameplay;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
7
23
|
/** Application-owned references. Persist references; freeze resolved text in requests. */
|
|
8
24
|
export function builtinPlayPrompts(locale) {
|
|
9
25
|
const zh = locale === "zh-CN";
|
|
@@ -67,6 +83,13 @@ ${historyUseGuidance(locale)}`,
|
|
|
67
83
|
body,
|
|
68
84
|
required: false,
|
|
69
85
|
})),
|
|
86
|
+
{
|
|
87
|
+
id: "play.tabletop",
|
|
88
|
+
name: zh ? "跑团主持" : "Tabletop GM",
|
|
89
|
+
body: tabletopPlayBody(locale),
|
|
90
|
+
required: false,
|
|
91
|
+
gameplay: "tabletop",
|
|
92
|
+
},
|
|
70
93
|
];
|
|
71
94
|
}
|
|
72
95
|
export function defaultOrderedPlayPrompts() {
|