narraeon 0.3.1 → 0.5.0

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 (76) hide show
  1. package/dist/node/protocol/appPreferences.js +25 -0
  2. package/dist/node/protocol/conversationObservation.js +67 -0
  3. package/dist/node/protocol/v1.js +188 -34
  4. package/dist/node/protocol/worldExtensions.js +16 -0
  5. package/dist/node/protocol/worldMaintenance.js +77 -0
  6. package/dist/node/protocol/worldPromptDiagnostics.js +1 -0
  7. package/dist/node/runtime/ConversationChanges.js +24 -0
  8. package/dist/node/runtime/V1Runtime.js +447 -121
  9. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +326 -12
  10. package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +63 -0
  11. package/dist/node/runtime/authoring/ModelToolConversation.js +218 -0
  12. package/dist/node/runtime/config/AppPreferencesStore.js +46 -17
  13. package/dist/node/runtime/content/ContentTreeFingerprint.js +13 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +43 -13
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +128 -46
  16. package/dist/node/runtime/content/FileNativeContentTree.js +28 -10
  17. package/dist/node/runtime/content/PackageFollowups.js +80 -0
  18. package/dist/node/runtime/extension/FrontendExtensionBundle.js +19 -14
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +70 -0
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +205 -0
  21. package/dist/node/runtime/extension/WorldExtensionRequests.js +64 -0
  22. package/dist/node/runtime/model/FileNativeModelAdapters.js +21 -466
  23. package/dist/node/runtime/model/ModelConnectionStore.js +44 -10
  24. package/dist/node/runtime/model/ModelHost.js +31 -1
  25. package/dist/node/runtime/model/ModelHostStream.js +51 -5
  26. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +55 -45
  27. package/dist/node/runtime/play/FileNativePlayPresetStore.js +256 -238
  28. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +118 -9
  29. package/dist/node/runtime/play/OrderedFollowups.js +71 -0
  30. package/dist/node/runtime/play/OrderedPlayPrompts.js +180 -0
  31. package/dist/node/runtime/play/PlayCallChain.js +814 -61
  32. package/dist/node/runtime/play/PlayContinuity.js +88 -0
  33. package/dist/node/runtime/play/PlayDocumentTools.js +205 -287
  34. package/dist/node/runtime/play/PlayFollowupRequests.js +34 -3
  35. package/dist/node/runtime/play/PlayPresetWorkbench.js +45 -54
  36. package/dist/node/runtime/play/PlayPromptRun.js +86 -0
  37. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +670 -93
  38. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -21
  39. package/dist/node/runtime/prompt/PromptCompilationCodec.js +322 -0
  40. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +251 -0
  41. package/dist/node/runtime/prompt/WorldMaterialCoverage.js +83 -0
  42. package/dist/node/runtime/prompt/WorldPromptDiagnostics.js +185 -0
  43. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +1027 -0
  44. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +1339 -0
  45. package/dist/node/runtime/setting/SettingImprovementSession.js +714 -0
  46. package/dist/node/runtime/world/FileNativeAuthorityV3.js +88 -1
  47. package/dist/node/runtime/world/FileNativeWorldStore.js +292 -27
  48. package/dist/node/runtime/world/PlayerViewBindings.js +30 -0
  49. package/dist/node/runtime/world/PlayerViewRenderer.js +27 -34
  50. package/dist/node/runtime/world/WorldDocumentMaintenance.js +174 -0
  51. package/dist/node/runtime/world/WorldDocumentStore.js +166 -96
  52. package/dist/node/runtime/world/WorldOperationCoordinator.js +103 -2
  53. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +696 -0
  54. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +380 -0
  55. package/dist/node/runtime/world-revision/WorldRevisionSession.js +709 -0
  56. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +494 -0
  57. package/dist/node/server/ConversationStream.js +109 -0
  58. package/dist/node/server/createServer.js +5 -1
  59. package/dist/node/shared/artifact-payload-contract.js +123 -0
  60. package/dist/node/shared/default-play-prompts.js +25 -45
  61. package/dist/node/shared/default-preset-host.js +114 -138
  62. package/dist/node/shared/default-setting-improvement-prompt.js +52 -38
  63. package/dist/node/shared/display-regex.js +114 -0
  64. package/dist/node/shared/first-party-action-choices.js +1 -6
  65. package/dist/node/shared/first-party-player-view.js +1 -6
  66. package/dist/node/shared/ordered-author-prompts.js +64 -0
  67. package/dist/node/shared/ordered-followups.js +42 -0
  68. package/dist/node/shared/ordered-play-prompts.js +73 -0
  69. package/dist/web/assets/index-BXfelCi9.js +1207 -0
  70. package/dist/web/assets/index-BYnhUxlk.css +1 -0
  71. package/dist/web/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/node/runtime/model/SettingAuthorStream.js +0 -21
  74. package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +0 -1731
  75. package/dist/web/assets/index-D5VqDvjY.js +0 -1192
  76. package/dist/web/assets/index-Xa0s35CJ.css +0 -1
@@ -0,0 +1,494 @@
1
+ import { createHash } from "node:crypto";
2
+ import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
3
+ import { readDeclaredWorldClock } from "../prompt/WorldMaintenanceReport.js";
4
+ import { WorldOperationClaimLostError } from "../world/WorldOperationCoordinator.js";
5
+ import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
6
+ import { assertUsableWorldRevisionFiles, inspectWorldRevisionFiles, } from "./WorldRevisionAuthoringTransaction.js";
7
+ export class WorldRevisionWorkspaceError extends Error {
8
+ constructor(message, options) {
9
+ super(message, options);
10
+ this.name = "WorldRevisionWorkspaceError";
11
+ }
12
+ }
13
+ /** Owns the one persistent state/control worktree for a locked world. */
14
+ export class WorldRevisionWorkspace {
15
+ #store;
16
+ #worlds;
17
+ constructor(input) {
18
+ this.#store = input.store;
19
+ this.#worlds = input.worlds;
20
+ }
21
+ async open(worldId) {
22
+ const locked = await this.#worlds.readWorldRevisionLock(worldId);
23
+ if (locked !== null) {
24
+ const resumed = await this.#resumeLocked(locked);
25
+ return resumed.lifecycle === "active" ? resumed : this.open(worldId);
26
+ }
27
+ const epochId = this.#store.createEpochId();
28
+ const acquired = await this.#worlds.acquireWorldRevisionLock(worldId, epochId);
29
+ if (acquired.kind === "busy") {
30
+ const current = await this.#worlds.readWorldRevisionLock(worldId);
31
+ if (current === null)
32
+ throw new WorldRevisionWorkspaceError("Another world operation is still finishing; retry opening the revision");
33
+ const resumed = await this.#resumeLocked(current);
34
+ return resumed.lifecycle === "active" ? resumed : this.open(worldId);
35
+ }
36
+ try {
37
+ return await this.#resumeLocked(acquired.handle);
38
+ }
39
+ catch (error) {
40
+ await this.#worlds.releaseWorldRevisionLock(acquired.handle);
41
+ throw error;
42
+ }
43
+ }
44
+ async active(worldId) {
45
+ const handle = await this.#worlds.readWorldRevisionLock(worldId);
46
+ if (handle === null)
47
+ return null;
48
+ const resumed = await this.#resumeLocked(handle);
49
+ return resumed.lifecycle === "active" ? resumed : null;
50
+ }
51
+ async latest(worldId) {
52
+ return (await this.#store.listEpochs(worldId))[0] ?? null;
53
+ }
54
+ async sealed(worldId) {
55
+ return (await this.#store.listEpochs(worldId)).flatMap((epoch) => {
56
+ if ((epoch.lifecycle !== "applied" && epoch.lifecycle !== "discarded") ||
57
+ epoch.finishedAt === null)
58
+ return [];
59
+ return [
60
+ {
61
+ epochId: epoch.epochId,
62
+ worldId: epoch.worldId,
63
+ lifecycle: epoch.lifecycle,
64
+ baseHead: epoch.baseHead,
65
+ diff: fileDiff(epoch.baseFiles, epoch.files),
66
+ changes: structuredClone(epoch.changes),
67
+ createdAt: epoch.createdAt,
68
+ finishedAt: epoch.finishedAt,
69
+ appliedHead: epoch.apply?.committedHead ?? null,
70
+ },
71
+ ];
72
+ });
73
+ }
74
+ async replace(input) {
75
+ const handle = revisionHandle(input);
76
+ return this.#withMutableEpoch(handle, async (epoch) => {
77
+ assertExpectedRevision(epoch, input.expectedRevision);
78
+ const files = cloneFiles(input.files);
79
+ assertUsableWorldRevisionFiles(files, epoch.baseFiles);
80
+ const changes = fileDiff(epoch.files, files);
81
+ if (changes.length === 0)
82
+ return epoch;
83
+ epoch.files = files;
84
+ epoch.revision = contentTreeFingerprint(files);
85
+ epoch.changes.push({
86
+ changeSetId: this.#store.createChangeSetId(),
87
+ source: "manual",
88
+ createdAt: Date.now(),
89
+ changes,
90
+ });
91
+ epoch.updatedAt = Date.now();
92
+ await this.#store.saveEpoch(epoch);
93
+ return epoch;
94
+ });
95
+ }
96
+ async publishAi(input) {
97
+ const handle = revisionHandle(input);
98
+ return this.#withMutableEpoch(handle, async (epoch) => {
99
+ if (epoch.revision === input.afterRevision) {
100
+ const ids = input.toolResults.flatMap(({ changeSetId }) => changeSetId === null ? [] : [changeSetId]);
101
+ if (ids.every((id) => epoch.changes.some(({ changeSetId }) => changeSetId === id)))
102
+ return epoch;
103
+ }
104
+ assertExpectedRevision(epoch, input.expectedRevision);
105
+ const files = cloneFiles(input.files);
106
+ assertUsableWorldRevisionFiles(files, epoch.baseFiles);
107
+ if (contentTreeFingerprint(files) !== input.afterRevision)
108
+ throw new WorldRevisionWorkspaceError("Prepared AI publication does not match its revision fingerprint");
109
+ for (const result of input.toolResults) {
110
+ if (result.isError ||
111
+ result.changeSetId === null ||
112
+ result.changes.length === 0)
113
+ continue;
114
+ epoch.changes.push({
115
+ changeSetId: result.changeSetId,
116
+ source: "ai",
117
+ createdAt: Date.now(),
118
+ changes: structuredClone(result.changes),
119
+ });
120
+ }
121
+ epoch.files = files;
122
+ epoch.revision = input.afterRevision;
123
+ epoch.updatedAt = Date.now();
124
+ await this.#store.saveEpoch(epoch);
125
+ return epoch;
126
+ });
127
+ }
128
+ async rollback(input) {
129
+ const handle = revisionHandle(input);
130
+ return this.#withMutableEpoch(handle, async (epoch) => {
131
+ const set = epoch.changes.find(({ changeSetId }) => changeSetId === input.changeSetId);
132
+ const matches = set?.changes.filter(({ path }) => path === input.path);
133
+ const change = matches?.[0];
134
+ if (set === undefined || matches?.length !== 1 || change === undefined)
135
+ throw new WorldRevisionWorkspaceError("The requested file change does not exist in this revision epoch");
136
+ if (fileMatchesBefore(epoch.files, change))
137
+ return {
138
+ status: "already_rolled_back",
139
+ changeSetId: input.changeSetId,
140
+ path: input.path,
141
+ changes: [],
142
+ epoch,
143
+ };
144
+ const inverse = inverseDiff(epoch.files, change);
145
+ const files = restoreBefore(epoch.files, change);
146
+ assertUsableWorldRevisionFiles(files, epoch.baseFiles);
147
+ epoch.files = files;
148
+ epoch.revision = contentTreeFingerprint(files);
149
+ epoch.changes.push({
150
+ changeSetId: this.#store.createChangeSetId(),
151
+ source: "rollback",
152
+ createdAt: Date.now(),
153
+ changes: [inverse],
154
+ });
155
+ epoch.updatedAt = Date.now();
156
+ await this.#store.saveEpoch(epoch);
157
+ return {
158
+ status: "rolled_back",
159
+ changeSetId: input.changeSetId,
160
+ path: input.path,
161
+ changes: [inverse],
162
+ epoch,
163
+ };
164
+ });
165
+ }
166
+ async discard(input) {
167
+ const handle = revisionHandle(input);
168
+ const epoch = await this.#withMutableEpoch(handle, async (current) => {
169
+ current.lifecycle = "discarded";
170
+ current.finishedAt = Date.now();
171
+ current.updatedAt = current.finishedAt;
172
+ await this.#store.saveEpoch(current);
173
+ return current;
174
+ });
175
+ await this.#worlds.releaseWorldRevisionLock(handle);
176
+ return epoch;
177
+ }
178
+ async apply(input) {
179
+ const handle = revisionHandle(input);
180
+ let epoch = await this.#prepareApply(handle, input.expectedRevision);
181
+ const overallDiff = fileDiff(epoch.baseFiles, epoch.files);
182
+ if (overallDiff.length === 0) {
183
+ epoch = await this.#finishEmptyApply(handle);
184
+ await this.#worlds.releaseWorldRevisionLock(handle);
185
+ return epoch;
186
+ }
187
+ if (epoch.apply?.phase === "prepared") {
188
+ const binding = await this.#worlds.bindWorldRevision(handle);
189
+ const worldClock = readDeclaredWorldClock(WorldDocumentStore.open({
190
+ layout: "world_state",
191
+ files: epoch.files,
192
+ }));
193
+ const outcome = await this.#worlds.commitCorrection({
194
+ ...(worldClock === undefined ? {} : { worldClock }),
195
+ operationId: epoch.apply.operationId,
196
+ worldId: epoch.worldId,
197
+ parentHead: epoch.baseHead,
198
+ nextMaterials: binding.additionalMaterials,
199
+ stateChanges: stateChanges(epoch.baseFiles, epoch.files),
200
+ worldRevisionLock: handle,
201
+ validationControl: controlFiles(epoch.files),
202
+ });
203
+ epoch = await this.#recordStateCommit(handle, outcome);
204
+ }
205
+ if (epoch.apply?.phase === "state_committed") {
206
+ await this.#worlds.replaceWorldRevisionControl({
207
+ handle,
208
+ expectedFingerprint: epoch.baseControlFingerprint,
209
+ files: controlFiles(epoch.files),
210
+ });
211
+ await this.#recordControlPublication(handle);
212
+ }
213
+ epoch = await this.#finishApply(handle);
214
+ await this.#worlds.releaseWorldRevisionLock(handle);
215
+ return epoch;
216
+ }
217
+ view(epoch, locked) {
218
+ const inspection = inspectWorldRevisionFiles(epoch.files, epoch.baseFiles);
219
+ return {
220
+ epochId: epoch.epochId,
221
+ worldId: epoch.worldId,
222
+ lifecycle: epoch.lifecycle,
223
+ locked,
224
+ baseHead: epoch.baseHead,
225
+ revision: epoch.revision,
226
+ files: cloneFiles(epoch.files),
227
+ diff: fileDiff(epoch.baseFiles, epoch.files),
228
+ diagnostics: structuredClone(inspection.diagnostics),
229
+ changes: structuredClone(epoch.changes),
230
+ createdAt: epoch.createdAt,
231
+ updatedAt: epoch.updatedAt,
232
+ finishedAt: epoch.finishedAt,
233
+ appliedHead: epoch.apply?.committedHead ?? null,
234
+ };
235
+ }
236
+ async #resumeLocked(handle) {
237
+ let epoch = await this.#store.readEpochIfPresent(handle.epochId);
238
+ if (epoch === null) {
239
+ const binding = await this.#worlds.bindWorldRevision(handle);
240
+ const files = Object.entries(binding.files)
241
+ .map(([path, contents]) => ({ path, contents }))
242
+ .sort((left, right) => left.path.localeCompare(right.path));
243
+ assertUsableWorldRevisionFiles(files, files);
244
+ const now = Date.now();
245
+ epoch = {
246
+ schemaVersion: 1,
247
+ epochId: handle.epochId,
248
+ worldId: handle.worldId,
249
+ lifecycle: "active",
250
+ baseHead: binding.parentHead,
251
+ baseControlFingerprint: controlFingerprint(controlFiles(files)),
252
+ baseFiles: cloneFiles(files),
253
+ files: cloneFiles(files),
254
+ revision: contentTreeFingerprint(files),
255
+ changes: [],
256
+ apply: null,
257
+ createdAt: now,
258
+ updatedAt: now,
259
+ finishedAt: null,
260
+ };
261
+ await this.#store.saveEpoch(epoch);
262
+ }
263
+ if (epoch.worldId !== handle.worldId)
264
+ throw new WorldRevisionWorkspaceError("World-revision lock points to another world's epoch");
265
+ if (epoch.lifecycle === "applying")
266
+ return this.apply({
267
+ worldId: epoch.worldId,
268
+ epochId: epoch.epochId,
269
+ expectedRevision: epoch.apply?.expectedRevision ?? epoch.revision,
270
+ });
271
+ if (epoch.lifecycle !== "active") {
272
+ await this.#worlds.releaseWorldRevisionLock(handle);
273
+ return epoch;
274
+ }
275
+ return epoch;
276
+ }
277
+ async #withMutableEpoch(handle, action) {
278
+ return this.#withRevisionOwnership(handle, async () => {
279
+ const epoch = await this.#store.readEpoch(handle.epochId);
280
+ if (epoch.worldId !== handle.worldId || epoch.lifecycle !== "active")
281
+ throw new WorldRevisionWorkspaceError("World-revision epoch is no longer editable");
282
+ return action(epoch);
283
+ });
284
+ }
285
+ async #prepareApply(handle, expectedRevision) {
286
+ return this.#withRevisionOwnership(handle, async () => {
287
+ const epoch = await this.#store.readEpoch(handle.epochId);
288
+ if (epoch.lifecycle === "applying")
289
+ return epoch;
290
+ if (epoch.lifecycle !== "active")
291
+ throw new WorldRevisionWorkspaceError("World-revision epoch is no longer applicable");
292
+ assertExpectedRevision(epoch, expectedRevision);
293
+ assertUsableWorldRevisionFiles(epoch.files, epoch.baseFiles);
294
+ epoch.lifecycle = "applying";
295
+ epoch.apply = {
296
+ operationId: `world-revision-${epoch.epochId}`,
297
+ expectedRevision,
298
+ phase: "prepared",
299
+ committedHead: null,
300
+ };
301
+ epoch.updatedAt = Date.now();
302
+ await this.#store.saveEpoch(epoch);
303
+ return epoch;
304
+ });
305
+ }
306
+ async #finishEmptyApply(handle) {
307
+ return this.#withRevisionOwnership(handle, async () => {
308
+ const epoch = await this.#store.readEpoch(handle.epochId);
309
+ if (epoch.lifecycle !== "applying" || epoch.apply === null)
310
+ throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
311
+ epoch.lifecycle = "applied";
312
+ epoch.apply.phase = "control_published";
313
+ epoch.apply.committedHead = epoch.baseHead;
314
+ epoch.finishedAt = Date.now();
315
+ epoch.updatedAt = epoch.finishedAt;
316
+ await this.#store.saveEpoch(epoch);
317
+ return epoch;
318
+ });
319
+ }
320
+ async #recordStateCommit(handle, outcome) {
321
+ return this.#withRevisionOwnership(handle, async () => {
322
+ const epoch = await this.#store.readEpoch(handle.epochId);
323
+ if (epoch.lifecycle !== "applying" || epoch.apply === null)
324
+ throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
325
+ epoch.apply.phase = "state_committed";
326
+ epoch.apply.committedHead = outcome.head;
327
+ epoch.updatedAt = Date.now();
328
+ await this.#store.saveEpoch(epoch);
329
+ return epoch;
330
+ });
331
+ }
332
+ async #recordControlPublication(handle) {
333
+ return this.#withRevisionOwnership(handle, async () => {
334
+ const epoch = await this.#store.readEpoch(handle.epochId);
335
+ if (epoch.lifecycle !== "applying" || epoch.apply === null)
336
+ throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
337
+ epoch.apply.phase = "control_published";
338
+ epoch.updatedAt = Date.now();
339
+ await this.#store.saveEpoch(epoch);
340
+ return epoch;
341
+ });
342
+ }
343
+ async #finishApply(handle) {
344
+ return this.#withRevisionOwnership(handle, async () => {
345
+ const epoch = await this.#store.readEpoch(handle.epochId);
346
+ if (epoch.lifecycle !== "applying" ||
347
+ epoch.apply?.phase !== "control_published")
348
+ throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
349
+ epoch.lifecycle = "applied";
350
+ epoch.finishedAt = Date.now();
351
+ epoch.updatedAt = epoch.finishedAt;
352
+ await this.#store.saveEpoch(epoch);
353
+ return epoch;
354
+ });
355
+ }
356
+ async #withRevisionOwnership(handle, action) {
357
+ try {
358
+ return await this.#worlds.operations.withWorldRevisionLock(handle, action);
359
+ }
360
+ catch (error) {
361
+ if (error instanceof WorldOperationClaimLostError)
362
+ throw new WorldRevisionWorkspaceError("The world-revision epoch no longer owns this world's lock", { cause: error });
363
+ throw error;
364
+ }
365
+ }
366
+ }
367
+ function revisionHandle(input) {
368
+ return { worldId: input.worldId, epochId: input.epochId };
369
+ }
370
+ function assertExpectedRevision(epoch, expected) {
371
+ if (epoch.revision !== expected)
372
+ throw new WorldRevisionWorkspaceError("The world-revision worktree changed; refresh it before writing");
373
+ }
374
+ function stateChanges(baseFiles, files) {
375
+ const base = new Map(baseFiles
376
+ .filter(({ path }) => path.startsWith("state/"))
377
+ .map((file) => [file.path, file]));
378
+ const snapshot = WorldDocumentStore.open({
379
+ layout: "world_state",
380
+ files: files.filter(({ path }) => path.startsWith("state/")),
381
+ });
382
+ if (snapshot.status !== "usable")
383
+ throw new WorldRevisionWorkspaceError("Only a usable world-state snapshot can be applied");
384
+ return files
385
+ .filter(({ path }) => path.startsWith("state/"))
386
+ .flatMap((file) => {
387
+ const previous = base.get(file.path);
388
+ if (previous?.contents === file.contents)
389
+ return [];
390
+ const result = snapshot.query({
391
+ kind: "read_document",
392
+ document: { logicalPath: file.path },
393
+ });
394
+ if (result.kind !== "read_document" || !result.ok)
395
+ throw new WorldRevisionWorkspaceError(`Cannot resolve changed world document: ${file.path}`);
396
+ if (previous !== undefined) {
397
+ const baseSnapshot = WorldDocumentStore.open({
398
+ layout: "world_state",
399
+ files: baseFiles.filter(({ path }) => path.startsWith("state/")),
400
+ });
401
+ const before = baseSnapshot.query({
402
+ kind: "read_document",
403
+ document: { logicalPath: file.path },
404
+ });
405
+ if (before.kind !== "read_document" ||
406
+ !before.ok ||
407
+ before.document.documentId !== result.document.documentId)
408
+ throw new WorldRevisionWorkspaceError(`Existing world-document identity changed: ${file.path}`);
409
+ }
410
+ return [
411
+ {
412
+ kind: previous === undefined ? "create" : "replace",
413
+ documentId: result.document.documentId,
414
+ stableShortRef: result.document.shortRef,
415
+ relativePath: file.path.slice("state/".length),
416
+ codec: result.document.codec,
417
+ expectedPreviousHash: previous === undefined ? null : sha256(previous.contents),
418
+ nextHash: sha256(file.contents),
419
+ canonicalNextBytes: file.contents,
420
+ },
421
+ ];
422
+ });
423
+ }
424
+ function controlFiles(files) {
425
+ return files
426
+ .filter(({ path }) => path.startsWith("control/"))
427
+ .map(({ path, contents, encoding }) => ({
428
+ path: path.slice("control/".length),
429
+ contents,
430
+ ...(encoding === undefined ? {} : { encoding }),
431
+ }));
432
+ }
433
+ function controlFingerprint(files) {
434
+ const hash = createHash("sha256");
435
+ for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path)))
436
+ hash.update(file.path).update("\0").update(file.contents).update("\0");
437
+ return `sha256:${hash.digest("hex")}`;
438
+ }
439
+ function sha256(contents) {
440
+ return `sha256:${createHash("sha256").update(contents).digest("hex")}`;
441
+ }
442
+ function fileDiff(beforeFiles, afterFiles) {
443
+ const before = new Map(beforeFiles.map((file) => [file.path, file]));
444
+ const after = new Map(afterFiles.map((file) => [file.path, file]));
445
+ return [...new Set([...before.keys(), ...after.keys()])]
446
+ .sort()
447
+ .flatMap((path) => {
448
+ const left = before.get(path);
449
+ const right = after.get(path);
450
+ if (left?.contents === right?.contents &&
451
+ left?.encoding === right?.encoding)
452
+ return [];
453
+ return [
454
+ {
455
+ path,
456
+ kind: left === undefined
457
+ ? "create"
458
+ : right === undefined
459
+ ? "delete"
460
+ : "modify",
461
+ before: left?.contents ?? null,
462
+ after: right?.contents ?? null,
463
+ },
464
+ ];
465
+ });
466
+ }
467
+ function fileMatchesBefore(files, change) {
468
+ const current = files.find(({ path }) => path === change.path);
469
+ return change.before === null
470
+ ? current === undefined
471
+ : current?.contents === change.before && current.encoding === undefined;
472
+ }
473
+ function restoreBefore(files, change) {
474
+ const restored = new Map(files.map((file) => [file.path, structuredClone(file)]));
475
+ if (change.before === null)
476
+ restored.delete(change.path);
477
+ else
478
+ restored.set(change.path, { path: change.path, contents: change.before });
479
+ return [...restored.values()].sort((left, right) => left.path.localeCompare(right.path));
480
+ }
481
+ function inverseDiff(files, change) {
482
+ const before = files.find(({ path }) => path === change.path)?.contents ?? null;
483
+ return {
484
+ path: change.path,
485
+ kind: before === null ? "create" : change.before === null ? "delete" : "modify",
486
+ before,
487
+ after: change.before,
488
+ };
489
+ }
490
+ function cloneFiles(files) {
491
+ return files
492
+ .map((file) => structuredClone(file))
493
+ .sort((left, right) => left.path.localeCompare(right.path));
494
+ }
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PassThrough } from "node:stream";
3
+ import { conversationUpdate, } from "../protocol/conversationObservation.js";
4
+ import { v1Protocol } from "../protocol/v1.js";
5
+ /** A connection observes an independently owned Runtime task. No connection
6
+ * teardown path has access to the model's cancellation signal. */
7
+ export function registerConversationStream(server, runtime) {
8
+ const connections = new Set();
9
+ server.addHook("preClose", () => {
10
+ for (const stream of connections)
11
+ stream.destroy();
12
+ return Promise.resolve();
13
+ });
14
+ server.get("/api/runtime/v1/events", (request, reply) => {
15
+ const { kind, id, sessionId } = request.query;
16
+ if ((kind !== "play" && kind !== "setting" && kind !== "revision") ||
17
+ typeof id !== "string" ||
18
+ !/^[A-Za-z0-9_-]{1,200}$/.test(id) ||
19
+ (sessionId !== undefined &&
20
+ (kind === "play" ||
21
+ typeof sessionId !== "string" ||
22
+ !/^[A-Za-z0-9_-]{1,200}$/.test(sessionId)))) {
23
+ return reply.code(400).send({ error: "Invalid conversation target" });
24
+ }
25
+ const target = kind === "play"
26
+ ? { kind, id }
27
+ : { kind, id, ...(sessionId === undefined ? {} : { sessionId }) };
28
+ const stream = new PassThrough();
29
+ connections.add(stream);
30
+ const epoch = randomUUID();
31
+ let sequence = 0;
32
+ let changes = 0;
33
+ let durableChanged = true;
34
+ let reading = false;
35
+ let previous = null;
36
+ let timer;
37
+ const write = (text) => {
38
+ if (stream.destroyed)
39
+ return;
40
+ // A slow renderer may reconnect from a snapshot; it cannot backpressure
41
+ // Provider execution or accumulate unbounded queued token events.
42
+ if (stream.readableLength + stream.writableLength > 1024 * 1024) {
43
+ stream.destroy();
44
+ return;
45
+ }
46
+ stream.write(text);
47
+ };
48
+ const read = async () => {
49
+ if (reading || stream.destroyed)
50
+ return;
51
+ reading = true;
52
+ const revision = changes;
53
+ const reload = durableChanged;
54
+ durableChanged = false;
55
+ try {
56
+ const next = await runtime.readConversation(target, reload ? undefined : (previous ?? undefined));
57
+ if (stream.destroyed)
58
+ return;
59
+ // Subscribe-before-read plus a revision check closes the initialization
60
+ // and asynchronous snapshot race without replaying transient deltas.
61
+ if (revision !== changes)
62
+ schedule();
63
+ if (previous === null ||
64
+ JSON.stringify(previous) !== JSON.stringify(next)) {
65
+ const update = conversationUpdate(previous, next);
66
+ write(`id: ${epoch}:${++sequence}\nevent: observation\ndata: ${JSON.stringify({ protocol: v1Protocol, update })}\n\n`);
67
+ previous = next;
68
+ }
69
+ }
70
+ catch (error) {
71
+ write(`event: failure\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : "Conversation observation failed" })}\n\n`);
72
+ stream.end();
73
+ cleanup();
74
+ }
75
+ finally {
76
+ reading = false;
77
+ }
78
+ };
79
+ const schedule = () => {
80
+ if (timer !== undefined || stream.destroyed)
81
+ return;
82
+ timer = setTimeout(() => {
83
+ timer = undefined;
84
+ void read();
85
+ }, 25);
86
+ };
87
+ const unsubscribe = runtime.subscribeConversation(target, (durable) => {
88
+ changes += 1;
89
+ durableChanged ||= durable;
90
+ schedule();
91
+ });
92
+ const heartbeat = setInterval(() => write(": heartbeat\n\n"), 15_000);
93
+ const cleanup = () => {
94
+ unsubscribe();
95
+ clearInterval(heartbeat);
96
+ clearTimeout(timer);
97
+ connections.delete(stream);
98
+ };
99
+ stream.on("close", cleanup);
100
+ reply.raw.on("close", () => stream.destroy());
101
+ write(": connected\nretry: 1000\n\n");
102
+ void read();
103
+ return reply
104
+ .header("Cache-Control", "no-store")
105
+ .header("X-Accel-Buffering", "no")
106
+ .type("text/event-stream; charset=utf-8")
107
+ .send(stream);
108
+ });
109
+ }
@@ -1,3 +1,4 @@
1
+ import { registerConversationStream } from "./ConversationStream.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { PassThrough } from "node:stream";
@@ -27,7 +28,9 @@ function isPollingRequest(request) {
27
28
  return false;
28
29
  const runtimeRequest = body.request;
29
30
  return (isRecord(runtimeRequest) &&
30
- (runtimeRequest.type === "setting-improvement.progress" ||
31
+ (runtimeRequest.type === "setting-improvement.read" ||
32
+ runtimeRequest.type === "setting-improvement.status" ||
33
+ runtimeRequest.type === "setting-improvement.overview" ||
31
34
  runtimeRequest.type === "play.chain.inspect"));
32
35
  }
33
36
  function isRuntimeApiRequest(request) {
@@ -77,6 +80,7 @@ export async function createServer(input) {
77
80
  .header("X-Frame-Options", "DENY");
78
81
  done(null, payload);
79
82
  });
83
+ registerConversationStream(server, input.runtime);
80
84
  server.get("/health", () => ({
81
85
  status: "ok",
82
86
  protocol: "narraeon.runtime/v1",