narraeon 0.6.1 → 0.6.3

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.
@@ -68,6 +68,11 @@ const requiredFields = {
68
68
  continuation: "object",
69
69
  },
70
70
  "setting-improvement.cancel": { sessionId: "string" },
71
+ "setting-improvement.resume": {
72
+ packageId: "string",
73
+ sessionId: "string",
74
+ requestId: "string",
75
+ },
71
76
  "play.create": { name: "string" },
72
77
  "play.copy": { presetId: "string" },
73
78
  "play.save": { presetId: "string", name: "string", files: "object" },
@@ -120,6 +125,11 @@ const requiredFields = {
120
125
  continuation: "object",
121
126
  },
122
127
  "world.revision.cancel": { sessionId: "string" },
128
+ "world.revision.resume": {
129
+ worldId: "string",
130
+ sessionId: "string",
131
+ requestId: "string",
132
+ },
123
133
  "world.revision.files.replace": {
124
134
  worldId: "string",
125
135
  epochId: "string",
@@ -422,6 +432,7 @@ const requestTypes = new Set([
422
432
  "setting-improvement.rollback",
423
433
  "setting-improvement.message",
424
434
  "setting-improvement.cancel",
435
+ "setting-improvement.resume",
425
436
  "play.read",
426
437
  "play.create",
427
438
  "play.copy",
@@ -456,6 +467,7 @@ const requestTypes = new Set([
456
467
  "world.revision.session.delete",
457
468
  "world.revision.message",
458
469
  "world.revision.cancel",
470
+ "world.revision.resume",
459
471
  "world.revision.files.replace",
460
472
  "world.revision.rollback",
461
473
  "world.revision.apply",
@@ -383,6 +383,8 @@ export class V1Runtime {
383
383
  return this.#settingImprovements.send(request);
384
384
  case "setting-improvement.cancel":
385
385
  return this.#settingImprovements.cancel(request.sessionId);
386
+ case "setting-improvement.resume":
387
+ return this.#settingImprovements.resume(request);
386
388
  case "play.read":
387
389
  return this.#playPresets.list();
388
390
  case "play.create":
@@ -614,6 +616,8 @@ export class V1Runtime {
614
616
  return this.#worldRevisionCall(() => this.#worldRevisions.send(request));
615
617
  case "world.revision.cancel":
616
618
  return this.#worldRevisionCall(() => this.#worldRevisions.cancel(request.sessionId));
619
+ case "world.revision.resume":
620
+ return this.#worldRevisionCall(() => this.#worldRevisions.resume(request));
617
621
  case "world.revision.files.replace":
618
622
  return this.#worldRevisionCall(() => this.#worldRevisions.replaceFiles(request));
619
623
  case "world.revision.rollback":
@@ -119,8 +119,15 @@ export class ModelToolConversation {
119
119
  await input.settleToolResponse(session, session.modelItems.length - 1, toolCalls);
120
120
  input.changed?.(true);
121
121
  }
122
- throw new Error(input.failure?.exchangeLimit ??
123
- `Authoring exceeded ${maximumExchangesPerMessage} model exchanges for one user message`);
122
+ // The exchange budget only bounds a runaway loop. Every tool response is
123
+ // already settled, so the conversation pauses and can resume unchanged.
124
+ session.runStatus = "paused";
125
+ session.activeRequestId = null;
126
+ session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
127
+ session.lastFailure = null;
128
+ session.updatedAt = Date.now();
129
+ await input.save(session);
130
+ return input.view(session);
124
131
  }
125
132
  catch (error) {
126
133
  session.runStatus = "interrupted";
@@ -154,6 +161,22 @@ export function appendAuthoringUserMessage(session, message, requestId) {
154
161
  session.lastFailure = null;
155
162
  session.updatedAt = now;
156
163
  }
164
+ /**
165
+ * Starts another exchange budget for a paused conversation without appending a
166
+ * user message; the next request replays the settled transcript as-is. The run
167
+ * still belongs to the paused user request, so that request stays active; the
168
+ * caller's resume request ID only deduplicates the resume itself.
169
+ */
170
+ export function resumePausedAuthoringConversation(session) {
171
+ if (session.runStatus !== "paused")
172
+ throw new Error("Only a paused authoring conversation can be continued");
173
+ if (session.modelItems.at(-1)?.kind !== "tool")
174
+ throw new Error("The paused authoring conversation does not end with settled tool results");
175
+ session.runStatus = "running";
176
+ session.activeRequestId = session.requests?.at(-1)?.requestId ?? null;
177
+ session.lastFailure = null;
178
+ session.updatedAt = Date.now();
179
+ }
157
180
  export function requiredAuthoringMessage(message) {
158
181
  if (message.trim().length === 0)
159
182
  throw new Error("An authoring message cannot be empty");
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
3
3
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
4
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
5
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
6
+ import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withCurrentPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
7
  import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -457,8 +457,8 @@ export class FileNativePlayPresetStore {
457
457
  .map((path) => ({ path, contents: binding.files[path] }));
458
458
  }
459
459
  async importPortable(input) {
460
- // A released preset gains the tabletop builtins at import, as at upgrade.
461
- const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
460
+ // A released preset gains the current builtins at import, as at upgrade.
461
+ const files = withCurrentPromptBuiltins(toFileMap(input.files, this.#limits));
462
462
  const parsed = parsePlayPresetFiles(files, this.#limits);
463
463
  if (parsed.kind === "invalid")
464
464
  throw parsed.error;
@@ -568,8 +568,8 @@ export class FileNativePlayPresetStore {
568
568
  return true;
569
569
  }
570
570
  /**
571
- * One-time upgrade for ordered presets saved before the tabletop builtins
572
- * existed. An affected revision is replaced by a new revision that inserts
571
+ * One-time upgrade for ordered presets saved before the tabletop or
572
+ * continuation builtins existed. An affected revision is replaced by a new revision that inserts
573
573
  * them; the old revision stays for frozen requests. A pending draft gets the
574
574
  * same insertion, because a draft that kept the old list would fail the
575
575
  * builtin-retention rule on save. The library is one atomically written file,
@@ -580,7 +580,7 @@ export class FileNativePlayPresetStore {
580
580
  for (const stored of document.presets) {
581
581
  const current = stored.revisions[stored.currentRevision];
582
582
  if (current !== undefined) {
583
- const migrated = withTabletopPromptBuiltins(current);
583
+ const migrated = withCurrentPromptBuiltins(current);
584
584
  if (migrated !== current) {
585
585
  const revision = revisionForFiles(migrated, this.#limits);
586
586
  stored.revisions[revision] = cloneFiles(migrated);
@@ -592,7 +592,7 @@ export class FileNativePlayPresetStore {
592
592
  ? undefined
593
593
  : stored.revisions[stored.draftRevision];
594
594
  if (draft !== undefined) {
595
- const migrated = withTabletopPromptBuiltins(draft);
595
+ const migrated = withCurrentPromptBuiltins(draft);
596
596
  if (migrated !== draft) {
597
597
  const revision = revisionForFiles(migrated, this.#limits);
598
598
  stored.revisions[revision] = cloneFiles(migrated);
@@ -118,6 +118,81 @@ export function withTabletopPromptBuiltins(files) {
118
118
  }
119
119
  return changed ? { ...files, "preset.yaml": document.toString() } : files;
120
120
  }
121
+ const alternativeGroups = () => [
122
+ {
123
+ anchor: "play.style",
124
+ before: [
125
+ "play.adjudication",
126
+ "play.state",
127
+ "play.narrative",
128
+ "play.continue-stage",
129
+ "play.tabletop",
130
+ ],
131
+ entries: personPromptEntries(),
132
+ },
133
+ {
134
+ anchor: "play.narrative",
135
+ before: ["play.tabletop"],
136
+ entries: continuationPromptEntries(),
137
+ },
138
+ ];
139
+ /**
140
+ * Insert the narrative-person and continuation builtins that an ordered preset
141
+ * does not list yet. Same identity contract as the tabletop insertion: files
142
+ * that are not ordered v2 or do not parse come back unchanged, and the same
143
+ * object is returned when nothing needs inserting.
144
+ */
145
+ export function withAlternativePromptBuiltins(files) {
146
+ const source = files["preset.yaml"];
147
+ if (source === undefined)
148
+ return files;
149
+ const document = parseDocument(source, { uniqueKeys: true });
150
+ if (document.errors.length > 0 ||
151
+ document.warnings.length > 0 ||
152
+ !isMap(document.contents) ||
153
+ document.get("format") !== "narraeon.play-preset/v2")
154
+ return files;
155
+ const playPrompts = document.get("playPrompts", true);
156
+ if (!isSeq(playPrompts))
157
+ return files;
158
+ let changed = false;
159
+ for (const group of alternativeGroups()) {
160
+ if (group.entries.some((entry) => hasEntry(playPrompts, entry.id)))
161
+ continue;
162
+ const anchor = playPrompts.items.findIndex((item) => entryField(item, "id") === group.anchor);
163
+ const fallback = playPrompts.items.findIndex((item) => {
164
+ const id = entryField(item, "id");
165
+ return ((typeof id === "string" && group.before.includes(id)) ||
166
+ entryField(item, "kind") === "world");
167
+ });
168
+ const at = anchor >= 0
169
+ ? anchor + 1
170
+ : fallback >= 0
171
+ ? fallback
172
+ : playPrompts.items.length;
173
+ playPrompts.items.splice(at, 0, ...group.entries.map((entry) => document.createNode(entry)));
174
+ changed = true;
175
+ }
176
+ return changed ? { ...files, "preset.yaml": document.toString() } : files;
177
+ }
178
+ /** Every builtin insertion a stored or imported ordered preset must receive. */
179
+ export function withCurrentPromptBuiltins(files) {
180
+ return withAlternativePromptBuiltins(withTabletopPromptBuiltins(files));
181
+ }
182
+ function alternativeEntries(prefix, ids, enabledId) {
183
+ return ids.map((id) => ({
184
+ id: `${prefix}${id}`,
185
+ kind: "builtin",
186
+ builtin: `${prefix}${id}`,
187
+ enabled: id === enabledId,
188
+ }));
189
+ }
190
+ function personPromptEntries() {
191
+ return alternativeEntries("play.person-", ["second", "first", "third"], "second");
192
+ }
193
+ function continuationPromptEntries() {
194
+ return alternativeEntries("play.continue-", ["stage", "advance"], "stage");
195
+ }
121
196
  function hasEntry(sequence, id) {
122
197
  return sequence.items.some((item) => entryField(item, "id") === id);
123
198
  }
@@ -239,9 +314,12 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
239
314
  if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
240
315
  path.endsWith(".md"))
241
316
  add(path, false);
242
- // The tabletop builtin joins converted presets just before the world placeholder.
317
+ // Converted presets gain the person and continuation builtins and then the
318
+ // tabletop builtin just before the world placeholder. The legacy style and
319
+ // narrative copies keep their own wording as user text; the current builtins
320
+ // arrive beside them.
243
321
  const worldIndex = entries.findIndex((entry) => entry.kind === "world");
244
- entries.splice(worldIndex, 0, {
322
+ entries.splice(worldIndex, 0, ...personPromptEntries(), ...continuationPromptEntries(), {
245
323
  id: "play.tabletop",
246
324
  kind: "builtin",
247
325
  builtin: "play.tabletop",
@@ -66,6 +66,15 @@ export class FileNativePlayDocuments {
66
66
  // Creation capabilities come from this prefix, not past directory slots.
67
67
  this.#declaredDirectories = declaredStateDirectories(bootstrap);
68
68
  }
69
+ /**
70
+ * Re-grant one exact read the model already completed in a retained
71
+ * transcript. The scope is resolved against the current snapshot again, so a
72
+ * document or node that no longer exists grants nothing. Returns whether the
73
+ * scope resolved.
74
+ */
75
+ replayRead(shortRef, locator) {
76
+ return authorizeResolvedRead(this.#reads, this.#candidate.snapshot, shortRef, locator);
77
+ }
69
78
  execute(call, history, options = {}) {
70
79
  let historyQuery;
71
80
  const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
@@ -156,26 +165,31 @@ function bootstrapAuthorizations(bootstrap, snapshot) {
156
165
  for (const { status, complete, readAuthorization } of bootstrap.coverage) {
157
166
  if (status !== "resolved" || !complete || readAuthorization === undefined)
158
167
  continue;
159
- if (readAuthorization.locator === null) {
160
- const resolved = snapshot.query({
161
- kind: "read_document",
162
- document: { shortRef: readAuthorization.shortRef },
163
- });
164
- if (resolved.kind === "read_document" && resolved.ok)
165
- authorizeRead(result, snapshot.id, resolved.document.shortRef, null);
166
- }
167
- else {
168
- const selected = snapshot.query({
169
- kind: "select_node",
170
- document: { shortRef: readAuthorization.shortRef },
171
- locator: readAuthorization.locator,
172
- });
173
- if (selected.kind === "select_node" && selected.ok)
174
- authorizeRead(result, snapshot.id, selected.document.shortRef, selected.node.locator);
175
- }
168
+ authorizeResolvedRead(result, snapshot, readAuthorization.shortRef, readAuthorization.locator);
176
169
  }
177
170
  return result;
178
171
  }
172
+ function authorizeResolvedRead(authorizations, snapshot, shortRef, locator) {
173
+ if (locator === null) {
174
+ const resolved = snapshot.query({
175
+ kind: "read_document",
176
+ document: { shortRef },
177
+ });
178
+ if (resolved.kind !== "read_document" || !resolved.ok)
179
+ return false;
180
+ authorizeRead(authorizations, snapshot.id, resolved.document.shortRef, null);
181
+ return true;
182
+ }
183
+ const selected = snapshot.query({
184
+ kind: "select_node",
185
+ document: { shortRef },
186
+ locator,
187
+ });
188
+ if (selected.kind !== "select_node" || !selected.ok)
189
+ return false;
190
+ authorizeRead(authorizations, snapshot.id, selected.document.shortRef, selected.node.locator);
191
+ return true;
192
+ }
179
193
  function declaredStateDirectories(bootstrap) {
180
194
  // Catalog coverage is the durable compiler projection shared by current
181
195
  // state_list contexts and legacy context_list contexts.
@@ -131,16 +131,64 @@ function assembleRetainedTrace(source, endpoint, selection) {
131
131
  export function restorePlayDocuments(files, context, events) {
132
132
  const documents = new FileNativePlayDocuments(files);
133
133
  try {
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.
134
+ // Read authorizations are memory-only (ADR-0044). A restored prefix starts
135
+ // from its bootstrap, then replays the exact reads the retained transcript
136
+ // still shows the model, so it is not asked to read again what it has
137
+ // already seen. Stale write authority is caught by the commit's hash.
136
138
  const runs = playPromptRunsThroughEvents(context, events);
137
139
  documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
140
+ for (const read of completedReadsThroughEvents(context, events))
141
+ documents.replayRead(read.shortRef, read.locator);
138
142
  }
139
143
  catch (error) {
140
144
  throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
141
145
  }
142
146
  return documents;
143
147
  }
148
+ /**
149
+ * The exact document scopes successfully read by context_read within the
150
+ * selected event prefix, in event order. Tool results are keyed by exchange and
151
+ * call ID, and the exchange is the one of the nearest preceding assistant event.
152
+ */
153
+ function completedReadsThroughEvents(context, events) {
154
+ const completed = new Map(context.completedTools.map((tool) => [tool.key, tool.result]));
155
+ const reads = [];
156
+ let exchange = 0;
157
+ for (const event of events) {
158
+ if (event.kind === "assistant") {
159
+ exchange = event.exchange;
160
+ continue;
161
+ }
162
+ if (event.kind !== "tool_result" || !event.ok)
163
+ continue;
164
+ const result = completed.get(`${exchange}:${event.callId}`);
165
+ const authorization = result?.readAuthorization;
166
+ if (!result?.ok || !isReplayableReadAuthorization(authorization))
167
+ continue;
168
+ reads.push({
169
+ shortRef: authorization.shortRef,
170
+ locator: structuredClone(authorization.locator),
171
+ });
172
+ }
173
+ return reads;
174
+ }
175
+ function isReplayableReadAuthorization(value) {
176
+ if (typeof value !== "object" || value === null)
177
+ return false;
178
+ const { shortRef, locator } = value;
179
+ if (typeof shortRef !== "string")
180
+ return false;
181
+ if (locator === null)
182
+ return true;
183
+ if (typeof locator !== "object" || locator === null)
184
+ return false;
185
+ const { yaml, markdown } = locator;
186
+ if (Array.isArray(yaml))
187
+ return yaml.every((segment) => typeof segment === "string" ||
188
+ (Number.isSafeInteger(segment) && Number(segment) >= 0));
189
+ return (Array.isArray(markdown) &&
190
+ markdown.every((segment) => typeof segment === "string"));
191
+ }
144
192
  export function independentContextCopy(source) {
145
193
  const context = structuredClone(source);
146
194
  context.continuityContextId ??= source.chainId;
@@ -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
  }
@@ -487,7 +487,10 @@ function validOptionalTitle(value) {
487
487
  !/[\r\n]/u.test(value)));
488
488
  }
489
489
  function validRunStatus(value) {
490
- return value === "ready" || value === "running" || value === "interrupted";
490
+ return (value === "ready" ||
491
+ value === "running" ||
492
+ value === "paused" ||
493
+ value === "interrupted");
491
494
  }
492
495
  function validCount(value) {
493
496
  return Number.isSafeInteger(value) && Number(value) >= 0;
@@ -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
  },
@@ -246,7 +246,7 @@ function validateSession(value) {
246
246
  /[\r\n\0]/u.test(value.worldTitle) ||
247
247
  typeof value.epochId !== "string" ||
248
248
  (value.locale !== "en" && value.locale !== "zh-CN") ||
249
- !["ready", "running", "interrupted"].includes(String(value.runStatus)) ||
249
+ !["ready", "running", "paused", "interrupted"].includes(String(value.runStatus)) ||
250
250
  !finite(value.createdAt) ||
251
251
  !finite(value.updatedAt) ||
252
252
  value.updatedAt < value.createdAt ||