narraeon 0.6.0 → 0.6.2

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 (48) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/node/protocol/v1.js +12 -2
  4. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  5. package/dist/node/runtime/V1Runtime.js +5 -5
  6. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  7. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  8. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  9. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  10. package/dist/node/runtime/authoring/ModelToolConversation.js +25 -2
  11. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  12. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  13. package/dist/node/runtime/config/dataRootLock.js +108 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +0 -10
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
  16. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  17. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  18. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  19. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  20. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
  21. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
  22. package/dist/node/runtime/play/PlayCallChain.js +338 -366
  23. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  24. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  25. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  26. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +39 -7
  27. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  28. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +14 -6
  29. package/dist/node/runtime/setting/SettingImprovementSession.js +39 -25
  30. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  31. package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
  32. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  33. package/dist/node/runtime/world/WorldActivity.js +14 -22
  34. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  35. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  36. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  37. package/dist/node/runtime/world/WorldQueue.js +46 -0
  38. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  39. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +1 -13
  40. package/dist/node/runtime/world-revision/WorldRevisionSession.js +29 -2
  41. package/dist/node/server/mobile.js +52 -0
  42. package/dist/node/server/startWebServer.js +15 -8
  43. package/dist/web/assets/index-CXnFaYK9.js +1323 -0
  44. package/dist/web/assets/{index-BJ-1Mb0e.css → index-LPMV0pl3.css} +1 -1
  45. package/dist/web/index.html +2 -2
  46. package/package.json +2 -1
  47. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  48. package/dist/web/assets/index-BgBL8G-A.js +0 -1323
@@ -1,8 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { HistoryQuery, historyInputs } from "../history/HistoryQuery.js";
2
3
  import { renderHistoryResult } from "../history/HistoryRendering.js";
3
4
  import { stateDirectoryHandle, parseStateDirectoryHandle, } from "../world/StateDirectoryHandles.js";
4
5
  import { renderDocumentWritePosition } from "../prompt/WorldMaintenanceReport.js";
5
- import { createHash } from "node:crypto";
6
6
  import { stringify } from "yaml";
7
7
  import { renderPlayerViewBindings } from "../world/PlayerViewBindings.js";
8
8
  import { PromptCompilationError, } from "../prompt/FileNativePromptCompiler.js";
@@ -56,66 +56,16 @@ export class FileNativePlayDocuments {
56
56
  get committedSnapshot() {
57
57
  return this.#committedSnapshot;
58
58
  }
59
- bindBootstrap(bootstrap, previousAuthorization) {
60
- const injected = bootstrapAuthorizations(bootstrap, this.#candidate.snapshot);
61
- this.#reads = injected;
62
- // Refreshing a prefix retains the native conversation. Its exact reads
63
- // remain valid only on the identical state tree, including our own writes.
64
- if (previousAuthorization?.stateFingerprint ===
65
- fingerprintStateFiles(this.#candidate.files)) {
66
- this.restoreAuthorizationCheckpoint(previousAuthorization);
67
- for (const [ref, locators] of injected.documents) {
68
- for (const locator of locators ?? [null])
69
- authorizeRead(this.#reads, this.#candidate.snapshot.id, ref, locator);
70
- }
71
- }
59
+ /**
60
+ * Read authorizations live only in memory (ADR-0044). A fresh binding starts
61
+ * from the bootstrap's own reads; writes are guarded at commit time by the
62
+ * previous-content hash of each changed file.
63
+ */
64
+ bindBootstrap(bootstrap) {
65
+ this.#reads = bootstrapAuthorizations(bootstrap, this.#candidate.snapshot);
72
66
  // Creation capabilities come from this prefix, not past directory slots.
73
67
  this.#declaredDirectories = declaredStateDirectories(bootstrap);
74
68
  }
75
- authorizationCheckpoint() {
76
- return {
77
- schemaVersion: 1,
78
- kind: "play_document_authorizations",
79
- stateFingerprint: fingerprintStateFiles(this.#candidate.files),
80
- documents: [...this.#reads.documents.entries()]
81
- .sort(([left], [right]) => left.localeCompare(right))
82
- .map(([shortRef, locators]) => ({
83
- shortRef,
84
- locators: locators === null
85
- ? null
86
- : locators
87
- .filter((locator, index, all) => all.findIndex((candidate) => sameLocator(candidate, locator)) === index)
88
- .map((locator) => structuredClone(locator)),
89
- })),
90
- };
91
- }
92
- restoreAuthorizationCheckpoint(checkpoint) {
93
- if (!isPlayDocumentAuthorizationCheckpoint(checkpoint))
94
- throw new TypeError("The play-document authorization checkpoint has an invalid format");
95
- if (checkpoint.stateFingerprint !==
96
- fingerprintStateFiles(this.#candidate.files))
97
- throw new TypeError("The play-document authorization checkpoint does not match the current state");
98
- const documents = new Map();
99
- for (const { shortRef, locators } of checkpoint.documents) {
100
- if (documentDescriptorByRef(this.#candidate.snapshot, shortRef) === null)
101
- throw new TypeError(`The play-document authorization checkpoint refers to missing @${shortRef}`);
102
- const restoredLocators = locators?.map((locator) => {
103
- const selected = this.#candidate.snapshot.query({
104
- kind: "select_node",
105
- document: { shortRef },
106
- locator,
107
- });
108
- if (selected.kind !== "select_node" || !selected.ok)
109
- throw new TypeError(`The play-document authorization checkpoint refers to a missing node in @${shortRef}`);
110
- return structuredClone(selected.node.locator);
111
- });
112
- documents.set(shortRef, restoredLocators ?? null);
113
- }
114
- this.#reads = {
115
- snapshotId: this.#candidate.snapshot.id,
116
- documents,
117
- };
118
- }
119
69
  execute(call, history, options = {}) {
120
70
  let historyQuery;
121
71
  const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
@@ -254,15 +204,6 @@ function authorizeToolRead(authorizations, snapshot, result) {
254
204
  return;
255
205
  authorizeRead(authorizations, snapshot.id, authorization.shortRef, authorization.locator);
256
206
  }
257
- function sameLocator(left, right) {
258
- if (left === null || right === null)
259
- return left === right;
260
- const leftPath = "yaml" in left ? left.yaml : left.markdown;
261
- const rightPath = "yaml" in right ? right.yaml : right.markdown;
262
- return ("yaml" in left === "yaml" in right &&
263
- leftPath.length === rightPath.length &&
264
- leftPath.every((segment, index) => segment === rightPath[index]));
265
- }
266
207
  function parseYamlHandleSegment(segment) {
267
208
  return /^(?:0|[1-9][0-9]*)$/u.test(segment) ? Number(segment) : segment;
268
209
  }
@@ -914,64 +855,6 @@ export function fingerprintControl(files) {
914
855
  hash.update(path).update("\0").update(contents).update("\0");
915
856
  return `sha256:${hash.digest("hex")}`;
916
857
  }
917
- export function isPlayDocumentAuthorizationCheckpoint(value) {
918
- if (!record(value) ||
919
- !hasExactKeys(value, [
920
- "schemaVersion",
921
- "kind",
922
- "stateFingerprint",
923
- "documents",
924
- ]) ||
925
- value.schemaVersion !== 1 ||
926
- value.kind !== "play_document_authorizations" ||
927
- typeof value.stateFingerprint !== "string" ||
928
- !/^sha256:[0-9a-f]{64}$/u.test(value.stateFingerprint) ||
929
- !Array.isArray(value.documents) ||
930
- value.documents.length > 100_000)
931
- return false;
932
- const refs = new Set();
933
- for (const document of value.documents) {
934
- if (!record(document) ||
935
- !hasExactKeys(document, ["shortRef", "locators"]) ||
936
- typeof document.shortRef !== "string" ||
937
- !/^[a-z][a-z0-9-]*$/u.test(document.shortRef) ||
938
- refs.has(document.shortRef) ||
939
- (document.locators !== null &&
940
- (!Array.isArray(document.locators) ||
941
- document.locators.length > 100_000 ||
942
- !document.locators.every(isPersistedAuthorizedLocator))))
943
- return false;
944
- refs.add(document.shortRef);
945
- }
946
- return true;
947
- }
948
- function fingerprintStateFiles(files) {
949
- const hash = createHash("sha256");
950
- for (const [path, contents] of Object.entries(files)
951
- .filter(([path]) => path.startsWith("state/"))
952
- .sort(([left], [right]) => left.localeCompare(right)))
953
- hash.update(path).update("\0").update(contents).update("\0");
954
- return `sha256:${hash.digest("hex")}`;
955
- }
956
- function isPersistedAuthorizedLocator(value) {
957
- if (!record(value))
958
- return false;
959
- if (hasExactKeys(value, ["yaml"]))
960
- return (Array.isArray(value.yaml) &&
961
- value.yaml.length <= 64 &&
962
- value.yaml.every((segment) => typeof segment === "string" ||
963
- (Number.isSafeInteger(segment) && Number(segment) >= 0)));
964
- if (hasExactKeys(value, ["markdown"]))
965
- return (Array.isArray(value.markdown) &&
966
- value.markdown.length <= 64 &&
967
- value.markdown.every((segment) => typeof segment === "string"));
968
- return false;
969
- }
970
- function hasExactKeys(value, expected) {
971
- const keys = Object.keys(value);
972
- return (keys.length === expected.length &&
973
- expected.every((key) => Object.hasOwn(value, key)));
974
- }
975
858
  function record(value) {
976
859
  return typeof value === "object" && value !== null && !Array.isArray(value);
977
860
  }
@@ -59,8 +59,8 @@ export async function runPlayFollowupRequests(input) {
59
59
  failure ??= outcome.failure;
60
60
  }
61
61
  const extension = failure === undefined
62
- ? await input.artifacts.completeExtension(input.context.operationId, completed)
63
- : await input.artifacts.failExtension(input.context.operationId, "recovery_required", failure, completed);
62
+ ? await input.artifacts.completeExtension(input.context.worldId, input.context.operationId, completed)
63
+ : await input.artifacts.failExtension(input.context.worldId, input.context.operationId, "recovery_required", failure, completed);
64
64
  return { outcomes, extension };
65
65
  }
66
66
  async function runOne(input, followup, prefix, requestSignal) {
@@ -1,18 +1,18 @@
1
1
  import { captureNarrativeOrigin } from "../history/HistoryBackground.js";
2
- import { FileNativePlayDocuments, } from "./PlayDocumentTools.js";
2
+ import { FileNativePlayDocuments } from "./PlayDocumentTools.js";
3
3
  import { currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
4
4
  import { isPlayerRoundMarker, playerInputAppend } from "./PlayContinuity.js";
5
5
  import { PlayCallChainError } from "./PlayCallChainError.js";
6
6
  /**
7
- * Prepare the selected player's prefix at the existing pre-commit seam.
8
- * Endpoint reads, revision submission and publication belong to the operation.
7
+ * Split a context at a selected player message for an edit: the prefix before
8
+ * it is kept, and the replacement is appended once the world has rewound and
9
+ * committed it (PlayCallChain.revisePlayer).
9
10
  */
10
11
  export function preparePlayerRevisionTrace(source, eventIndex) {
11
12
  const prefix = structuredClone(source.events.slice(0, eventIndex));
12
13
  const transcript = transcriptThroughEvents(source.transcript, prefix);
13
14
  const player = source.events[eventIndex];
14
15
  return {
15
- // This append precedes the operation's materialization check, as before.
16
16
  withReplacement(replacement) {
17
17
  const events = [
18
18
  ...prefix,
@@ -46,7 +46,7 @@ export function preparePlayerRevisionTrace(source, eventIndex) {
46
46
  pagePrefixAtEndpoint(endpoint) {
47
47
  if (prefix.length === 0)
48
48
  return null;
49
- // Preserve the fresh path's post-commit prefix reconstruction seam.
49
+ // A fresh context keeps the page before the edit as a detached prefix.
50
50
  const prefixTranscript = transcriptThroughEvents(source.transcript, prefix);
51
51
  return assembleRetainedTrace(source, endpoint, {
52
52
  events: prefix,
@@ -119,7 +119,6 @@ function assembleRetainedTrace(source, endpoint, selection) {
119
119
  completedTools: source.completedTools
120
120
  .filter(({ key }) => completedKeys === null || completedKeys.has(key))
121
121
  .map((item) => structuredClone(item)),
122
- documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(source, selection.events, documents.authorizationCheckpoint()),
123
122
  changedDocuments: changedDocumentsAtHead(source.changedDocuments, endpoint.baseline.state, endpoint.selected.state),
124
123
  nextMaterials: structuredClone(selection.materials),
125
124
  nextEventId: Math.max(0, ...selection.events.map(({ id }) => id)) + 1,
@@ -132,29 +131,16 @@ function assembleRetainedTrace(source, endpoint, selection) {
132
131
  export function restorePlayDocuments(files, context, events) {
133
132
  const documents = new FileNativePlayDocuments(files);
134
133
  try {
135
- const checkpoint = documentAuthorizationThroughEvents(context, events);
134
+ // Read authorizations are memory-only: a restored prefix always starts from
135
+ // its bootstrap, and stale write authority is caught by the commit's hash.
136
136
  const runs = playPromptRunsThroughEvents(context, events);
137
- if (checkpoint === undefined || runs.length > 0)
138
- documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
139
- if (checkpoint !== undefined)
140
- documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
137
+ documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
141
138
  }
142
139
  catch (error) {
143
140
  throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
144
141
  }
145
142
  return documents;
146
143
  }
147
- function documentAuthorizationThroughEvents(context, events) {
148
- const checkpoints = context.documentAuthorizationCheckpoints;
149
- if (checkpoints === undefined || checkpoints.length === 0)
150
- return undefined;
151
- const selectedEventIds = new Set(events.map(({ id }) => id));
152
- const selected = checkpoints.findLast(({ afterEventId }) => afterEventId === 0 || selectedEventIds.has(afterEventId));
153
- // Legacy V1 records had no durable dynamic authorization. If the selected
154
- // prefix predates their first lazily-written checkpoint, retain the exact
155
- // old recovery behavior and rebuild only bootstrap authorization.
156
- return selected === undefined ? undefined : structuredClone(selected);
157
- }
158
144
  export function independentContextCopy(source) {
159
145
  const context = structuredClone(source);
160
146
  context.continuityContextId ??= source.chainId;
@@ -162,20 +148,6 @@ export function independentContextCopy(source) {
162
148
  delete context.branchedBeforePlayer;
163
149
  return context;
164
150
  }
165
- function authorizationCheckpointsThroughEvents(context, events, fallback) {
166
- const eventIds = new Set(events.map(({ id }) => id));
167
- const selected = (context.documentAuthorizationCheckpoints ?? [])
168
- .filter(({ afterEventId }) => afterEventId === 0 || eventIds.has(afterEventId))
169
- .map((checkpoint) => structuredClone(checkpoint));
170
- if (selected.length > 0)
171
- return selected;
172
- return [
173
- {
174
- afterEventId: Math.max(0, ...events.map(({ id }) => id)),
175
- authorization: structuredClone(fallback),
176
- },
177
- ];
178
- }
179
151
  function transcriptThroughEvents(transcript, events) {
180
152
  const result = [];
181
153
  let cursor = 0;
@@ -236,16 +236,48 @@ export function renderDocumentWritePosition(shortRef, fact, locale) {
236
236
  ? `最近写入位置 @${shortRef}:\n正文:${worldWritePositionText(fact.lastBodyWrite, locale)}\n元信息:${worldWritePositionText(fact.lastMetadataWrite, locale)}`
237
237
  : `Last committed write @${shortRef}:\nBody: ${worldWritePositionText(fact.lastBodyWrite, locale)}\nMetadata: ${worldWritePositionText(fact.lastMetadataWrite, locale)}`;
238
238
  }
239
- export function renderWorldMaintenanceReport(report, locale) {
239
+ /**
240
+ * Lists only documents the current tool response changed or that exceed their
241
+ * advisory limit, so the report stays proportional to the edit rather than to
242
+ * the package size.
243
+ */
244
+ export function renderWorldMaintenanceReport(report, changedShortRefs, locale) {
240
245
  if (report === undefined)
241
246
  return "";
242
247
  const zh = locale === "zh-CN";
248
+ const heading = zh
249
+ ? "# 文档整理报告(UTF-8 字节;超限仅提示)"
250
+ : "# Document maintenance (UTF-8 bytes; advisory only)";
251
+ const documents = report.documents.filter((document) => document.overAdvisory || changedShortRefs.has(document.shortRef));
252
+ const slots = report.slots.filter((slot) => slot.overAdvisory ||
253
+ slot.documents.some((shortRef) => changedShortRefs.has(shortRef)));
254
+ if (documents.length === 0 && slots.length === 0)
255
+ return `${heading}\n${zh ? "本次没有改动世界文档,也没有文档或材料槽超过建议上限。" : "No world document changed in this response, and no document or slot exceeds its advisory limit."}`;
256
+ const allColumns = [
257
+ { label: zh ? "正文" : "body", value: (d) => d.bodyBytes },
258
+ {
259
+ label: zh ? "注入贡献" : "injection contribution",
260
+ value: (d) => d.injectedBytes,
261
+ },
262
+ { label: zh ? "建议上限" : "advisory", value: (d) => d.advisoryBytes },
263
+ { label: zh ? "基准" : "baseline", value: (d) => d.baselineBytes },
264
+ { label: zh ? "增长" : "growth", value: (d) => d.growthBytes },
265
+ ];
266
+ // Columns that are unset for every listed document carry no information.
267
+ const columns = allColumns.filter(({ value }) => documents.some((document) => value(document) !== null));
268
+ const overAdvisory = report.documents.some(({ overAdvisory }) => overAdvisory) ||
269
+ report.slots.some(({ overAdvisory }) => overAdvisory);
243
270
  return [
244
- zh
245
- ? "# 文档整理报告(UTF-8 字节;超限仅提示)"
246
- : "# Document maintenance (UTF-8 bytes; advisory only)",
247
- `${zh ? "世界材料 / 其中检查点历史 / 作者提示 / Runtime 提示 / 工具定义" : "World materials / checkpoint history subset / author instructions / Runtime instructions / tool definitions"}: ${report.worldMaterialsBytes} / ${report.checkpointHistoryBytes} / ${report.authorInstructionBytes} / ${report.runtimeInstructionBytes} / ${report.toolDefinitionBytes}`,
248
- ...report.documents.map((document) => `@${document.shortRef}: ${zh ? "正文 / 注入贡献 / 建议上限 / 基准 / 增长" : "body / injection contribution / advisory / baseline / growth"}: ${document.bodyBytes} / ${document.injectedBytes} / ${document.advisoryBytes ?? "—"} / ${document.baselineBytes ?? "—"} / ${document.growthBytes ?? "—"}${document.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
249
- ...report.slots.map((slot) => `${zh ? "材料槽" : "Slot"} ${slot.id}: ${slot.bytes} / ${slot.advisoryBytes ?? "—"}${slot.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
271
+ heading,
272
+ ...(overAdvisory
273
+ ? [
274
+ `${zh ? "世界材料 / 其中检查点历史 / 作者提示 / Runtime 提示 / 工具定义" : "World materials / checkpoint history subset / author instructions / Runtime instructions / tool definitions"}: ${report.worldMaterialsBytes} / ${report.checkpointHistoryBytes} / ${report.authorInstructionBytes} / ${report.runtimeInstructionBytes} / ${report.toolDefinitionBytes}`,
275
+ ]
276
+ : []),
277
+ ...(documents.length > 0
278
+ ? [columns.map(({ label }) => label).join(" / ")]
279
+ : []),
280
+ ...documents.map((document) => `@${document.shortRef}: ${columns.map(({ value }) => value(document) ?? "—").join(" / ")}${document.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
281
+ ...slots.map((slot) => `${zh ? "材料槽" : "Slot"} ${slot.id}: ${slot.bytes} / ${slot.advisoryBytes ?? "—"}${slot.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
250
282
  ].join("\n");
251
283
  }
@@ -1,7 +1,8 @@
1
1
  import { validPromptCompilation, validLogicalMessages, validPromptTools, validPlayFollowup, validProviderKind, validCacheStrategy, validToolStrategy, } from "../prompt/PromptCompilationCodec.js";
2
2
  import { validAuthoringRequests, } from "../authoring/AuthoringRequestSnapshot.js";
3
3
  import { randomUUID } from "node:crypto";
4
- import { link, mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
4
+ import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
5
+ import { linkExclusive } from "../FileNativeExclusiveLink.js";
5
6
  import { dirname, join } from "node:path";
6
7
  import { isDeepStrictEqual } from "node:util";
7
8
  import { fictionGameplay } from "../../protocol/gameplay.js";
@@ -486,7 +487,10 @@ function validOptionalTitle(value) {
486
487
  !/[\r\n]/u.test(value)));
487
488
  }
488
489
  function validRunStatus(value) {
489
- return value === "ready" || value === "running" || value === "interrupted";
490
+ return (value === "ready" ||
491
+ value === "running" ||
492
+ value === "paused" ||
493
+ value === "interrupted");
490
494
  }
491
495
  function validCount(value) {
492
496
  return Number.isSafeInteger(value) && Number(value) >= 0;
@@ -969,13 +973,11 @@ async function publishJson(path, value) {
969
973
  const handle = await open(temporary, "wx", 0o600);
970
974
  try {
971
975
  await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
972
- await handle.sync();
973
976
  }
974
977
  finally {
975
978
  await handle.close();
976
979
  }
977
980
  await rename(temporary, path);
978
- await syncDirectory(dirname(path));
979
981
  }
980
982
  finally {
981
983
  await rm(temporary, { force: true });
@@ -988,14 +990,12 @@ async function publishImmutableSource(path, contents) {
988
990
  const handle = await open(temporary, "wx", 0o600);
989
991
  try {
990
992
  await handle.writeFile(contents, "utf8");
991
- await handle.sync();
992
993
  }
993
994
  finally {
994
995
  await handle.close();
995
996
  }
996
997
  try {
997
- await link(temporary, path);
998
- await syncDirectory(dirname(path));
998
+ await linkExclusive(temporary, path);
999
999
  }
1000
1000
  catch (error) {
1001
1001
  if (!isNodeError(error) || error.code !== "EEXIST")
@@ -1008,15 +1008,6 @@ async function publishImmutableSource(path, contents) {
1008
1008
  await rm(temporary, { force: true });
1009
1009
  }
1010
1010
  }
1011
- async function syncDirectory(path) {
1012
- const handle = await open(path, "r");
1013
- try {
1014
- await handle.sync();
1015
- }
1016
- finally {
1017
- await handle.close();
1018
- }
1019
- }
1020
1011
  function isRecord(value) {
1021
1012
  return typeof value === "object" && value !== null && !Array.isArray(value);
1022
1013
  }
@@ -329,6 +329,7 @@ export class SettingAuthoringTransaction {
329
329
  execute(calls) {
330
330
  const normalized = calls.map(normalizeCall);
331
331
  const results = [];
332
+ const responseBase = this.files();
332
333
  let lastSuccessfulWrite = -1;
333
334
  for (const call of normalized) {
334
335
  const before = this.files();
@@ -344,7 +345,9 @@ export class SettingAuthoringTransaction {
344
345
  if (lastSuccessfulWrite >= 0) {
345
346
  this.#review = this.#inspect();
346
347
  const settled = results[lastSuccessfulWrite];
347
- settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, this.#locale)}`;
348
+ // The stored review stays cumulative for the whole user request; the
349
+ // model only needs what this tool response changed.
350
+ settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, fileDiff(responseBase, this.#snapshot.files), this.#snapshot, this.#locale)}`;
348
351
  }
349
352
  return results;
350
353
  }
@@ -450,9 +453,16 @@ export class SettingAuthoringTransaction {
450
453
  }
451
454
  }
452
455
  }
453
- function renderAutomaticReview(review, locale) {
454
- if (review.status === "usable")
455
- return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changes ${review.diff.length} file(s) directly in the content package. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应直接修改内容包中的 ${review.diff.length} 个文件。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(review.playCoverage, locale)}\n\n${renderWorldMaintenanceReport(review.preview?.compilation.maintenance, locale)}`;
456
+ function renderAutomaticReview(review, responseDiff, snapshot, locale) {
457
+ if (review.status === "usable" && review.preview !== null) {
458
+ const changed = responseDiff.length;
459
+ const cumulative = review.diff.length;
460
+ const changedPaths = new Set(responseDiff.map(({ path }) => path));
461
+ const changedShortRefs = new Set(listQueryableWorldDocuments(snapshot)
462
+ .filter(({ logicalPath }) => changedPaths.has(logicalPath))
463
+ .map(({ shortRef }) => shortRef));
464
+ return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changed ${changed} file(s) in the content package${cumulative === changed ? "" : `; ${cumulative} file(s) changed so far in this user request`}. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应修改内容包中的 ${changed} 个文件${cumulative === changed ? "" : `,本次用户请求累计改动 ${cumulative} 个文件`}。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(buildSettingAuthoringPlayCoverage(snapshot, review.preview, responseDiff), locale)}\n\n${renderWorldMaintenanceReport(review.preview.compilation.maintenance, changedShortRefs, locale)}`;
465
+ }
456
466
  return [
457
467
  localized(locale, "# Current-tree review needs repair", "# 当前树自动检查需要修复"),
458
468
  "",
@@ -561,8 +571,6 @@ function playAccessDetail(access) {
561
571
  return details[access];
562
572
  }
563
573
  function renderPlayCoverage(coverage, locale) {
564
- if (coverage === null)
565
- return localized(locale, "# Play-consumption coverage\n\nCoverage is unavailable until the real Prompt Preview succeeds.", "# 游玩读取覆盖\n\n真实 Prompt Preview 通过后才会提供覆盖报告。");
566
574
  const labels = locale === "zh-CN"
567
575
  ? {
568
576
  full_injected: "全文注入",
@@ -4,7 +4,7 @@ import { InvalidContentTreeError, } from "../content/ContentWorkspace.js";
4
4
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
5
5
  import { equalModelHostBinding } from "../model/ModelHost.js";
6
6
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
7
- import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, } from "../authoring/ModelToolConversation.js";
7
+ import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
8
8
  import { summarizeStoredSettingImprovementSession, } from "./FileNativeSettingImprovementStore.js";
9
9
  import { SettingAuthoringTransaction, settingImprovementRuntimeContract, settingImprovementToolDefinitions, } from "./SettingAuthoringTransaction.js";
10
10
  class RecoveryWriteConflictError extends Error {
@@ -185,33 +185,48 @@ export class SettingImprovementSession {
185
185
  session = await this.#store.read(input.continuation.sessionId);
186
186
  assertPackage(session, input.packageId);
187
187
  }
188
- session = await this.#recoverIfNeeded(session);
189
- if (session.completedRequestIds.includes(requestId))
190
- return { session, run: null };
191
- const running = this.#conversation.running(session.sessionId);
192
- if (running !== null) {
193
- if (running.requestId !== requestId)
194
- throw new Error("Another message in this setting-improvement conversation is still running");
195
- return { session, run: running.promise };
196
- }
197
- const host = await this.#bindExistingModelHost(session.modelBinding);
198
- if (!equalModelHostBinding(session.modelBinding, host.binding()))
199
- throw new Error("No saved model connection matches this setting-improvement conversation");
200
- const { playPreset, bootstrap } = await this.#compileCurrent(input.packageId, session.modelBinding);
201
- session.schemaVersion = 3;
202
- session.requests ??= [];
203
- session.requests.push({
204
- requestId,
205
- modelItemStart: session.modelItems.length,
206
- bootstrap,
207
- playPreset: structuredClone(playPreset),
188
+ return this.#startRequest(session, requestId, async (next) => {
189
+ const { playPreset, bootstrap } = await this.#compileCurrent(input.packageId, next.modelBinding);
190
+ next.schemaVersion = 3;
191
+ next.requests ??= [];
192
+ next.requests.push({
193
+ requestId,
194
+ modelItemStart: next.modelItems.length,
195
+ bootstrap,
196
+ playPreset: structuredClone(playPreset),
197
+ });
198
+ appendAuthoringUserMessage(next, message, requestId);
208
199
  });
209
- appendAuthoringUserMessage(session, message, requestId);
210
- await this.#store.save(session);
211
- return { session, run: this.#startRun(session, host, requestId) };
212
200
  });
213
201
  return prepared.run ?? this.#view(prepared.session);
214
202
  }
203
+ /** Continues a paused conversation with the frozen request it paused in. */
204
+ async resume(input) {
205
+ const requestId = requiredRequestId(input.requestId);
206
+ const prepared = await this.#mutate(async () => {
207
+ const session = await this.#store.read(input.sessionId);
208
+ assertPackage(session, input.packageId);
209
+ return this.#startRequest(session, requestId, (next) => resumePausedAuthoringConversation(next));
210
+ });
211
+ return prepared.run ?? this.#view(prepared.session);
212
+ }
213
+ async #startRequest(stored, requestId, prepare) {
214
+ const session = await this.#recoverIfNeeded(stored);
215
+ if (session.completedRequestIds.includes(requestId))
216
+ return { session, run: null };
217
+ const running = this.#conversation.running(session.sessionId);
218
+ if (running !== null) {
219
+ if (running.requestId !== requestId)
220
+ throw new Error("Another message in this setting-improvement conversation is still running");
221
+ return { session, run: running.promise };
222
+ }
223
+ const host = await this.#bindExistingModelHost(session.modelBinding);
224
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
225
+ throw new Error("No saved model connection matches this setting-improvement conversation");
226
+ await prepare(session);
227
+ await this.#store.save(session);
228
+ return { session, run: this.#startRun(session, host, requestId) };
229
+ }
215
230
  async preview(packageId, sessionId) {
216
231
  const session = sessionId === undefined ? null : await this.#store.read(sessionId);
217
232
  if (session !== null)
@@ -307,7 +322,6 @@ export class SettingImprovementSession {
307
322
  view: (next) => this.#view(next),
308
323
  failure: {
309
324
  emptyResponse: "The model returned an empty tool-free authoring response",
310
- exchangeLimit: "Setting improvement exceeded 64 model exchanges for one user message",
311
325
  cancelled: "The setting-improvement response was cancelled. Complete tool responses already published to the current tree were retained.",
312
326
  interrupted: "The setting-improvement response was interrupted",
313
327
  },