dsh-openwrite 0.2.27 → 0.2.33

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 (130) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -6
  3. package/THIRD_PARTY_NOTICES.md +4 -3
  4. package/companions.mjs +508 -0
  5. package/cordis.patch.yml +0 -2
  6. package/docs/CLOSED_ISSUE_FOLLOWUPS.md +5 -3
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +60 -0
  9. package/docs/COST_MEASUREMENT.md +2 -2
  10. package/docs/DEVELOPMENT.md +6 -11
  11. package/docs/DSH_STORE.md +14 -2
  12. package/docs/EDITOR_SAFETY_REPAIRS.md +17 -0
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +97 -0
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUES_98_100.md +2 -0
  18. package/docs/ISSUE_123_FOLLOWUP.md +55 -0
  19. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  20. package/docs/LEARNING.md +1 -1
  21. package/docs/MOBILE_READINESS.md +62 -0
  22. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  23. package/docs/RELEASE_ACCEPTANCE.md +90 -3
  24. package/docs/REVIEW_ROUND_22.md +67 -0
  25. package/docs/REVIEW_ROUND_23.md +48 -0
  26. package/docs/REVIEW_ROUND_25.md +51 -0
  27. package/docs/SELECTION_UPGRADE_REPAIRS.md +16 -0
  28. package/docs/TOOLS.md +933 -0
  29. package/docs/WORKFLOWS.md +5 -5
  30. package/package.json +18 -21
  31. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  32. package/packages/openwrite-bridge/lib/client.js +90 -9
  33. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  34. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  35. package/packages/openwrite-bridge/lib/dog-delivery.js +10 -4
  36. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  37. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  38. package/packages/openwrite-bridge/lib/domain.js +250 -29
  39. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  40. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  41. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  42. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  43. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  44. package/packages/openwrite-bridge/lib/index.js +150 -6
  45. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  46. package/packages/openwrite-bridge/lib/managed-runtime.js +179 -35
  47. package/packages/openwrite-bridge/lib/tool-policy.js +39 -12
  48. package/packages/openwrite-bridge/lib/tools.js +1238 -1219
  49. package/packages/openwrite-bridge/package.json +7 -5
  50. package/packages/studio-panel/lib/client.js +116667 -114773
  51. package/packages/studio-panel/lib/client.js.map +1 -1
  52. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  53. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  54. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  55. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  57. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  58. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  59. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +8 -2
  60. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  61. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  62. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  63. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  64. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  65. package/packages/studio-panel/lib/types/client/manuscript-annotations.d.ts +2 -1
  66. package/packages/studio-panel/lib/types/client/manuscript-overlay.d.ts +2 -1
  67. package/packages/studio-panel/lib/types/client/manuscript-selection.d.ts +18 -5
  68. package/packages/studio-panel/lib/types/client/selection-coordinates.d.ts +15 -0
  69. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  70. package/packages/studio-panel/package.json +5 -3
  71. package/plugin.mjs +159 -20
  72. package/preset-definition.mjs +144 -4
  73. package/presets/openwrite/agent.cordis.yml +7 -9
  74. package/presets/openwrite/ops-integration.mjs +28 -0
  75. package/presets/openwrite/preset.yml +2 -1
  76. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  77. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  78. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  79. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  80. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  81. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  82. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  83. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  84. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  85. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  86. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  87. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  88. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  89. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  90. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  91. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  92. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  93. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  94. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  95. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  96. package/release/{openwrite-5.8.16-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  97. package/release/runtime-manifest.json +4 -12
  98. package/release/source.json +21 -21
  99. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  100. package/scripts/maintenance.mjs +67 -7
  101. package/scripts/plugin-doctor.mjs +179 -0
  102. package/scripts/prepare.mjs +11 -2
  103. package/scripts/safe-upgrade.mjs +141 -13
  104. package/dog-tools.mjs +0 -11
  105. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  106. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  107. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  108. package/scripts/dog/delivery-stage.js +0 -32
  109. package/scripts/dog/import-chapter.js +0 -20
  110. package/scripts/dog/import-record.js +0 -35
  111. package/scripts/dog/review-dimension.js +0 -41
  112. package/scripts/dog/review-record.js +0 -42
  113. package/vendor/dsh-dog/LICENSE +0 -29
  114. package/vendor/dsh-dog/README.md +0 -114
  115. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  116. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  117. package/vendor/dsh-dog/lib/client.js +0 -2960
  118. package/vendor/dsh-dog/lib/client.js.map +0 -1
  119. package/vendor/dsh-dog/lib/core-BAlgZpLq.js +0 -2074
  120. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  121. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  122. package/vendor/dsh-dog/lib/core.js +0 -2
  123. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  124. package/vendor/dsh-dog/lib/index.js +0 -1279
  125. package/vendor/dsh-dog/package.json +0 -110
  126. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  127. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  128. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  129. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  130. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -7,7 +7,7 @@
7
7
  import { createHash } from 'node:crypto';
8
8
  import { realpathSync } from 'node:fs';
9
9
  import { mkdir, writeFile } from 'node:fs/promises';
10
- import { isAbsolute, join } from 'node:path';
10
+ import { isAbsolute, join, normalize } from 'node:path';
11
11
  import { ToolArgsError, parameterSchemaSpecToJsonSchema, validateJsonSchemaValue, valueSchemaSpecToJsonSchema, } from '@deepseek-ai/dsh-tools';
12
12
  import { StudioError } from './client.js';
13
13
  import { materializeChapterDelivery } from './dog-delivery.js';
@@ -48,14 +48,35 @@ function parseJsonArgument(name, value) {
48
48
  throw new Error(`parameter "${name}" expects a JSON value; received a string that is not valid JSON (${detail})`);
49
49
  }
50
50
  }
51
+ /**
52
+ * The asset id shape the server accepts. The same text is quoted in the
53
+ * parameter description the model reads and in the error it gets when the id
54
+ * is missing or malformed, so a rejected call can be corrected in one retry
55
+ * instead of guessing the format again.
56
+ */
57
+ const ASSET_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
58
+ const ASSET_ID_SHAPE = '[A-Za-z0-9][A-Za-z0-9_.-]{0,79}';
59
+ /** A missing asset id must say what a valid one looks like. */
60
+ function assetIdRequired(action) {
61
+ return `id is required for novel_assets(action="${action}"); pass an id matching ${ASSET_ID_SHAPE}`;
62
+ }
63
+ function assertAssetId(id) {
64
+ if (!ASSET_ID_PATTERN.test(id)) {
65
+ throw new Error(`invalid asset id "${id}"; it must match ${ASSET_ID_SHAPE}`);
66
+ }
67
+ }
68
+ /** JSON-typed parameters that also legitimately accept a raw string. */
69
+ const RAW_STRING_JSON_ARGUMENTS = new Set(['novel_source_action.focus']);
51
70
  /**
52
71
  * Normalize arguments for parameters declared `type: 'json'` (and
53
72
  * `type: 'array'` with json items). DSH keeps `json` an annotation-only
54
73
  * schema, so the value reaches the plugin in whatever shape the model
55
74
  * emitted; models that serialize the object/array as a JSON *string* would
56
75
  * otherwise fail the plugin-side object/array checks (GitHub issue #75).
76
+ * Parameters in {@link RAW_STRING_JSON_ARGUMENTS} additionally accept their
77
+ * documented plain-string form.
57
78
  */
58
- function normalizeJsonArguments(parameters, args) {
79
+ function normalizeJsonArguments(toolName, parameters, args) {
59
80
  let normalized;
60
81
  for (const [name, spec] of Object.entries(parameters)) {
61
82
  const value = args[name];
@@ -63,8 +84,20 @@ function normalizeJsonArguments(parameters, args) {
63
84
  continue;
64
85
  const type = spec.type;
65
86
  if (type === 'json' && typeof value === 'string') {
87
+ let parsed;
88
+ try {
89
+ parsed = parseJsonArgument(name, value);
90
+ }
91
+ catch (error) {
92
+ // Some parameters describe a plain string for one action and a JSON
93
+ // value for another (novel_source_action.focus). For those, a raw
94
+ // string is a legitimate value, not malformed JSON.
95
+ if (!RAW_STRING_JSON_ARGUMENTS.has(`${toolName}.${name}`))
96
+ throw error;
97
+ parsed = value;
98
+ }
66
99
  normalized ??= { ...args };
67
- normalized[name] = parseJsonArgument(name, value);
100
+ normalized[name] = parsed;
68
101
  }
69
102
  else if (type === 'array') {
70
103
  const items = spec.items;
@@ -103,9 +136,14 @@ function defineTool(options) {
103
136
  const userIsConcurrencySafe = options.isConcurrencySafe;
104
137
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0))
105
138
  throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`);
106
- const parameters = parameterSchemaSpecToJsonSchema(options.parameters);
139
+ const compiled = parameterSchemaSpecToJsonSchema(options.parameters);
140
+ // A tool that silently accepts an undeclared argument turns a model typo into
141
+ // a no-op write: the argument is dropped and the call still reports success.
142
+ // The host's schema validator enforces this keyword, so an unknown argument
143
+ // is rejected before the tool body runs.
144
+ const parameters = { ...compiled, additionalProperties: false };
107
145
  const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema);
108
- const normalize = (args) => normalizeJsonArguments(options.parameters, objectValue(args));
146
+ const normalize = (args) => normalizeJsonArguments(options.name, options.parameters, objectValue(args));
109
147
  const validate = (args) => validateJsonSchemaValue(parameters, args, '');
110
148
  const tool = {
111
149
  name: options.name,
@@ -132,7 +170,8 @@ function defineTool(options) {
132
170
  throw error;
133
171
  error.message = JSON.stringify({ code: error.code, message: error.message,
134
172
  status: error.status, details: error.details, request_id: error.requestId,
135
- recovery: error.code === 'NETWORK_ERROR' ? 'List recent tasks and chapter runs before resubmitting; the server may have accepted the request.' : null,
173
+ // A timeout is as ambiguous as a transport failure about whether the server applied the write.
174
+ recovery: error.code === 'NETWORK_ERROR' || error.code === 'REQUEST_TIMEOUT' ? 'List recent tasks and chapter runs before resubmitting; the server may have accepted the request.' : null,
136
175
  });
137
176
  throw error;
138
177
  }
@@ -175,6 +214,36 @@ function resolveReviewPath(args) {
175
214
  throw new Error(`chapter_id must match ch_<digits>, got "${chapterId}"`);
176
215
  return `data/manuscript/${chapterId}.md`;
177
216
  }
217
+ /** Unicode code-point offset of a JavaScript UTF-16 index (Studio counts code points). */
218
+ function codePointOffset(text, index) {
219
+ return Array.from(text.slice(0, index)).length;
220
+ }
221
+ /**
222
+ * Derive exact annotation offsets from a quote when the caller supplied none.
223
+ * The server matches `content[start:end]` literally, so a quote that is absent
224
+ * or ambiguous is reported before any request is sent.
225
+ */
226
+ async function resolveAnnotationHints(client, chapterId, quote, signal) {
227
+ const brief = objectValue(await client.getJson(`/api/chapters/${encodeURIComponent(chapterId)}/work-brief`, {}, signal));
228
+ const path = objectValue(brief['manuscript'])['path'];
229
+ if (typeof path !== 'string' || path === '') {
230
+ throw new Error('cannot locate the chapter document to derive annotation offsets; pass start_hint and end_hint explicitly');
231
+ }
232
+ const document = objectValue(await client.getJson('/api/document', { path }, signal));
233
+ const content = document['content'];
234
+ if (typeof content !== 'string') {
235
+ throw new Error('cannot read the chapter document to derive annotation offsets; pass start_hint and end_hint explicitly');
236
+ }
237
+ const first = content.indexOf(quote);
238
+ if (first < 0) {
239
+ throw new Error('quote was not found in the chapter; pass the exact selected text or explicit start_hint/end_hint');
240
+ }
241
+ if (content.indexOf(quote, first + 1) >= 0) {
242
+ throw new Error('quote occurs more than once in the chapter; pass start_hint and end_hint explicitly');
243
+ }
244
+ const start = codePointOffset(content, first);
245
+ return { start, end: start + Array.from(quote).length };
246
+ }
178
247
  function objectValue(value) {
179
248
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
180
249
  }
@@ -235,6 +304,12 @@ function exportFilename(filename, fallback) {
235
304
  if (name.trim() === '' || name === '.' || name === '..' || /[\\/\u0000-\u001f\u007f]/.test(name) || /^[A-Za-z]:/.test(name)) {
236
305
  throw new StudioError('Studio returned an unsafe export filename', 502, 'INVALID_EXPORT_FILENAME', { reason: 'not_basename' });
237
306
  }
307
+ // Beyond the traversal checks: a name longer than the filesystem limit would
308
+ // otherwise escape as a bare ENAMETOOLONG whose message embeds the full local
309
+ // path. 255 is the common ceiling; stay well under it for extension suffixes.
310
+ if (Buffer.byteLength(name, 'utf8') > 200) {
311
+ throw new StudioError('Studio returned an export filename that is too long', 502, 'INVALID_EXPORT_FILENAME', { reason: 'too_long' });
312
+ }
238
313
  return name;
239
314
  }
240
315
  /** Register every `novel_*` tool into the host tools registry. */
@@ -244,59 +319,71 @@ export function registerNovelTools(ctx, clientFactory, options) {
244
319
  // a missing/invalid Workspace context fails closed before any HTTP call.
245
320
  const clientFor = (exec) => clientFactory(exec);
246
321
  // ── reads ──────────────────────────────────────────────────────────────
322
+ // ── embedding configuration ───────────────────────────────────────────────────
247
323
  ctx.tools.register(defineTool({
248
324
  name: 'novel_embedding_profiles',
249
- description: 'List independent Embedding profiles and the active selection. Embedding is configured separately from Chat profiles.',
250
- parameters: {},
251
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
252
- timeoutMs,
253
- isConcurrencySafe: () => true,
254
- async execute(_args, exec) {
255
- return await clientFor(exec).getJson('/api/model/embedding', {}, exec.signal);
256
- },
257
- }));
258
- ctx.tools.register(defineTool({
259
- name: 'novel_embedding_profile_save',
260
- description: 'Create or update an independent Embedding profile; it is never attached to a Chat profile.',
325
+ description: 'List, save, select, delete and test embedding profiles. Selection chooses the profile the ' +
326
+ 'server uses for semantic search and recall.',
261
327
  parameters: {
262
- profile: { type: 'json', required: true, description: 'Embedding profile: id, label, provider, model, base_url, dimension, max_tokens.' },
328
+ action: { type: 'string', required: true, enum: ['list', 'save', 'select', 'delete', 'test'], description: 'Embedding configuration operation.' },
329
+ profile: { type: 'json', description: 'Embedding profile: id, label, provider, model, base_url, dimension, max_tokens.' },
263
330
  api_key: { type: 'string', description: 'Embedding API key.' },
264
331
  remember_api_key: { type: 'boolean', description: 'Persist the key (default true).' },
332
+ profile_id: { type: 'string' },
333
+ id: { type: 'string', description: 'Stored profile id to probe.' },
334
+ candidate: { type: 'json', description: 'Embedding candidate settings object (merged into the request).' },
265
335
  },
266
336
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
267
337
  timeoutMs,
338
+ isConcurrencySafe: args => ['list', 'test'].includes(args.action),
268
339
  async execute(args, exec) {
269
- if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))
270
- throw new Error('profile must be a JSON object');
271
- const body = { ...args.profile };
272
- for (const key of ['api_key', 'remember_api_key'])
273
- if (args[key] !== undefined)
274
- body[key] = args[key];
275
- return await clientFor(exec).postJson('/api/model/embedding', body, exec.signal);
276
- },
277
- }));
278
- ctx.tools.register(defineTool({
279
- name: 'novel_embedding_profile_select',
280
- description: 'Select the active independent Embedding profile used by search.',
281
- parameters: { profile_id: { type: 'string', required: true } },
282
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
283
- timeoutMs,
284
- async execute(args, exec) {
285
- if (!args.profile_id.trim())
286
- throw new Error('profile_id must be non-empty');
287
- return await clientFor(exec).postJson('/api/model/embedding/select', { profile_id: args.profile_id }, exec.signal);
288
- },
289
- }));
290
- ctx.tools.register(defineTool({
291
- name: 'novel_embedding_profile_delete',
292
- description: 'Delete an independent Embedding profile; the active profile automatically moves to another one.',
293
- parameters: { profile_id: { type: 'string', required: true } },
294
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
295
- timeoutMs,
296
- async execute(args, exec) {
297
- if (!args.profile_id.trim())
298
- throw new Error('profile_id must be non-empty');
299
- return await clientFor(exec).postJson('/api/model/embedding/delete', { profile_id: args.profile_id }, exec.signal);
340
+ switch (args.action) {
341
+ case 'list': {
342
+ return await clientFor(exec).getJson('/api/model/embedding', {}, exec.signal);
343
+ }
344
+ case 'save': {
345
+ if (args.profile === undefined)
346
+ throw new Error('profile is required for novel_embedding_profiles(action="save")');
347
+ if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))
348
+ throw new Error('profile must be a JSON object');
349
+ const body = { ...args.profile };
350
+ for (const key of ['api_key', 'remember_api_key'])
351
+ if (args[key] !== undefined)
352
+ body[key] = args[key];
353
+ return await clientFor(exec).postJson('/api/model/embedding', body, exec.signal);
354
+ }
355
+ case 'select': {
356
+ if (args.profile_id === undefined)
357
+ throw new Error('profile_id is required for novel_embedding_profiles(action="select")');
358
+ if (!args.profile_id.trim())
359
+ throw new Error('profile_id must be non-empty');
360
+ return await clientFor(exec).postJson('/api/model/embedding/select', { profile_id: args.profile_id }, exec.signal);
361
+ }
362
+ case 'delete': {
363
+ if (args.profile_id === undefined)
364
+ throw new Error('profile_id is required for novel_embedding_profiles(action="delete")');
365
+ if (!args.profile_id.trim())
366
+ throw new Error('profile_id must be non-empty');
367
+ return await clientFor(exec).postJson('/api/model/embedding/delete', { profile_id: args.profile_id }, exec.signal);
368
+ }
369
+ case 'test': {
370
+ const client = clientFor(exec);
371
+ const body = {};
372
+ if (args.candidate !== undefined) {
373
+ if (typeof args.candidate !== 'object' || args.candidate === null || Array.isArray(args.candidate)) {
374
+ throw new Error('candidate must be a JSON object');
375
+ }
376
+ Object.assign(body, args.candidate);
377
+ }
378
+ for (const key of ['id', 'api_key']) {
379
+ const value = args[key];
380
+ if (value !== undefined)
381
+ body[key] = value;
382
+ }
383
+ return await client.postJson('/api/model/embedding/test', body, exec.signal);
384
+ }
385
+ default: throw new Error(`novel_embedding_profiles: unknown action ${String(args.action)}`);
386
+ }
300
387
  },
301
388
  }));
302
389
  ctx.tools.register(defineTool({
@@ -350,7 +437,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
350
437
  }));
351
438
  ctx.tools.register(defineTool({
352
439
  name: 'novel_context_preview',
353
- description: 'Preview the context packet OpenWrite would assemble for writing a chapter (relevant outline, assets, foreshadowing, and prior-chapter memory). Set include_agent_payload to inspect canonical writer inputs and document inclusion without calling a model.',
440
+ description: 'Preview the context packet OpenWrite would assemble for writing a chapter (relevant outline, assets, foreshadowing, and prior-chapter memory). The chapter must already exist in the outline; an unknown chapter id is rejected instead of returning an empty generic context. Set include_agent_payload to inspect canonical writer inputs and document inclusion without calling a model.',
354
441
  parameters: {
355
442
  chapter: { type: 'string', description: 'Chapter id (e.g. "ch_0007"); omit or pass "next" for the next chapter to write.' },
356
443
  include_agent_payload: { type: 'boolean', description: 'Include the full writer payload, rendered initial messages and document inventory. Later segment/retry messages depend on the write request.' },
@@ -381,15 +468,50 @@ export function registerNovelTools(ctx, clientFactory, options) {
381
468
  return await client.getJson('/api/outline', params, exec.signal);
382
469
  },
383
470
  }));
471
+ // ── reading order ─────────────────────────────────────────────────────────────
384
472
  ctx.tools.register(defineTool({
385
473
  name: 'novel_reading_order',
386
- description: 'Read the canonical manuscript order with stable document/occurrence ids, volume membership, previous/next links, revision, and explicit duplicate or missing-file issues.',
387
- parameters: {},
474
+ description: 'Read the reading order of the current project and move one document to another volume or ' +
475
+ 'position. The move is optimistic and requires the current revision.',
476
+ parameters: {
477
+ action: { type: 'string', required: true, enum: ['get', 'move'], description: 'Reading-order operation.' },
478
+ document_id: { type: 'string', description: 'Stable document_id or unambiguous occurrence_id.' },
479
+ target_volume_id: { type: 'string', description: 'Target volume_id from novel_reading_order.' },
480
+ target_index: { type: 'integer', description: 'Zero-based position inside the target volume.' },
481
+ expected_revision: { type: 'string', description: 'Fresh reading-order revision used for CAS.' },
482
+ },
388
483
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
389
484
  timeoutMs,
390
- isConcurrencySafe: () => true,
391
- async execute(_args, exec) {
392
- return await clientFor(exec).getJson('/api/reading-order', {}, exec.signal);
485
+ isConcurrencySafe: args => ['get'].includes(args.action),
486
+ async execute(args, exec) {
487
+ switch (args.action) {
488
+ case 'get': {
489
+ return await clientFor(exec).getJson('/api/reading-order', {}, exec.signal);
490
+ }
491
+ case 'move': {
492
+ if (args.document_id === undefined)
493
+ throw new Error('document_id is required for novel_reading_order(action="move")');
494
+ if (args.target_volume_id === undefined)
495
+ throw new Error('target_volume_id is required for novel_reading_order(action="move")');
496
+ if (args.target_index === undefined)
497
+ throw new Error('target_index is required for novel_reading_order(action="move")');
498
+ if (args.expected_revision === undefined)
499
+ throw new Error('expected_revision is required for novel_reading_order(action="move")');
500
+ if (!args.document_id.trim())
501
+ throw new Error('document_id must be non-empty');
502
+ if (!args.target_volume_id.trim())
503
+ throw new Error('target_volume_id must be non-empty');
504
+ if (!args.expected_revision.trim())
505
+ throw new Error('expected_revision must be non-empty');
506
+ return await clientFor(exec).postJson('/api/reading-order/move', {
507
+ document_id: args.document_id,
508
+ target_volume_id: args.target_volume_id,
509
+ target_index: args.target_index,
510
+ expected_revision: args.expected_revision,
511
+ }, exec.signal);
512
+ }
513
+ default: throw new Error(`novel_reading_order: unknown action ${String(args.action)}`);
514
+ }
393
515
  },
394
516
  }));
395
517
  ctx.tools.register(defineTool({
@@ -436,15 +558,85 @@ export function registerNovelTools(ctx, clientFactory, options) {
436
558
  return await clientFor(exec).getJson(`/api/chapters/${encodeURIComponent(args.chapter_id)}/work-brief`, params, exec.signal);
437
559
  },
438
560
  }));
561
+ // ── scenes ────────────────────────────────────────────────────────────────────
439
562
  ctx.tools.register(defineTool({
440
- name: 'novel_scene_structure',
441
- description: 'Read the canonical scene structure with stable scene ids, reading/story-time order, chapter revisions, references, freshness, and explicit issues.',
442
- parameters: {},
563
+ name: 'novel_scene',
564
+ description: 'Read the scene structure of a chapter, update one scene, and move a scene to another chapter ' +
565
+ 'or position. Structure is read-only; update and move are optimistic and require the current ' +
566
+ 'revision.',
567
+ parameters: {
568
+ action: { type: 'string', required: true, enum: ['structure', 'update', 'move'], description: 'Scene operation.' },
569
+ scene_id: { type: 'string', description: 'Stable scene id from novel_scene(action="structure").' },
570
+ expected_revision: { type: 'string', description: 'Fresh scene-structure revision used for CAS.' },
571
+ title: { type: 'string', description: 'Optional scene title.' },
572
+ story_time_sort_key: { type: 'string', description: 'Optional lexical story-time sort key.' },
573
+ story_time_label: { type: 'string', description: 'Optional author-facing story-time label.' },
574
+ characters: { type: 'array', items: { type: 'string' }, description: 'Character references; replaces the current list when supplied.' },
575
+ locations: { type: 'array', items: { type: 'string' }, description: 'Location references; replaces the current list when supplied.' },
576
+ events: { type: 'array', items: { type: 'string' }, description: 'Event references; replaces the current list when supplied.' },
577
+ target_chapter_id: { type: 'string', description: 'Target chapter id (ch_<digits>).' },
578
+ target_index: { type: 'integer', description: 'Zero-based scene position inside the target chapter.' },
579
+ expected_source_revision: { type: 'string', description: 'Fresh source chapter revision.' },
580
+ expected_target_revision: { type: 'string', description: 'Fresh target chapter revision.' },
581
+ },
443
582
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
444
583
  timeoutMs,
445
- isConcurrencySafe: () => true,
446
- async execute(_args, exec) {
447
- return await clientFor(exec).getJson('/api/scenes', {}, exec.signal);
584
+ isConcurrencySafe: args => ['structure'].includes(args.action),
585
+ async execute(args, exec) {
586
+ switch (args.action) {
587
+ case 'structure': {
588
+ return await clientFor(exec).getJson('/api/scenes', {}, exec.signal);
589
+ }
590
+ case 'update': {
591
+ if (args.scene_id === undefined)
592
+ throw new Error('scene_id is required for novel_scene(action="update")');
593
+ if (args.expected_revision === undefined)
594
+ throw new Error('expected_revision is required for novel_scene(action="update")');
595
+ if (!args.scene_id.trim() || !args.expected_revision.trim())
596
+ throw new Error('scene_id and expected_revision must be non-empty');
597
+ const body = { scene_id: args.scene_id, expected_revision: args.expected_revision };
598
+ if (args.title !== undefined)
599
+ body['title'] = args.title;
600
+ if (args.story_time_sort_key !== undefined)
601
+ body['story_time_sort_key'] = args.story_time_sort_key;
602
+ if (args.story_time_label !== undefined)
603
+ body['story_time_label'] = args.story_time_label;
604
+ if (args.characters !== undefined)
605
+ body['characters'] = args.characters;
606
+ if (args.locations !== undefined)
607
+ body['locations'] = args.locations;
608
+ if (args.events !== undefined)
609
+ body['events'] = args.events;
610
+ return await clientFor(exec).postJson('/api/scenes/metadata', body, exec.signal);
611
+ }
612
+ case 'move': {
613
+ if (args.scene_id === undefined)
614
+ throw new Error('scene_id is required for novel_scene(action="move")');
615
+ if (args.target_chapter_id === undefined)
616
+ throw new Error('target_chapter_id is required for novel_scene(action="move")');
617
+ if (args.target_index === undefined)
618
+ throw new Error('target_index is required for novel_scene(action="move")');
619
+ if (args.expected_revision === undefined)
620
+ throw new Error('expected_revision is required for novel_scene(action="move")');
621
+ if (args.expected_source_revision === undefined)
622
+ throw new Error('expected_source_revision is required for novel_scene(action="move")');
623
+ if (args.expected_target_revision === undefined)
624
+ throw new Error('expected_target_revision is required for novel_scene(action="move")');
625
+ if (!args.scene_id.trim() || !CHAPTER_ID_PATTERN.test(args.target_chapter_id))
626
+ throw new Error('scene_id and a valid target_chapter_id are required');
627
+ if (!args.expected_revision.trim() || !args.expected_source_revision.trim() || !args.expected_target_revision.trim())
628
+ throw new Error('all scene and chapter revisions are required');
629
+ return await clientFor(exec).postJson('/api/scenes/move', {
630
+ scene_id: args.scene_id,
631
+ target_chapter_id: args.target_chapter_id,
632
+ target_index: args.target_index,
633
+ expected_revision: args.expected_revision,
634
+ expected_source_revision: args.expected_source_revision,
635
+ expected_target_revision: args.expected_target_revision,
636
+ }, exec.signal);
637
+ }
638
+ default: throw new Error(`novel_scene: unknown action ${String(args.action)}`);
639
+ }
448
640
  },
449
641
  }));
450
642
  ctx.tools.register(defineTool({
@@ -462,29 +654,125 @@ export function registerNovelTools(ctx, clientFactory, options) {
462
654
  return await clientFor(exec).getJson(`/api/chapters/${encodeURIComponent(args.chapter_id)}/scenes`, {}, exec.signal);
463
655
  },
464
656
  }));
657
+ // ── scene migration ───────────────────────────────────────────────────────────
465
658
  ctx.tools.register(defineTool({
466
- name: 'novel_scene_migration_preview',
467
- description: 'Build a read-only, exact-revision preview that segments existing chapters into stable scenes. This does not write a sidecar.',
468
- parameters: {},
659
+ name: 'novel_scene_migration',
660
+ description: 'Preview, apply and roll back the paragraph-to-scene migration of one chapter. The preview is ' +
661
+ 'read-only; apply and rollback are guarded by the revisions the previous step reported.',
662
+ parameters: {
663
+ action: { type: 'string', required: true, enum: ['preview', 'apply', 'rollback'], description: 'Scene migration step; apply and rollback require the revision the previous step reported.' },
664
+ expected_preview_revision: { type: 'string', description: 'Exact preview_revision from novel_scene_migration(action="preview").' },
665
+ confirm: { type: 'boolean', description: 'Must be true after the author approves the preview.' },
666
+ migration_id: { type: 'string', description: 'Migration id returned by novel_scene_migration(action="apply").' },
667
+ expected_revision: { type: 'string', description: 'Fresh scene-structure revision used for CAS.' },
668
+ },
469
669
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
470
670
  timeoutMs,
471
- isConcurrencySafe: () => true,
472
- async execute(_args, exec) {
473
- return await clientFor(exec).getJson('/api/scenes/migration-preview', {}, exec.signal);
671
+ isConcurrencySafe: args => ['preview'].includes(args.action),
672
+ async execute(args, exec) {
673
+ switch (args.action) {
674
+ case 'preview': {
675
+ return await clientFor(exec).getJson('/api/scenes/migration-preview', {}, exec.signal);
676
+ }
677
+ case 'apply': {
678
+ if (args.expected_preview_revision === undefined)
679
+ throw new Error('expected_preview_revision is required for novel_scene_migration(action="apply")');
680
+ if (args.confirm === undefined)
681
+ throw new Error('confirm is required for novel_scene_migration(action="apply")');
682
+ if (!args.expected_preview_revision.trim())
683
+ throw new Error('expected_preview_revision must be non-empty');
684
+ if (args.confirm !== true)
685
+ throw new Error('confirm must be true');
686
+ return await clientFor(exec).postJson('/api/scenes/migration/apply', {
687
+ expected_preview_revision: args.expected_preview_revision,
688
+ confirm: true,
689
+ }, exec.signal);
690
+ }
691
+ case 'rollback': {
692
+ if (args.migration_id === undefined)
693
+ throw new Error('migration_id is required for novel_scene_migration(action="rollback")');
694
+ if (args.expected_revision === undefined)
695
+ throw new Error('expected_revision is required for novel_scene_migration(action="rollback")');
696
+ if (!args.migration_id.trim() || !args.expected_revision.trim())
697
+ throw new Error('migration_id and expected_revision must be non-empty');
698
+ return await clientFor(exec).postJson('/api/scenes/migration/rollback', {
699
+ migration_id: args.migration_id,
700
+ expected_revision: args.expected_revision,
701
+ }, exec.signal);
702
+ }
703
+ default: throw new Error(`novel_scene_migration: unknown action ${String(args.action)}`);
704
+ }
474
705
  },
475
706
  }));
707
+ // ── writing assets ────────────────────────────────────────────────────────────
476
708
  ctx.tools.register(defineTool({
477
- name: 'novel_assets_list',
478
- description: 'List structured assets (characters, world entities, progression systems) of the current novel project.',
709
+ name: 'novel_assets',
710
+ description: 'List, read, create and update the writing assets of the current project (terminology, sample ' +
711
+ 'pools, character attribute groups and related records).',
479
712
  parameters: {
713
+ action: { type: 'string', required: true, enum: ['list', 'read', 'create', 'update'], description: 'Writing-asset operation.' },
480
714
  kind: { type: 'string', enum: ['character', 'world', 'progression'], description: 'Restrict to one asset kind; omit to list all kinds.' },
715
+ id: { type: 'string', description: 'Asset id; required for read/create/update. Must match [A-Za-z0-9][A-Za-z0-9_.-]{0,79}.' },
716
+ data: { type: 'json', description: 'Object of asset fields (e.g. name, summary, aliases).' },
717
+ body_markdown: { type: 'string', description: 'Markdown body (character/world assets).' },
718
+ revision: { type: 'string', description: 'The asset revision fingerprint from a fresh novel_assets(action="list") call.' },
719
+ raw_text: { type: 'string', description: 'Full replacement document text, validated by the server.' },
720
+ related_mode: { type: 'string', enum: ['merge', 'replace'], description: 'Preserve and merge existing related entries by default; replace explicitly clears or replaces relations.' },
481
721
  },
482
722
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
483
723
  timeoutMs,
484
- isConcurrencySafe: () => true,
724
+ isConcurrencySafe: args => ['list', 'read'].includes(args.action),
485
725
  async execute(args, exec) {
486
- const client = clientFor(exec);
487
- return await client.getJson('/api/assets', { kind: args.kind ?? '' }, exec.signal);
726
+ switch (args.action) {
727
+ case 'list': {
728
+ const client = clientFor(exec);
729
+ return await client.getJson('/api/assets', { kind: args.kind ?? '' }, exec.signal);
730
+ }
731
+ case 'read': {
732
+ if (args.kind === undefined)
733
+ throw new Error('kind is required for novel_assets(action="read")');
734
+ if (args.id === undefined)
735
+ throw new Error(assetIdRequired('read'));
736
+ const client = clientFor(exec);
737
+ assertAssetId(args.id);
738
+ return await client.getJson(`/api/assets/${args.kind}/${encodeURIComponent(args.id)}`, {}, exec.signal);
739
+ }
740
+ case 'create': {
741
+ if (args.kind === undefined)
742
+ throw new Error('kind is required for novel_assets(action="create")');
743
+ if (args.id === undefined)
744
+ throw new Error(assetIdRequired('create'));
745
+ const client = clientFor(exec);
746
+ assertAssetId(args.id);
747
+ const body = { kind: args.kind, id: args.id };
748
+ if (args.data !== undefined)
749
+ body['data'] = args.data;
750
+ if (args.body_markdown !== undefined)
751
+ body['body_markdown'] = args.body_markdown;
752
+ return await client.postJson('/api/assets', body, exec.signal);
753
+ }
754
+ case 'update': {
755
+ if (args.kind === undefined)
756
+ throw new Error('kind is required for novel_assets(action="update")');
757
+ if (args.id === undefined)
758
+ throw new Error(assetIdRequired('update'));
759
+ if (args.revision === undefined)
760
+ throw new Error('revision is required for novel_assets(action="update")');
761
+ const client = clientFor(exec);
762
+ assertAssetId(args.id);
763
+ const body = { kind: args.kind, id: args.id, revision: args.revision };
764
+ if (args.data !== undefined)
765
+ body['data'] = args.data;
766
+ if (args.body_markdown !== undefined)
767
+ body['body_markdown'] = args.body_markdown;
768
+ if (args.raw_text !== undefined)
769
+ body['raw_text'] = args.raw_text;
770
+ if (args.related_mode !== undefined)
771
+ body['related_mode'] = args.related_mode;
772
+ return await client.postJson('/api/assets/update', body, exec.signal);
773
+ }
774
+ default: throw new Error(`novel_assets: unknown action ${String(args.action)}`);
775
+ }
488
776
  },
489
777
  }));
490
778
  ctx.tools.register(defineTool({
@@ -510,27 +798,85 @@ export function registerNovelTools(ctx, clientFactory, options) {
510
798
  return await client.getJson('/api/search', params, exec.signal);
511
799
  },
512
800
  }));
801
+ // ── project documents ─────────────────────────────────────────────────────────
513
802
  ctx.tools.register(defineTool({
514
- name: 'novel_doc_read',
515
- description: 'Read a project document (path relative to the novel data root, e.g. "src/outline.md" or "data/manuscript/ch_0007.md"). Returns content plus a `version` fingerprint needed by novel_doc_write.',
803
+ name: 'novel_doc',
804
+ description: 'Read, write and create the plain documents of the current project (notes, briefs and other ' +
805
+ 'author-owned files). Writes are optimistic and reject a stale version.',
516
806
  parameters: {
517
- path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
807
+ action: { type: 'string', required: true, enum: ['read', 'write', 'create'], description: 'Document operation.' },
808
+ path: { type: 'string', description: 'Document path relative to the novel data root (read/write). Writable roots are src/ and data/manuscript/; create derives its path from kind and name.' },
809
+ content: { type: 'string', description: 'Full new document content (max 2 MB).' },
810
+ version: { type: 'string', description: 'The version fingerprint from novel_doc(action="read") (optimistic lock).' },
811
+ force: { type: 'boolean', description: 'Bypass the version check (default false).' },
812
+ kind: { type: 'string', description: 'Document kind for create: "character" or "world" (the server derives the file path).' },
813
+ name: { type: 'string', description: 'Document name.' },
814
+ description: { type: 'string', description: 'Short description of the document.' },
518
815
  },
519
816
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
520
817
  timeoutMs,
521
- isConcurrencySafe: () => true,
818
+ isConcurrencySafe: args => ['read'].includes(args.action),
522
819
  async execute(args, exec) {
523
- const client = clientFor(exec);
524
- if (!args.path.trim())
525
- throw new Error('path must be a non-empty string');
526
- return await client.getJson('/api/document', { path: args.path }, exec.signal);
820
+ switch (args.action) {
821
+ case 'read': {
822
+ if (args.path === undefined)
823
+ throw new Error('path is required for novel_doc(action="read")');
824
+ const client = clientFor(exec);
825
+ if (!args.path.trim())
826
+ throw new Error('path must be a non-empty string');
827
+ return await client.getJson('/api/document', { path: args.path }, exec.signal);
828
+ }
829
+ case 'write': {
830
+ if (args.path === undefined)
831
+ throw new Error('path is required for novel_doc(action="write")');
832
+ if (args.content === undefined)
833
+ throw new Error('content is required for novel_doc(action="write")');
834
+ const client = clientFor(exec);
835
+ if (!args.path.trim())
836
+ throw new Error('path must be a non-empty string');
837
+ const writable = normalize(args.path.replaceAll('\\', '/')).replace(/^\.\//, '');
838
+ if (!(writable.startsWith('src/') || writable.startsWith('data/manuscript/')) || !writable.toLowerCase().endsWith('.md')) {
839
+ throw new Error('path must be a .md document under src/ or data/manuscript/; Studio does not write other locations');
840
+ }
841
+ // The server only CAS-checks when `version` is present, so omitting
842
+ // it is an unlocked last-write-wins overwrite of an existing
843
+ // document. `force` is the explicit bypass; require one or the
844
+ // other rather than silently choosing "overwrite".
845
+ if (args.version === undefined && args.force !== true) {
846
+ throw new Error('version is required for novel_doc(action="write"); pass force=true to overwrite without a lock');
847
+ }
848
+ const body = { path: args.path, content: args.content };
849
+ if (args.version !== undefined)
850
+ body['version'] = args.version;
851
+ if (args.force !== undefined)
852
+ body['force'] = args.force;
853
+ return await client.putJson('/api/document', body, exec.signal);
854
+ }
855
+ case 'create': {
856
+ if (args.kind === undefined)
857
+ throw new Error('kind is required for novel_doc(action="create")');
858
+ if (args.name === undefined)
859
+ throw new Error('name is required for novel_doc(action="create")');
860
+ const client = clientFor(exec);
861
+ if (!args.kind.trim() || !args.name.trim())
862
+ throw new Error('kind and name must be non-empty strings');
863
+ if (args.kind !== 'character' && args.kind !== 'world') {
864
+ throw new Error('kind must be "character" or "world" for novel_doc(action="create"); the server derives the document path from it');
865
+ }
866
+ const body = { kind: args.kind, name: args.name };
867
+ if (args.description !== undefined)
868
+ body['description'] = args.description;
869
+ return await client.postJson('/api/document/create', body, exec.signal);
870
+ }
871
+ default: throw new Error(`novel_doc: unknown action ${String(args.action)}`);
872
+ }
527
873
  },
528
874
  }));
529
875
  // ── writes ─────────────────────────────────────────────────────────────
530
876
  ctx.tools.register(defineTool({
531
877
  name: 'novel_outline_edit',
532
878
  description: 'Immediate compatibility write for an outline change the author has already authorized exactly; otherwise use ' +
533
- 'novel_structured_change_plan. Apply ONE atomic structural edit to the outline tree. Edits are revision-gated: ' +
879
+ 'novel_change_plan(action="structured"). Apply ONE atomic structural edit to the outline tree. Edits are revision-gated: ' +
534
880
  'read the current `revision` via novel_outline_read first and pass it back; the server ' +
535
881
  'rejects the edit with a conflict if the outline changed in between. ' +
536
882
  'Operations: "rename" (node_id + title), "update_summary" (node_id + summary), ' +
@@ -540,7 +886,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
540
886
  parameters: {
541
887
  operation: { type: 'string', required: true, enum: ['rename', 'update_summary', 'add_child', 'add_after', 'delete', 'promote', 'get_meta', 'set_meta'], description: 'The structural operation to apply.' },
542
888
  cascade: { type: 'boolean', description: 'For delete, false removes only the container and keeps children and chapter IDs.' },
543
- metadata: { type: 'json', description: 'Field object for set_meta; use target_words, beats, hooks or characters.' },
889
+ metadata: { type: 'json', description: 'Field object for set_meta; use dramatic_position, content_focus, goals, target_words, word_budget, characters, settings, emotional_arc, beats, hooks, promise, payoff_of or function. Text fields (dramatic_position, content_focus, emotional_arc) take one line of text; word counts (target_words, word_budget) take a number. List fields (goals, beats, hooks, characters, settings, payoff_of, function) take an array of strings or one comma-separated string; promise accepts both too.' },
544
890
  chapter_id: { type: 'string', description: 'Chapter ID alias for node_id in get_meta/set_meta.' },
545
891
  updates: { type: 'json', description: 'Batch of 1-50 {chapter_id, metadata} objects for set_meta, or {chapter_id} for get_meta; one revision guards the whole batch.' },
546
892
  revision: { type: 'string', required: true, description: 'The outline revision fingerprint from a fresh novel_outline_read call.' },
@@ -573,138 +919,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
573
919
  return await client.postJson('/api/outline/edit', body, exec.signal);
574
920
  },
575
921
  }));
576
- ctx.tools.register(defineTool({
577
- name: 'novel_reading_order_move',
578
- description: 'Move one manuscript document within or across volumes. Requires the fresh reading-order revision and rejects ambiguous, missing, or concurrently changed order.',
579
- parameters: {
580
- document_id: { type: 'string', required: true, description: 'Stable document_id or unambiguous occurrence_id.' },
581
- target_volume_id: { type: 'string', required: true, description: 'Target volume_id from novel_reading_order.' },
582
- target_index: { type: 'integer', required: true, description: 'Zero-based position inside the target volume.' },
583
- expected_revision: { type: 'string', required: true, description: 'Fresh reading-order revision used for CAS.' },
584
- },
585
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
586
- timeoutMs,
587
- async execute(args, exec) {
588
- if (!args.document_id.trim())
589
- throw new Error('document_id must be non-empty');
590
- if (!args.target_volume_id.trim())
591
- throw new Error('target_volume_id must be non-empty');
592
- if (!args.expected_revision.trim())
593
- throw new Error('expected_revision must be non-empty');
594
- return await clientFor(exec).postJson('/api/reading-order/move', {
595
- document_id: args.document_id,
596
- target_volume_id: args.target_volume_id,
597
- target_index: args.target_index,
598
- expected_revision: args.expected_revision,
599
- }, exec.signal);
600
- },
601
- }));
602
- ctx.tools.register(defineTool({
603
- name: 'novel_scene_migration_apply',
604
- description: 'Apply one freshly read scene migration preview. Requires the exact preview revision and explicit confirmation.',
605
- parameters: {
606
- expected_preview_revision: { type: 'string', required: true, description: 'Exact preview_revision from novel_scene_migration_preview.' },
607
- confirm: { type: 'boolean', required: true, description: 'Must be true after the author approves the preview.' },
608
- },
609
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
610
- timeoutMs,
611
- async execute(args, exec) {
612
- if (!args.expected_preview_revision.trim())
613
- throw new Error('expected_preview_revision must be non-empty');
614
- if (args.confirm !== true)
615
- throw new Error('confirm must be true');
616
- return await clientFor(exec).postJson('/api/scenes/migration/apply', {
617
- expected_preview_revision: args.expected_preview_revision,
618
- confirm: true,
619
- }, exec.signal);
620
- },
621
- }));
622
- ctx.tools.register(defineTool({
623
- name: 'novel_scene_migration_rollback',
624
- description: 'Restore the byte-exact scene sidecar that preceded a migration, guarded by the current scene-structure revision.',
625
- parameters: {
626
- migration_id: { type: 'string', required: true, description: 'Migration id returned by novel_scene_migration_apply.' },
627
- expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
628
- },
629
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
630
- timeoutMs,
631
- async execute(args, exec) {
632
- if (!args.migration_id.trim() || !args.expected_revision.trim())
633
- throw new Error('migration_id and expected_revision must be non-empty');
634
- return await clientFor(exec).postJson('/api/scenes/migration/rollback', {
635
- migration_id: args.migration_id,
636
- expected_revision: args.expected_revision,
637
- }, exec.signal);
638
- },
639
- }));
640
- ctx.tools.register(defineTool({
641
- name: 'novel_scene_update',
642
- description: 'CAS-update scene title, story time, and character/location/event references without changing manuscript prose.',
643
- parameters: {
644
- scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
645
- expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
646
- title: { type: 'string', description: 'Optional scene title.' },
647
- story_time_sort_key: { type: 'string', description: 'Optional lexical story-time sort key.' },
648
- story_time_label: { type: 'string', description: 'Optional author-facing story-time label.' },
649
- characters: { type: 'array', items: { type: 'string' }, description: 'Character references; replaces the current list when supplied.' },
650
- locations: { type: 'array', items: { type: 'string' }, description: 'Location references; replaces the current list when supplied.' },
651
- events: { type: 'array', items: { type: 'string' }, description: 'Event references; replaces the current list when supplied.' },
652
- },
653
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
654
- timeoutMs,
655
- async execute(args, exec) {
656
- if (!args.scene_id.trim() || !args.expected_revision.trim())
657
- throw new Error('scene_id and expected_revision must be non-empty');
658
- const body = { scene_id: args.scene_id, expected_revision: args.expected_revision };
659
- if (args.title !== undefined)
660
- body['title'] = args.title;
661
- if (args.story_time_sort_key !== undefined)
662
- body['story_time_sort_key'] = args.story_time_sort_key;
663
- if (args.story_time_label !== undefined)
664
- body['story_time_label'] = args.story_time_label;
665
- if (args.characters !== undefined)
666
- body['characters'] = args.characters;
667
- if (args.locations !== undefined)
668
- body['locations'] = args.locations;
669
- if (args.events !== undefined)
670
- body['events'] = args.events;
671
- return await clientFor(exec).postJson('/api/scenes/metadata', body, exec.signal);
672
- },
673
- }));
674
- ctx.tools.register(defineTool({
675
- name: 'novel_scene_move',
676
- description: 'Move one complete scene within or across chapters with scene/source/target revision guards. Cross-chapter moves start the existing manuscript acceptance flow.',
677
- parameters: {
678
- scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
679
- target_chapter_id: { type: 'string', required: true, description: 'Target chapter id (ch_<digits>).' },
680
- target_index: { type: 'integer', required: true, description: 'Zero-based scene position inside the target chapter.' },
681
- expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision.' },
682
- expected_source_revision: { type: 'string', required: true, description: 'Fresh source chapter revision.' },
683
- expected_target_revision: { type: 'string', required: true, description: 'Fresh target chapter revision.' },
684
- },
685
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
686
- timeoutMs,
687
- async execute(args, exec) {
688
- if (!args.scene_id.trim() || !CHAPTER_ID_PATTERN.test(args.target_chapter_id))
689
- throw new Error('scene_id and a valid target_chapter_id are required');
690
- if (!args.expected_revision.trim() || !args.expected_source_revision.trim() || !args.expected_target_revision.trim())
691
- throw new Error('all scene and chapter revisions are required');
692
- return await clientFor(exec).postJson('/api/scenes/move', {
693
- scene_id: args.scene_id,
694
- target_chapter_id: args.target_chapter_id,
695
- target_index: args.target_index,
696
- expected_revision: args.expected_revision,
697
- expected_source_revision: args.expected_source_revision,
698
- expected_target_revision: args.expected_target_revision,
699
- }, exec.signal);
700
- },
701
- }));
702
922
  ctx.tools.register(defineTool({
703
923
  name: 'novel_write_chapter',
704
- description: 'Queue a chapter write and immediately return a durable task_id. Poll novel_task_get for progress/result. Omit chapter_id to write ' +
924
+ description: 'Queue a chapter write and immediately return a durable task_id. Poll novel_task(action="get") for progress/result. Omit chapter_id to write ' +
705
925
  'the outline-recommended next chapter; a given chapter_id must match the current recommendation. ' +
706
- 'Preview context first with novel_context_preview and consult novel_outline_read for the recommendation.',
926
+ 'Preview context first with novel_context_preview and consult novel_outline_read for the recommendation. ' +
927
+ 'Cancel with novel_task(action="cancel"). Generated partial prose is retained; resume_from_draft continues it in a new run when the source/context revisions still match.',
707
928
  parameters: {
929
+ dry_run: { type: 'boolean', description: 'Only assemble the current writer input and per-document sizes; no model call and no writing task. Guidance and target_words are included in this preview.' },
708
930
  run_id_v2: { type: 'string', description: 'Resume this completed draft without generating it again.' },
709
931
  resume_from_draft: { type: 'boolean', description: 'Resume the latest uncommitted draft for this chapter.' },
710
932
  target_words: { type: 'integer', description: 'Target word count, 200-50000. When omitted use the outline target, then default 3000.' },
@@ -735,6 +957,8 @@ export function registerNovelTools(ctx, clientFactory, options) {
735
957
  body['temperature'] = args.temperature;
736
958
  if (args.outline_revision !== undefined)
737
959
  body['outline_revision'] = args.outline_revision;
960
+ if (args.dry_run === true)
961
+ return await client.postJson('/api/write', { ...body, dry_run: true }, exec.signal);
738
962
  return await client.postJson('/api/tasks', { type: 'chapter_write', input: body }, exec.signal);
739
963
  },
740
964
  }));
@@ -749,14 +973,28 @@ export function registerNovelTools(ctx, clientFactory, options) {
749
973
  chapter_id: { type: 'string', description: 'Chapter id (ch_<digits>), resolved client-side to data/manuscript/<id>.md.' },
750
974
  strict: { type: 'boolean', description: 'Enable strict review mode (default false).' },
751
975
  dimensions: { type: 'array', items: { type: 'integer' }, description: 'Optional list of review dimension numbers (1-37) to restrict to.' },
976
+ depth: { type: 'string', enum: ['quick', 'full'], description: 'Default full. Quick reviews coherence/canon/prose and safety only; scope_status=partial cannot approve full-chapter delivery. Do not combine quick with dimensions.' },
977
+ domains: { type: 'array', items: { type: 'string' }, description: 'Review domain ids: coherence, character, plot, pacing, prose, canon. Use this or dimensions. Omitted domains remain explicitly unreviewed.' },
752
978
  dog_threshold: { type: 'integer', description: 'DoG aggregate score threshold (default 70); this does not change OpenWrite review.' },
753
979
  },
754
980
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
755
981
  timeoutMs,
756
982
  async execute(args, exec) {
757
983
  const client = clientFor(exec);
758
- if (args.action === 'versions' || args.action === 'compare')
759
- return await client.postJson('/api/review/history', { action: args.action, chapter_id: reviewChapterId(args), revision: args.revision ?? '' }, exec.signal);
984
+ if (args.action === 'versions' || args.action === 'compare') {
985
+ // "versions" lists saved reviews and needs no revision; only
986
+ // "compare" needs one. An empty revision for compare is not "unset":
987
+ // the store answers REVIEW_HISTORY_NOT_FOUND, so report the real cause
988
+ // before sending the request.
989
+ if (args.action === 'compare' && !args.revision?.trim()) {
990
+ throw new Error('revision is required for novel_review_chapter(action="compare")');
991
+ }
992
+ return await client.postJson('/api/review/history', {
993
+ action: args.action,
994
+ chapter_id: reviewChapterId(args),
995
+ revision: args.revision ?? '',
996
+ }, exec.signal);
997
+ }
760
998
  if (args.dimensions !== undefined && args.dimensions.some(value => !Number.isInteger(value) || value < 1 || value > 37)) {
761
999
  throw new Error('dimensions must contain only integers from 1 to 37');
762
1000
  }
@@ -769,6 +1007,10 @@ export function registerNovelTools(ctx, clientFactory, options) {
769
1007
  body['strict'] = args.strict;
770
1008
  if (args.dimensions !== undefined)
771
1009
  body['dimensions'] = args.dimensions;
1010
+ if (args.depth !== undefined)
1011
+ body['depth'] = args.depth;
1012
+ if (args.domains !== undefined)
1013
+ body['domains'] = args.domains;
772
1014
  const response = await client.postJson('/api/review', body, exec.signal);
773
1015
  const value = response !== null && typeof response === 'object' && !Array.isArray(response)
774
1016
  ? response
@@ -790,42 +1032,10 @@ export function registerNovelTools(ctx, clientFactory, options) {
790
1032
  return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
791
1033
  },
792
1034
  }));
793
- ctx.tools.register(defineTool({
794
- name: 'novel_asset_update',
795
- description: 'Immediate compatibility write for an asset change the author has already authorized exactly; otherwise use ' +
796
- 'novel_structured_change_plan. Update a structured asset (character / world / progression). Revision-gated: pass the `revision` ' +
797
- 'fingerprint from novel_assets_list; the server rejects the update with a conflict if the asset changed. ' +
798
- 'Pass field changes via `data` (merged into the asset front matter), `body_markdown` to replace the ' +
799
- 'markdown body, or `raw_text` to replace the whole document.',
800
- parameters: {
801
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
802
- id: { type: 'string', required: true, description: 'Asset id.' },
803
- revision: { type: 'string', required: true, description: 'The asset revision fingerprint from a fresh novel_assets_list call.' },
804
- data: { type: 'json', description: 'Object of asset fields to merge (e.g. name, summary, aliases).' },
805
- body_markdown: { type: 'string', description: 'Replacement markdown body (character/world assets).' },
806
- raw_text: { type: 'string', description: 'Full replacement document text, validated by the server.' },
807
- related_mode: { type: 'string', enum: ['merge', 'replace'], description: 'Preserve and merge existing related entries by default; replace explicitly clears or replaces relations.' },
808
- },
809
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
810
- timeoutMs,
811
- async execute(args, exec) {
812
- const client = clientFor(exec);
813
- const body = { kind: args.kind, id: args.id, revision: args.revision };
814
- if (args.data !== undefined)
815
- body['data'] = args.data;
816
- if (args.body_markdown !== undefined)
817
- body['body_markdown'] = args.body_markdown;
818
- if (args.raw_text !== undefined)
819
- body['raw_text'] = args.raw_text;
820
- if (args.related_mode !== undefined)
821
- body['related_mode'] = args.related_mode;
822
- return await client.postJson('/api/assets/update', body, exec.signal);
823
- },
824
- }));
825
1035
  ctx.tools.register(defineTool({
826
1036
  name: 'novel_foreshadowing',
827
1037
  description: 'Immediate compatibility write for a foreshadowing change the author has already authorized exactly; otherwise use ' +
828
- 'novel_structured_change_plan. Manage the foreshadowing DAG. action "create": node_id + content, optional weight (1-10, default 5), ' +
1038
+ 'novel_change_plan(action="structured"). Manage the foreshadowing DAG. action "create": node_id + content, optional weight (1-10, default 5), ' +
829
1039
  'layer (default "支线"), target_chapter. action "update": node_id + status (e.g. planted/paid-off).',
830
1040
  parameters: {
831
1041
  action: { type: 'string', required: true, enum: ['create', 'update'], description: 'Create a new foreshadowing node or update an existing one.' },
@@ -856,111 +1066,81 @@ export function registerNovelTools(ctx, clientFactory, options) {
856
1066
  return await client.postJson('/api/foreshadowing', body, exec.signal);
857
1067
  },
858
1068
  }));
1069
+ // ── change plans ──────────────────────────────────────────────────────────────
859
1070
  ctx.tools.register(defineTool({
860
- name: 'novel_doc_write',
861
- description: 'Write a project document (path relative to the novel data root). Uses a version-based optimistic lock: ' +
862
- 'pass the `version` returned by novel_doc_read; the server rejects with a conflict if the document changed. ' +
863
- 'Omit version to create a new document; set force to bypass the lock.',
864
- parameters: {
865
- path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
866
- content: { type: 'string', required: true, description: 'Full new document content (max 2 MB).' },
867
- version: { type: 'string', description: 'The version fingerprint from novel_doc_read (optimistic lock).' },
868
- force: { type: 'boolean', description: 'Bypass the version check (default false).' },
869
- },
870
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
871
- timeoutMs,
872
- async execute(args, exec) {
873
- const client = clientFor(exec);
874
- if (!args.path.trim())
875
- throw new Error('path must be a non-empty string');
876
- const body = { path: args.path, content: args.content };
877
- if (args.version !== undefined)
878
- body['version'] = args.version;
879
- if (args.force !== undefined)
880
- body['force'] = args.force;
881
- return await client.putJson('/api/document', body, exec.signal);
882
- },
883
- }));
884
- ctx.tools.register(defineTool({
885
- name: 'novel_document_change_plan',
886
- description: 'Stage and review revision-gated changes to an existing novel document. Use action "preview" with path and edits ' +
887
- 'to receive an immutable preview_token and real before/after summary. Use "apply" only after author acceptance, ' +
888
- 'passing that token and confirm: true; "reject" discards it, "retry" rebuilds it against the current source, and "undo" safely ' +
889
- 'applies an undo_preview_token returned by a prior apply. Each edit uses old_text/new_text or unique start_text/' +
890
- 'end_text/new_text anchors. The server rechecks the source revision and writes exactly the stored preview.',
1071
+ name: 'novel_change_plan',
1072
+ description: 'Preview, apply, reject, retry and undo a change plan. document plans edit one project document ' +
1073
+ 'as text; structured plans edit the manuscript model (outline, scenes, canon) as operations. ' +
1074
+ 'Both are two-step: preview returns a token the follow-up step must present.',
891
1075
  parameters: {
892
- action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
1076
+ action: { type: 'string', required: true, enum: ['document', 'structured'], description: 'Plan kind: document edits a project document, structured edits the manuscript model.' },
1077
+ step: { type: 'string', enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle step.' },
893
1078
  confirm: { type: 'boolean', description: 'Required true for apply and undo after author acceptance.' },
894
1079
  path: { type: 'string', description: 'Existing document path for preview, relative to the novel root.' },
895
1080
  edits: { type: 'json', description: 'Preview edits: [{old_text,new_text}] or [{start_text,end_text,new_text,end_inclusive}]. Range end defaults to inclusive for compatibility; end_inclusive:false preserves the end anchor (e.g. the next heading).' },
896
1081
  preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
897
- },
898
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
899
- timeoutMs,
900
- async execute(args, exec) {
901
- if (args.action === 'preview' && (!Array.isArray(args.edits) || (args.edits.length < 1 || args.edits.length > 50))) {
902
- throw new Error('preview requires between 1 and 50 edits ' +
903
- `(received ${describeValue(args.edits)})`);
904
- }
905
- if (args.action !== 'preview' && !args.preview_token?.trim()) {
906
- throw new Error(`${args.action} requires preview_token`);
907
- }
908
- if (['apply', 'undo'].includes(args.action) && args.confirm !== true)
909
- throw new Error('apply/undo requires confirm: true');
910
- const client = clientFor(exec);
911
- const body = { action: args.action };
912
- if (args.confirm !== undefined)
913
- body['confirm'] = args.confirm;
914
- if (args.path !== undefined)
915
- body['path'] = args.path;
916
- if (args.edits !== undefined)
917
- body['edits'] = args.edits;
918
- if (args.preview_token !== undefined)
919
- body['preview_token'] = args.preview_token;
920
- return await client.postJson('/api/document/change-plan', body, exec.signal);
921
- },
922
- }));
923
- ctx.tools.register(defineTool({
924
- name: 'novel_structured_change_plan',
925
- description: 'Stage one structured OpenWrite domain change without writing it. Supported change_kind values are "outline", ' +
926
- '"asset", "focus", "foreshadowing", and "writing_targets"; `change` uses the corresponding direct tool payload. ' +
927
- 'Use action "preview" to receive an immutable token and entity-level before/after summary. Only after author ' +
928
- 'acceptance pass that token unchanged to "apply". "reject" consumes it, "retry" rebuilds the same semantic ' +
929
- 'change against current source, and "undo" accepts only the revision-bound undo token returned by apply. The ' +
930
- 'server writes the exact validated preview and rejects source conflicts.',
931
- parameters: {
932
- action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
933
1082
  change_kind: { type: 'string', enum: ['outline', 'asset', 'focus', 'foreshadowing', 'writing_targets'], description: 'Structured domain for a preview.' },
934
1083
  change: { type: 'json', description: 'One direct-tool-shaped change object, required for preview.' },
935
- preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
936
1084
  },
937
1085
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
938
1086
  timeoutMs,
939
- async execute(args, exec) {
940
- if (args.action === 'preview') {
941
- if (!args.change_kind)
942
- throw new Error('preview requires change_kind');
943
- if (args.change === null || typeof args.change !== 'object' || Array.isArray(args.change)) {
944
- throw new Error(`preview requires one change object (received ${describeValue(args.change)})`);
1087
+ isConcurrencySafe: () => false,
1088
+ async execute(args, exec) {
1089
+ switch (args.action) {
1090
+ case 'document': {
1091
+ if (args.step === undefined)
1092
+ throw new Error('step is required for novel_change_plan(action="document")');
1093
+ if (args.step === 'preview' && (!Array.isArray(args.edits) || (args.edits.length < 1 || args.edits.length > 50))) {
1094
+ throw new Error('preview requires between 1 and 50 edits ' +
1095
+ `(received ${describeValue(args.edits)})`);
1096
+ }
1097
+ if (args.step !== 'preview' && !args.preview_token?.trim()) {
1098
+ throw new Error(`${args.step} requires preview_token`);
1099
+ }
1100
+ if (['apply', 'undo'].includes(args.step) && args.confirm !== true)
1101
+ throw new Error('apply/undo requires confirm: true');
1102
+ const client = clientFor(exec);
1103
+ const body = { action: args.step };
1104
+ if (args.confirm !== undefined)
1105
+ body['confirm'] = args.confirm;
1106
+ if (args.path !== undefined)
1107
+ body['path'] = args.path;
1108
+ if (args.edits !== undefined)
1109
+ body['edits'] = args.edits;
1110
+ if (args.preview_token !== undefined)
1111
+ body['preview_token'] = args.preview_token;
1112
+ return await client.postJson('/api/document/change-plan', body, exec.signal);
945
1113
  }
1114
+ case 'structured': {
1115
+ if (args.step === undefined)
1116
+ throw new Error('step is required for novel_change_plan(action="structured")');
1117
+ if (args.step === 'preview') {
1118
+ if (!args.change_kind)
1119
+ throw new Error('preview requires change_kind');
1120
+ if (args.change === null || typeof args.change !== 'object' || Array.isArray(args.change)) {
1121
+ throw new Error(`preview requires one change object (received ${describeValue(args.change)})`);
1122
+ }
1123
+ }
1124
+ else if (!args.preview_token?.trim()) {
1125
+ throw new Error(`${args.step} requires preview_token`);
1126
+ }
1127
+ const client = clientFor(exec);
1128
+ const body = { action: args.step };
1129
+ if (args.change_kind !== undefined)
1130
+ body['change_kind'] = args.change_kind;
1131
+ if (args.change !== undefined)
1132
+ body['change'] = args.change;
1133
+ if (args.preview_token !== undefined)
1134
+ body['preview_token'] = args.preview_token;
1135
+ return await client.postJson('/api/structured/change-plan', body, exec.signal);
1136
+ }
1137
+ default: throw new Error(`novel_change_plan: unknown action ${String(args.action)}`);
946
1138
  }
947
- else if (!args.preview_token?.trim()) {
948
- throw new Error(`${args.action} requires preview_token`);
949
- }
950
- const client = clientFor(exec);
951
- const body = { action: args.action };
952
- if (args.change_kind !== undefined)
953
- body['change_kind'] = args.change_kind;
954
- if (args.change !== undefined)
955
- body['change'] = args.change;
956
- if (args.preview_token !== undefined)
957
- body['preview_token'] = args.preview_token;
958
- return await client.postJson('/api/structured/change-plan', body, exec.signal);
959
1139
  },
960
1140
  }));
961
1141
  ctx.tools.register(defineTool({
962
1142
  name: 'novel_focus',
963
- description: 'Immediate compatibility write for a focus change the author has already authorized exactly; otherwise use novel_structured_change_plan. Set the writing focus compass for the project.',
1143
+ description: 'Immediate compatibility write for a focus change the author has already authorized exactly; otherwise use novel_change_plan(action="structured"). Set the writing focus compass for the project.',
964
1144
  parameters: {
965
1145
  goal: { type: 'string', required: true, description: 'The current writing goal.' },
966
1146
  must_keep: { type: 'array', items: { type: 'string' }, description: 'Constraints that must be kept.' },
@@ -981,547 +1161,435 @@ export function registerNovelTools(ctx, clientFactory, options) {
981
1161
  return await client.postJson('/api/focus', body, exec.signal);
982
1162
  },
983
1163
  }));
984
- ctx.tools.register(defineTool({
985
- name: 'novel_export_preflight',
986
- description: 'Inspect the current manuscript export order, structural defects, writing targets, metadata, review freshness, ' +
987
- 'manuscript acceptance, blockers, and warnings. Pass the returned preflight_revision unchanged to novel_export ' +
988
- 'so the downloaded file is bound to the manuscript state that was inspected.',
989
- parameters: {
990
- format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
991
- purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
992
- },
993
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
994
- timeoutMs,
995
- isConcurrencySafe: () => true,
996
- async execute(args, exec) {
997
- const client = clientFor(exec);
998
- return await client.getJson('/api/export/preflight', {
999
- format: args.format ?? 'md',
1000
- purpose: args.purpose ?? 'delivery',
1001
- }, exec.signal);
1002
- },
1003
- }));
1164
+ // ── export ────────────────────────────────────────────────────────────────────
1004
1165
  ctx.tools.register(defineTool({
1005
1166
  name: 'novel_export',
1006
- description: 'Export the manuscript as a file download and save it locally. Inspect novel_export_preflight first and pass its ' +
1007
- 'preflight_revision unchanged to bind the download to that inspected manuscript state. Returns the saved file path.',
1167
+ description: 'Preflight and run a manuscript export. Preflight validates the selection, purpose and format ' +
1168
+ 'and returns a revision the export must present.',
1008
1169
  parameters: {
1170
+ action: { type: 'string', required: true, enum: ['preflight', 'export'], description: 'Export step; preflight reports what would be written without writing it.' },
1009
1171
  format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
1010
- purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery").' },
1011
- preflight_revision: { type: 'string', description: 'Revision returned by novel_export_preflight for the same format and purpose.' },
1012
- },
1013
- output: {
1014
- schema: {
1015
- type: 'object',
1016
- additionalProperties: false,
1017
- properties: {
1018
- path: { type: 'string', required: true, description: 'Absolute path of the saved export file.' },
1019
- filename: { type: 'string', required: true },
1020
- format: { type: 'string', required: true },
1021
- bytes: { type: 'integer', required: true },
1022
- },
1023
- },
1024
- render: (_args, value) => [{
1025
- type: 'text',
1026
- text: `Exported ${value.filename} (${value.bytes} bytes) to ${value.path}`,
1027
- }],
1028
- },
1029
- timeoutMs,
1030
- async execute(args, exec) {
1031
- const client = clientFor(exec);
1032
- const format = args.format ?? 'md';
1033
- const purpose = args.purpose ?? 'delivery';
1034
- const download = await client.download('/api/export', {
1035
- format,
1036
- purpose,
1037
- ...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
1038
- }, exec.signal);
1039
- const filename = exportFilename(download.filename, `novel.${format}`);
1040
- const exportDir = outputDirFor(outputDir, client);
1041
- await mkdir(exportDir, { recursive: true });
1042
- const path = join(exportDir, filename);
1043
- await writeFile(path, download.content);
1044
- return { path, filename, format, bytes: download.content.byteLength };
1045
- },
1046
- }));
1047
- // ── assets: create / read / packages ────────────────────────────────────
1048
- ctx.tools.register(defineTool({
1049
- name: 'novel_asset_read',
1050
- description: 'Read one structured asset by kind and id, including its relation view.',
1051
- parameters: {
1052
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
1053
- id: { type: 'string', required: true, description: 'Asset id.' },
1054
- },
1055
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1056
- timeoutMs,
1057
- isConcurrencySafe: () => true,
1058
- async execute(args, exec) {
1059
- const client = clientFor(exec);
1060
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
1061
- throw new Error(`invalid asset id "${args.id}"`);
1062
- return await client.getJson(`/api/assets/${args.kind}/${encodeURIComponent(args.id)}`, {}, exec.signal);
1063
- },
1064
- }));
1065
- ctx.tools.register(defineTool({
1066
- name: 'novel_asset_create',
1067
- description: 'Create a structured asset. character/world assets take front-matter fields via `data` plus an optional ' +
1068
- '`body_markdown`; progression assets take their YAML mapping via `data`. Fails with a conflict if the id exists — ' +
1069
- 'use novel_asset_update instead.',
1070
- parameters: {
1071
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
1072
- id: { type: 'string', required: true, description: 'Asset id (letters, digits, "_", "-", ".").' },
1073
- data: { type: 'json', description: 'Object of asset fields (e.g. name, summary, aliases).' },
1074
- body_markdown: { type: 'string', description: 'Markdown body (character/world assets).' },
1172
+ purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
1173
+ preflight_revision: { type: 'string', description: 'Revision returned by novel_export(action="preflight") for the same format and purpose.' },
1075
1174
  },
1076
1175
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1077
1176
  timeoutMs,
1177
+ isConcurrencySafe: args => ['preflight'].includes(args.action),
1078
1178
  async execute(args, exec) {
1079
- const client = clientFor(exec);
1080
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
1081
- throw new Error(`invalid asset id "${args.id}"`);
1082
- const body = { kind: args.kind, id: args.id };
1083
- if (args.data !== undefined)
1084
- body['data'] = args.data;
1085
- if (args.body_markdown !== undefined)
1086
- body['body_markdown'] = args.body_markdown;
1087
- return await client.postJson('/api/assets', body, exec.signal);
1088
- },
1089
- }));
1090
- ctx.tools.register(defineTool({
1091
- name: 'novel_assets_package_export',
1092
- description: 'Export assets as an .owasset.zip package and save it locally. Omit selections to export all assets.',
1093
- parameters: {
1094
- selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
1095
- },
1096
- output: {
1097
- schema: {
1098
- type: 'object',
1099
- additionalProperties: false,
1100
- properties: {
1101
- path: { type: 'string', required: true, description: 'Absolute path of the saved package file.' },
1102
- filename: { type: 'string', required: true },
1103
- bytes: { type: 'integer', required: true },
1104
- },
1105
- },
1106
- render: (_args, value) => [{
1107
- type: 'text',
1108
- text: `Exported asset package ${value.filename} (${value.bytes} bytes) to ${value.path}`,
1109
- }],
1110
- },
1111
- timeoutMs,
1112
- isConcurrencySafe: () => true,
1113
- async execute(args, exec) {
1114
- const client = clientFor(exec);
1115
- const selections = args.selections ?? [];
1116
- for (const item of selections) {
1117
- if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
1118
- throw new Error(`invalid asset selection "${item}"; expected "kind:id"`);
1179
+ switch (args.action) {
1180
+ case 'preflight': {
1181
+ const client = clientFor(exec);
1182
+ return await client.getJson('/api/export/preflight', {
1183
+ format: args.format ?? 'md',
1184
+ purpose: args.purpose ?? 'delivery',
1185
+ }, exec.signal);
1119
1186
  }
1187
+ case 'export': {
1188
+ const client = clientFor(exec);
1189
+ const format = args.format ?? 'md';
1190
+ const purpose = args.purpose ?? 'delivery';
1191
+ const download = await client.download('/api/export', {
1192
+ format,
1193
+ purpose,
1194
+ ...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
1195
+ }, exec.signal);
1196
+ const filename = exportFilename(download.filename, `novel.${format}`);
1197
+ const exportDir = outputDirFor(outputDir, client);
1198
+ await mkdir(exportDir, { recursive: true });
1199
+ const path = join(exportDir, filename);
1200
+ await writeFile(path, download.content);
1201
+ return { path, filename, format, bytes: download.content.byteLength };
1202
+ }
1203
+ default: throw new Error(`novel_export: unknown action ${String(args.action)}`);
1120
1204
  }
1121
- const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
1122
- const filename = exportFilename(download.filename, 'assets.owasset.zip');
1123
- const exportDir = outputDirFor(outputDir, client);
1124
- await mkdir(exportDir, { recursive: true });
1125
- const path = join(exportDir, filename);
1126
- await writeFile(path, download.content);
1127
- return { path, filename, bytes: download.content.byteLength };
1128
- },
1129
- }));
1130
- ctx.tools.register(defineTool({
1131
- name: 'novel_assets_package_preview',
1132
- description: 'Stage an .owasset.zip asset package (base64) and preview its contents and conflicts without importing. ' +
1133
- 'Returns an upload_id to pass to novel_assets_package_import.',
1134
- parameters: {
1135
- package_base64: { type: 'string', required: true, description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
1136
- },
1137
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1138
- timeoutMs,
1139
- async execute(args, exec) {
1140
- const client = clientFor(exec);
1141
- if (!args.package_base64.trim())
1142
- throw new Error('package_base64 must be a non-empty string');
1143
- return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
1144
1205
  },
1145
1206
  }));
1207
+ // ── assets: create / read / packages ────────────────────────────────────
1208
+ // ── asset packages ────────────────────────────────────────────────────────────
1146
1209
  ctx.tools.register(defineTool({
1147
- name: 'novel_assets_package_import',
1148
- description: 'Import a previously staged asset package (see novel_assets_package_preview). Resolves per-asset conflicts ' +
1149
- 'via `resolutions`; set allow_missing_dependencies to skip missing dependency checks.',
1210
+ name: 'novel_assets_package',
1211
+ description: 'Export the project assets into a portable package, preview a package before importing it, and ' +
1212
+ 'import a reviewed package with explicit dependency resolutions.',
1150
1213
  parameters: {
1151
- upload_id: { type: 'string', required: true, description: 'The upload_id returned by novel_assets_package_preview.' },
1152
- package_sha256: { type: 'string', description: 'Optional expected package digest guard.' },
1214
+ action: { type: 'string', required: true, enum: ['export', 'preview', 'import'], description: 'Asset package operation.' },
1215
+ selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
1216
+ package_base64: { type: 'string', description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
1217
+ upload_id: { type: 'string', description: 'The upload_id returned by novel_assets_package(action="preview").' },
1218
+ package_sha256: { type: 'string', description: 'Package digest. Required for import: pass the package_sha256 returned by action="preview"; the server rejects an import without it as a stale package.' },
1153
1219
  resolutions: { type: 'json', description: 'Object mapping conflicting asset keys to resolutions.' },
1154
1220
  allow_missing_dependencies: { type: 'boolean', description: 'Allow import despite missing asset dependencies (default false).' },
1155
1221
  },
1156
1222
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1157
1223
  timeoutMs,
1158
- async execute(args, exec) {
1159
- const client = clientFor(exec);
1160
- const body = { upload_id: args.upload_id };
1161
- if (args.package_sha256 !== undefined)
1162
- body['package_sha256'] = args.package_sha256;
1163
- if (args.resolutions !== undefined)
1164
- body['resolutions'] = args.resolutions;
1165
- if (args.allow_missing_dependencies !== undefined)
1166
- body['allow_missing_dependencies'] = args.allow_missing_dependencies;
1167
- return await client.postJson('/api/assets/package/import', body, exec.signal);
1224
+ // `export` writes a local archive (non-atomic `writeFile` to a fixed name),
1225
+ // so it must be serialized; `preview` is the server-side read-only action.
1226
+ isConcurrencySafe: args => args.action === 'preview',
1227
+ async execute(args, exec) {
1228
+ switch (args.action) {
1229
+ case 'export': {
1230
+ const client = clientFor(exec);
1231
+ const selections = args.selections ?? [];
1232
+ for (const item of selections) {
1233
+ if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
1234
+ throw new Error(`invalid asset selection "${item}"; expected "kind:id"`);
1235
+ }
1236
+ }
1237
+ const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
1238
+ const filename = exportFilename(download.filename, 'assets.owasset.zip');
1239
+ const exportDir = outputDirFor(outputDir, client);
1240
+ await mkdir(exportDir, { recursive: true });
1241
+ const path = join(exportDir, filename);
1242
+ await writeFile(path, download.content);
1243
+ return { path, filename, bytes: download.content.byteLength };
1244
+ }
1245
+ case 'preview': {
1246
+ if (args.package_base64 === undefined)
1247
+ throw new Error('package_base64 is required for novel_assets_package(action="preview")');
1248
+ const client = clientFor(exec);
1249
+ if (!args.package_base64.trim())
1250
+ throw new Error('package_base64 must be a non-empty string');
1251
+ return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
1252
+ }
1253
+ case 'import': {
1254
+ if (args.upload_id === undefined)
1255
+ throw new Error('upload_id is required for novel_assets_package(action="import")');
1256
+ const client = clientFor(exec);
1257
+ // The server treats a missing digest as a stale package, so an
1258
+ // import without the preview digest always fails with
1259
+ // ASSET_PACKAGE_CONFLICT. Require it and name the fix here.
1260
+ if (!args.package_sha256?.trim()) {
1261
+ throw new Error('package_sha256 is required for novel_assets_package(action="import"); pass the package_sha256 returned by action="preview"');
1262
+ }
1263
+ const body = { upload_id: args.upload_id };
1264
+ if (args.package_sha256 !== undefined)
1265
+ body['package_sha256'] = args.package_sha256;
1266
+ if (args.resolutions !== undefined)
1267
+ body['resolutions'] = args.resolutions;
1268
+ if (args.allow_missing_dependencies !== undefined)
1269
+ body['allow_missing_dependencies'] = args.allow_missing_dependencies;
1270
+ return await client.postJson('/api/assets/package/import', body, exec.signal);
1271
+ }
1272
+ default: throw new Error(`novel_assets_package: unknown action ${String(args.action)}`);
1273
+ }
1168
1274
  },
1169
1275
  }));
1170
1276
  // ── revisions ────────────────────────────────────────────────────────────
1277
+ // ── revisions ─────────────────────────────────────────────────────────────────
1171
1278
  ctx.tools.register(defineTool({
1172
- name: 'novel_revisions_list',
1173
- description: 'List revision proposals, optionally filtered by chapter and/or status.',
1279
+ name: 'novel_revision',
1280
+ description: 'List, inspect, create, regenerate, apply and reject revision proposals for a chapter. Create ' +
1281
+ 'builds a proposal from review issues (source=review) or from an explicit text selection ' +
1282
+ '(source=selection); apply writes the accepted hunks into the manuscript.',
1174
1283
  parameters: {
1284
+ action: { type: 'string', required: true, enum: ['list', 'get', 'create', 'regenerate', 'apply', 'reject'], description: 'Revision operation; create reads its source from the source parameter.' },
1175
1285
  chapter: { type: 'string', description: 'Chapter id filter (e.g. "ch_0007").' },
1176
1286
  status: { type: 'string', description: 'Status filter (e.g. "proposed", "applied", "rejected").' },
1177
- },
1178
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1179
- timeoutMs,
1180
- isConcurrencySafe: () => true,
1181
- async execute(args, exec) {
1182
- const client = clientFor(exec);
1183
- const params = {};
1184
- if (args.chapter !== undefined)
1185
- params['chapter'] = args.chapter;
1186
- if (args.status !== undefined)
1187
- params['status'] = args.status;
1188
- return await client.getJson('/api/revisions', params, exec.signal);
1189
- },
1190
- }));
1191
- ctx.tools.register(defineTool({
1192
- name: 'novel_revision_get',
1193
- description: 'Get one revision proposal with its diff hunks by proposal id (rev_*).',
1194
- parameters: {
1195
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1196
- },
1197
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1198
- timeoutMs,
1199
- isConcurrencySafe: () => true,
1200
- async execute(args, exec) {
1201
- const client = clientFor(exec);
1202
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1203
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1204
- return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
1205
- },
1206
- }));
1207
- ctx.tools.register(defineTool({
1208
- name: 'novel_revision_create_selection',
1209
- description: 'Create a revision proposal for a text selection in a chapter (LONG-RUNNING, model-generated). ' +
1210
- 'start/end are character offsets; pass the selected text as original_text. The proposal is only staged — ' +
1211
- 'apply it with novel_revision_apply.',
1212
- parameters: {
1213
- chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1214
- start: { type: 'integer', required: true, description: 'Selection start offset.' },
1215
- end: { type: 'integer', required: true, description: 'Selection end offset.' },
1216
- original_text: { type: 'string', description: 'The selected original text.' },
1217
- instruction: { type: 'string', description: 'Revision instruction (max 4000 characters).' },
1218
- action: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
1219
- target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1220
- full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
1221
- },
1222
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1223
- timeoutMs,
1224
- async execute(args, exec) {
1225
- const client = clientFor(exec);
1226
- if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1227
- throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1228
- const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
1229
- if (args.original_text !== undefined)
1230
- body['original_text'] = args.original_text;
1231
- if (args.instruction !== undefined)
1232
- body['instruction'] = args.instruction;
1233
- if (args.action !== undefined)
1234
- body['action'] = args.action;
1235
- if (args.target_units !== undefined)
1236
- body['target_units'] = args.target_units;
1237
- if (args.full_chapter !== undefined)
1238
- body['full_chapter'] = args.full_chapter;
1239
- return await client.postJson('/api/revisions/selection', body, exec.signal);
1240
- },
1241
- }));
1242
- ctx.tools.register(defineTool({
1243
- name: 'novel_revision_create_from_review',
1244
- description: 'Create a revision proposal that addresses selected issues from a chapter review (LONG-RUNNING, model-generated). ' +
1245
- 'Issue ids come from novel_review_chapter\'s issue_details. The proposal is only staged — apply it with novel_revision_apply.',
1246
- parameters: {
1247
- chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1248
- issue_ids: { type: 'array', required: true, items: { type: 'string' }, description: 'Review issue ids to fix.' },
1287
+ proposal_id: { type: 'string', description: 'Revision proposal id (rev_*).' },
1288
+ source: { type: 'string', enum: ['review', 'selection'], description: 'Create source: review (default) fixes review issues, selection revises an explicit text range.' },
1289
+ chapter_id: { type: 'string', description: 'Chapter id (ch_<digits>).' },
1290
+ issue_ids: { type: 'array', items: { type: 'string' }, description: 'Review issue ids to fix; required for create with source="review".' },
1291
+ selection: { type: 'object', additionalProperties: false, description: 'Optional exact saved-text range in Unicode code points; preserve raw CR/LF characters.', properties: {
1292
+ start: { type: 'integer', description: 'Inclusive source code-point offset.' },
1293
+ end: { type: 'integer', description: 'Exclusive source code-point offset.' },
1294
+ original_text: { type: 'string', required: true, description: 'Exact saved text inside this range.' },
1295
+ } },
1249
1296
  instruction: { type: 'string', description: 'Additional revision instruction.' },
1250
1297
  target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1251
- expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
1252
- expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
1253
- },
1254
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1255
- timeoutMs,
1256
- async execute(args, exec) {
1257
- const client = clientFor(exec);
1258
- if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1259
- throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1260
- if (args.issue_ids.length === 0)
1261
- throw new Error('issue_ids must be a non-empty array');
1262
- const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
1263
- if (args.instruction !== undefined)
1264
- body['instruction'] = args.instruction;
1265
- if (args.target_units !== undefined)
1266
- body['target_units'] = args.target_units;
1267
- if (args.expected_review_revision !== undefined)
1268
- body['expected_review_revision'] = args.expected_review_revision;
1269
- if (args.expected_document_revision !== undefined)
1270
- body['expected_document_revision'] = args.expected_document_revision;
1271
- const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
1272
- try {
1273
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
1274
- }
1275
- catch (error) {
1276
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1277
- }
1278
- },
1279
- }));
1280
- ctx.tools.register(defineTool({
1281
- name: 'novel_revision_apply',
1282
- description: 'Apply a staged revision proposal to the manuscript. Optionally override the generated text or apply only ' +
1283
- 'selected diff hunks. Fails with a conflict if the document changed since the proposal was created.',
1284
- parameters: {
1285
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1286
- replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
1287
- selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
1288
- },
1289
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1290
- timeoutMs,
1291
- async execute(args, exec) {
1292
- const client = clientFor(exec);
1293
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1294
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1295
- const body = {};
1296
- if (args.replacement_text !== undefined)
1297
- body['replacement_text'] = args.replacement_text;
1298
- if (args.selected_hunk_ids !== undefined)
1299
- body['selected_hunk_ids'] = args.selected_hunk_ids;
1300
- const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
1301
- const responseValue = objectValue(response);
1302
- const proposal = objectValue(responseValue['proposal'] ?? response);
1303
- const chapterId = String(proposal['chapter_id'] ?? '');
1304
- try {
1305
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1306
- }
1307
- catch (error) {
1308
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1309
- }
1310
- },
1311
- }));
1312
- ctx.tools.register(defineTool({
1313
- name: 'novel_revision_reject',
1314
- description: 'Reject and discard a staged revision proposal. This permanently drops the generated text — it cannot be ' +
1315
- 'recovered except by regenerating.',
1316
- parameters: {
1317
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1318
- },
1319
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1320
- timeoutMs,
1321
- async execute(args, exec) {
1322
- const client = clientFor(exec);
1323
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1324
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1325
- const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/reject`, {}, exec.signal);
1326
- const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1327
- try {
1328
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1329
- }
1330
- catch (error) {
1331
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1332
- }
1333
- },
1334
- }));
1335
- ctx.tools.register(defineTool({
1336
- name: 'novel_revision_regenerate',
1337
- description: 'Regenerate the text of a staged revision proposal (LONG-RUNNING, model-generated).',
1338
- parameters: {
1339
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1340
- },
1341
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1342
- timeoutMs,
1343
- async execute(args, exec) {
1344
- const client = clientFor(exec);
1345
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1346
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1347
- const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
1348
- const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1349
- try {
1350
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1351
- }
1352
- catch (error) {
1353
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1354
- }
1355
- },
1356
- }));
1357
- // ── background tasks ─────────────────────────────────────────────────────
1358
- ctx.tools.register(defineTool({
1359
- name: 'novel_tasks_list',
1360
- description: 'List background tasks (queued/running/finished AI jobs) with per-status counts.',
1361
- parameters: {
1362
- since: { type: 'string', description: 'ISO timestamp filter for requests whose response was lost.' },
1363
- limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
1364
- },
1365
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1366
- timeoutMs,
1367
- isConcurrencySafe: () => true,
1368
- async execute(args, exec) {
1369
- const client = clientFor(exec);
1370
- const params = {};
1371
- if (args.since !== undefined)
1372
- params['since'] = args.since;
1373
- if (args.limit !== undefined)
1374
- params['limit'] = String(args.limit);
1375
- return await client.getJson('/api/tasks', params, exec.signal);
1376
- },
1377
- }));
1378
- ctx.tools.register(defineTool({
1379
- name: 'novel_task_get',
1380
- description: 'Get one background task with its event log by task id (tsk_*). Completed chapter write/review/revision tasks refresh the chapter-delivery DoG graph; chapter reviews also materialize the 37-dimension graph.',
1381
- parameters: {
1382
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1383
- },
1384
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1385
- timeoutMs,
1386
- async execute(args, exec) {
1387
- const client = clientFor(exec);
1388
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1389
- throw new Error(`invalid task_id "${args.task_id}"`);
1390
- const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
1391
- const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
1392
- ? response
1393
- : {};
1394
- const taskValue = responseRecord['task'] ?? response;
1395
- const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
1396
- ? taskValue
1397
- : {};
1398
- if (taskRecord['status'] !== 'completed')
1399
- return response;
1400
- const taskType = String(taskRecord['type'] ?? '');
1401
- if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
1402
- return response;
1403
- const taskResult = objectValue(taskRecord['result']);
1404
- const taskInput = objectValue(taskRecord['input']);
1405
- const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
1406
- || String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
1407
- || '';
1408
- const value = response !== null && typeof response === 'object' && !Array.isArray(response)
1409
- ? response
1410
- : { task: response };
1411
- if (!/^ch_\d+$/.test(chapterId)) {
1412
- return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
1413
- }
1414
- let workspace;
1415
- try {
1416
- workspace = await client.getJson('/api/workspace', {}, exec.signal);
1417
- }
1418
- catch (error) {
1419
- const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1420
- return taskType === 'chapter_review'
1421
- ? { ...value, dog_review: unavailable, dog_delivery: unavailable }
1422
- : { ...value, dog_delivery: unavailable };
1423
- }
1424
- let dogDelivery;
1425
- try {
1426
- dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
1427
- }
1428
- catch (error) {
1429
- dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1430
- }
1431
- if (taskType !== 'chapter_review')
1432
- return { ...value, dog_delivery: dogDelivery };
1433
- let dogReview;
1434
- try {
1435
- const framework = await client.getJson('/api/review/framework', {}, exec.signal);
1436
- dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, 70, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
1437
- }
1438
- catch (error) {
1439
- dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1440
- }
1441
- return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
1442
- },
1443
- }));
1444
- ctx.tools.register(defineTool({
1445
- name: 'novel_task_create',
1446
- description: 'Queue a background AI task and return immediately; poll with novel_task_get. Task types and their `input` keys: ' +
1447
- 'chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
1448
- 'chapter_review {chapter_id | path, strict?, dimensions?}; ' +
1449
- 'revision_selection / revision_from_review (same inputs as the novel_revision_create_* tools); ' +
1450
- 'source_operation / reference_operation (same inputs as novel_source_action / novel_reference_library_action); ' +
1451
- 'manuscript_import (same input as novel_import); research {prompt, search?, language?, quality?, llm?}; ' +
1452
- 'manuscript_reconcile (durable acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); ' +
1453
- 'settle_backfill {chapter_ids?, only_missing?} — prefer the novel_settle_backfill convenience tool; ' +
1454
- 'continuous_write — prefer the novel_multi_write convenience tool.',
1455
- parameters: {
1456
- type: {
1457
- type: 'string',
1458
- required: true,
1459
- enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
1460
- description: 'Task type.',
1461
- },
1462
- input: { type: 'json', required: true, description: 'Task input object; shape depends on the task type (see description).' },
1463
- },
1464
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1465
- timeoutMs,
1466
- async execute(args, exec) {
1467
- const client = clientFor(exec);
1468
- if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
1469
- throw new Error('input must be a JSON object');
1470
- }
1471
- return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
1472
- },
1473
- }));
1474
- ctx.tools.register(defineTool({
1475
- name: 'novel_task_cancel',
1476
- description: 'Request cancellation of a queued or running task. Check cancelled and cancellation_status for the actual outcome; poll novel_task_get while cancellation is requested.',
1477
- parameters: {
1478
- wait_seconds: { type: 'number', description: 'Wait up to 30 seconds for actual cancellation; running model calls may finish later.' },
1479
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1480
- },
1481
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1482
- timeoutMs,
1483
- async execute(args, exec) {
1484
- const client = clientFor(exec);
1485
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1486
- throw new Error(`invalid task_id "${args.task_id}"`);
1487
- return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, { wait_seconds: args.wait_seconds ?? 0 }, exec.signal);
1488
- },
1489
- }));
1490
- ctx.tools.register(defineTool({
1491
- name: 'novel_task_retry',
1492
- description: 'Retry a failed or cancelled background task.',
1493
- parameters: {
1494
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1298
+ expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
1299
+ expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
1300
+ start: { type: 'integer', description: 'Selection start offset.' },
1301
+ end: { type: 'integer', description: 'Selection end offset.' },
1302
+ original_text: { type: 'string', description: 'The selected original text.' },
1303
+ mode: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
1304
+ full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
1305
+ replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
1306
+ selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
1495
1307
  },
1496
1308
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1497
1309
  timeoutMs,
1310
+ isConcurrencySafe: args => ['list', 'get'].includes(args.action),
1498
1311
  async execute(args, exec) {
1499
- const client = clientFor(exec);
1500
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1501
- throw new Error(`invalid task_id "${args.task_id}"`);
1502
- return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/retry`, {}, exec.signal);
1312
+ switch (args.action) {
1313
+ case 'list': {
1314
+ const client = clientFor(exec);
1315
+ const params = {};
1316
+ if (args.chapter !== undefined)
1317
+ params['chapter'] = args.chapter;
1318
+ if (args.status !== undefined)
1319
+ params['status'] = args.status;
1320
+ return await client.getJson('/api/revisions', params, exec.signal);
1321
+ }
1322
+ case 'get': {
1323
+ if (args.proposal_id === undefined)
1324
+ throw new Error('proposal_id is required for novel_revision(action="get")');
1325
+ const client = clientFor(exec);
1326
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1327
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1328
+ return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
1329
+ }
1330
+ case 'create': {
1331
+ if (args.chapter_id === undefined)
1332
+ throw new Error('chapter_id is required for novel_revision(action="create")');
1333
+ // source=selection keeps the pre-merge create-from-selection contract;
1334
+ // anything else (including an omitted source) stays on from-review.
1335
+ if (args.source === 'selection') {
1336
+ if (args.chapter_id === undefined)
1337
+ throw new Error('chapter_id is required for novel_revision(action="create", source="selection")');
1338
+ if (args.start === undefined)
1339
+ throw new Error('start is required for novel_revision(action="create", source="selection")');
1340
+ if (args.end === undefined)
1341
+ throw new Error('end is required for novel_revision(action="create", source="selection")');
1342
+ const client = clientFor(exec);
1343
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1344
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1345
+ const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
1346
+ if (args.original_text !== undefined)
1347
+ body['original_text'] = args.original_text;
1348
+ if (args.expected_document_revision !== undefined)
1349
+ body['expected_document_revision'] = args.expected_document_revision;
1350
+ if (args.instruction !== undefined)
1351
+ body['instruction'] = args.instruction;
1352
+ if (args.mode !== undefined)
1353
+ body['action'] = args.mode;
1354
+ if (args.target_units !== undefined)
1355
+ body['target_units'] = args.target_units;
1356
+ if (args.full_chapter !== undefined)
1357
+ body['full_chapter'] = args.full_chapter;
1358
+ return await client.postJson('/api/revisions/selection', body, exec.signal);
1359
+ }
1360
+ if (args.source !== undefined && args.source !== 'review' && args.source !== 'selection') {
1361
+ throw new Error(`source must be "review" or "selection", got "${args.source}"`);
1362
+ }
1363
+ if (args.issue_ids === undefined)
1364
+ throw new Error('issue_ids is required for novel_revision(action="create", source="review")');
1365
+ const client = clientFor(exec);
1366
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1367
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1368
+ if (args.issue_ids.length === 0)
1369
+ throw new Error('issue_ids must be a non-empty array');
1370
+ const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
1371
+ if (args.selection !== undefined)
1372
+ body['selection'] = args.selection;
1373
+ if (args.instruction !== undefined)
1374
+ body['instruction'] = args.instruction;
1375
+ if (args.target_units !== undefined)
1376
+ body['target_units'] = args.target_units;
1377
+ if (args.expected_review_revision !== undefined)
1378
+ body['expected_review_revision'] = args.expected_review_revision;
1379
+ if (args.expected_document_revision !== undefined)
1380
+ body['expected_document_revision'] = args.expected_document_revision;
1381
+ const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
1382
+ try {
1383
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
1384
+ }
1385
+ catch (error) {
1386
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1387
+ }
1388
+ }
1389
+ case 'regenerate': {
1390
+ if (args.proposal_id === undefined)
1391
+ throw new Error('proposal_id is required for novel_revision(action="regenerate")');
1392
+ const client = clientFor(exec);
1393
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1394
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1395
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
1396
+ const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1397
+ try {
1398
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1399
+ }
1400
+ catch (error) {
1401
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1402
+ }
1403
+ }
1404
+ case 'apply': {
1405
+ if (args.proposal_id === undefined)
1406
+ throw new Error('proposal_id is required for novel_revision(action="apply")');
1407
+ const client = clientFor(exec);
1408
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1409
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1410
+ const body = {};
1411
+ if (args.replacement_text !== undefined)
1412
+ body['replacement_text'] = args.replacement_text;
1413
+ if (args.selected_hunk_ids !== undefined)
1414
+ body['selected_hunk_ids'] = args.selected_hunk_ids;
1415
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
1416
+ const responseValue = objectValue(response);
1417
+ const proposal = objectValue(responseValue['proposal'] ?? response);
1418
+ const chapterId = String(proposal['chapter_id'] ?? '');
1419
+ try {
1420
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1421
+ }
1422
+ catch (error) {
1423
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1424
+ }
1425
+ }
1426
+ case 'reject': {
1427
+ if (args.proposal_id === undefined)
1428
+ throw new Error('proposal_id is required for novel_revision(action="reject")');
1429
+ const client = clientFor(exec);
1430
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1431
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1432
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/reject`, {}, exec.signal);
1433
+ const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1434
+ try {
1435
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1436
+ }
1437
+ catch (error) {
1438
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1439
+ }
1440
+ }
1441
+ default: throw new Error(`novel_revision: unknown action ${String(args.action)}`);
1442
+ }
1503
1443
  },
1504
1444
  }));
1505
- ctx.tools.register(defineTool({
1506
- name: 'novel_task_confirm',
1507
- description: 'Confirm a background task that is awaiting confirmation (e.g. a continuous_write run paused between chapters), letting it resume.',
1508
- parameters: {
1509
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1445
+ // ── background tasks ─────────────────────────────────────────────────────
1446
+ // ── background tasks ──────────────────────────────────────────────────────────
1447
+ ctx.tools.register(defineTool({
1448
+ name: 'novel_task',
1449
+ description: 'Queue a background AI task and return immediately; poll with get or list. Task types and their ' +
1450
+ '`input` keys: chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
1451
+ 'chapter_review {chapter_id | path, strict?, dimensions?}; revision_selection / revision_from_review ' +
1452
+ '(same inputs as the novel_revision create action); source_operation / reference_operation (same ' +
1453
+ 'inputs as novel_source_action / novel_reference_library_action); manuscript_import (same input as ' +
1454
+ 'novel_import); research {prompt, search?, language?, quality? (advisory|balanced|strict), llm?}; manuscript_reconcile (durable ' +
1455
+ 'acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); settle_backfill ' +
1456
+ '{chapter_ids?, only_missing?} - prefer the novel_settle_backfill convenience tool; continuous_write ' +
1457
+ '- prefer the novel_multi_write convenience tool.',
1458
+ parameters: {
1459
+ action: { type: 'string', required: true, enum: ['create', 'get', 'list', 'cancel', 'confirm', 'retry'], description: 'Task operation; long-running work returns a task id immediately. confirm resumes a paused run and returns a continuation task: poll the returned task_id; re-confirming the paused task is idempotent.' },
1460
+ type: {
1461
+ type: 'string',
1462
+ enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
1463
+ description: 'Task type; required for create.',
1464
+ },
1465
+ input: { type: 'json', description: 'Task input object; required for create. Shape depends on the task type (see description).' },
1466
+ task_id: { type: 'string', description: 'Task id (tsk_*).' },
1467
+ since: { type: 'string', description: 'ISO timestamp filter for requests whose response was lost.' },
1468
+ limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
1469
+ wait_seconds: { type: 'number', description: 'Wait up to 30 seconds for actual cancellation; running model calls may finish later.' },
1510
1470
  },
1511
1471
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1512
1472
  timeoutMs,
1473
+ isConcurrencySafe: args => ['list'].includes(args.action),
1513
1474
  async execute(args, exec) {
1514
- const client = clientFor(exec);
1515
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1516
- throw new Error(`invalid task_id "${args.task_id}"`);
1517
- return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/confirm`, {}, exec.signal);
1475
+ switch (args.action) {
1476
+ case 'create': {
1477
+ if (args.type === undefined)
1478
+ throw new Error('type is required for novel_task(action="create")');
1479
+ if (args.input === undefined)
1480
+ throw new Error('input is required for novel_task(action="create")');
1481
+ const client = clientFor(exec);
1482
+ if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
1483
+ throw new Error('input must be a JSON object');
1484
+ }
1485
+ return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
1486
+ }
1487
+ case 'get': {
1488
+ if (args.task_id === undefined)
1489
+ throw new Error('task_id is required for novel_task(action="get")');
1490
+ const client = clientFor(exec);
1491
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1492
+ throw new Error(`invalid task_id "${args.task_id}"`);
1493
+ const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
1494
+ const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
1495
+ ? response
1496
+ : {};
1497
+ const taskValue = responseRecord['task'] ?? response;
1498
+ const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
1499
+ ? taskValue
1500
+ : {};
1501
+ if (taskRecord['status'] !== 'completed')
1502
+ return response;
1503
+ const taskType = String(taskRecord['type'] ?? '');
1504
+ if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
1505
+ return response;
1506
+ const taskResult = objectValue(taskRecord['result']);
1507
+ const taskInput = objectValue(taskRecord['input']);
1508
+ const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
1509
+ || String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
1510
+ || '';
1511
+ const value = response !== null && typeof response === 'object' && !Array.isArray(response)
1512
+ ? response
1513
+ : { task: response };
1514
+ if (!/^ch_\d+$/.test(chapterId)) {
1515
+ return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
1516
+ }
1517
+ let workspace;
1518
+ try {
1519
+ workspace = await client.getJson('/api/workspace', {}, exec.signal);
1520
+ }
1521
+ catch (error) {
1522
+ const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1523
+ return taskType === 'chapter_review'
1524
+ ? { ...value, dog_review: unavailable, dog_delivery: unavailable }
1525
+ : { ...value, dog_delivery: unavailable };
1526
+ }
1527
+ let dogDelivery;
1528
+ try {
1529
+ dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
1530
+ }
1531
+ catch (error) {
1532
+ dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1533
+ }
1534
+ if (taskType !== 'chapter_review')
1535
+ return { ...value, dog_delivery: dogDelivery };
1536
+ let dogReview;
1537
+ try {
1538
+ const framework = await client.getJson('/api/review/framework', {}, exec.signal);
1539
+ // Pass the threshold through rather than hardcoding 70: the
1540
+ // manifest's threshold feeds deliveryStatus, so a caller who
1541
+ // asked for another value must not silently get the default.
1542
+ // `undefined` makes the materializer read it back from the
1543
+ // persisted delivery.json, exactly as the direct path does.
1544
+ dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, undefined, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
1545
+ }
1546
+ catch (error) {
1547
+ dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1548
+ }
1549
+ return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
1550
+ }
1551
+ case 'list': {
1552
+ const client = clientFor(exec);
1553
+ const params = {};
1554
+ if (args.since !== undefined)
1555
+ params['since'] = args.since;
1556
+ if (args.limit !== undefined)
1557
+ params['limit'] = String(args.limit);
1558
+ return await client.getJson('/api/tasks', params, exec.signal);
1559
+ }
1560
+ case 'cancel': {
1561
+ if (args.task_id === undefined)
1562
+ throw new Error('task_id is required for novel_task(action="cancel")');
1563
+ const client = clientFor(exec);
1564
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1565
+ throw new Error(`invalid task_id "${args.task_id}"`);
1566
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, { wait_seconds: args.wait_seconds ?? 0 }, exec.signal);
1567
+ }
1568
+ case 'confirm': {
1569
+ if (args.task_id === undefined)
1570
+ throw new Error('task_id is required for novel_task(action="confirm")');
1571
+ const client = clientFor(exec);
1572
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1573
+ throw new Error(`invalid task_id "${args.task_id}"`);
1574
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/confirm`, {}, exec.signal);
1575
+ }
1576
+ case 'retry': {
1577
+ if (args.task_id === undefined)
1578
+ throw new Error('task_id is required for novel_task(action="retry")');
1579
+ const client = clientFor(exec);
1580
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1581
+ throw new Error(`invalid task_id "${args.task_id}"`);
1582
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/retry`, {}, exec.signal);
1583
+ }
1584
+ default: throw new Error(`novel_task: unknown action ${String(args.action)}`);
1585
+ }
1518
1586
  },
1519
1587
  }));
1520
1588
  ctx.tools.register(defineTool({
1521
1589
  name: 'novel_multi_write',
1522
1590
  description: 'Queue a BACKGROUND continuous-writing run: writes up to max_chapters chapters following the outline, ' +
1523
1591
  'reviewing each; stops early on review blockers, continuity errors, low scores, or budget limits. ' +
1524
- 'Returns immediately with a task id — poll with novel_task_get and resume paused runs with novel_task_confirm.',
1592
+ 'Returns immediately with a task id — poll with novel_task(action="get"). A paused run is resumed with novel_task(action="confirm"), which returns a continuation task: poll that returned task_id. Confirming the same paused task again is idempotent.',
1525
1593
  parameters: {
1526
1594
  max_chapters: { type: 'integer', description: 'Chapters to write, 1-10 (default 1).' },
1527
1595
  minimum_review_score: { type: 'integer', description: 'Stop when a chapter scores below this, 0-100 (default 82).' },
@@ -1533,7 +1601,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1533
1601
  temperature: { type: 'number', description: 'Sampling temperature (default 0.7).' },
1534
1602
  stop_on_blocker: { type: 'boolean', description: 'Stop when a review reports a blocker issue (default true).' },
1535
1603
  stop_on_continuity_error: { type: 'boolean', description: 'Stop on high-severity continuity issues (default true).' },
1536
- require_confirmation_after_each_chapter: { type: 'boolean', description: 'Pause for novel_task_confirm after each chapter (default false).' },
1604
+ require_confirmation_after_each_chapter: { type: 'boolean', description: 'Pause for novel_task(action="confirm") after each chapter (default false).' },
1537
1605
  },
1538
1606
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1539
1607
  timeoutMs,
@@ -1566,71 +1634,130 @@ export function registerNovelTools(ctx, clientFactory, options) {
1566
1634
  },
1567
1635
  }));
1568
1636
  // ── project lifecycle ────────────────────────────────────────────────────
1637
+ // ── projects ──────────────────────────────────────────────────────────────────
1569
1638
  ctx.tools.register(defineTool({
1570
- name: 'novel_project_init',
1571
- description: 'Create a new OpenWrite novel project on the server machine and make it the active project. ' +
1572
- 'Works without a currently open project. novel_id must be 2-64 chars of letters/digits/"-"/"_".',
1639
+ name: 'novel_project',
1640
+ description: 'Initialize, open and delete a novel project, and manage its archive packages (list, get, ' +
1641
+ 'create, restore preview, restore, download). Archiving never touches the live project.',
1573
1642
  parameters: {
1574
- novel_id: { type: 'string', required: true, description: 'Project id, e.g. "my_novel" (2-64 chars: letters, digits, "-", "_").' },
1575
- title: { type: 'string', required: true, description: 'Book title (max 120 characters).' },
1643
+ action: { type: 'string', required: true, enum: ['init', 'open', 'delete', 'archive', 'download'], description: 'Project operation.' },
1644
+ novel_id: { type: 'string', description: 'Project id, e.g. "my_novel" (2-64 chars: letters, digits, "-", "_").' },
1645
+ title: { type: 'string', description: 'Book title (max 120 characters).' },
1576
1646
  author: { type: 'string', description: 'Author credit for delivery export (max 120 characters).' },
1577
1647
  language: { type: 'string', description: 'Book language tag, e.g. zh-CN or en (default zh-CN).' },
1578
1648
  project_path: { type: 'string', description: 'Target directory on the server; omit to use the server default location.' },
1579
1649
  template: { type: 'string', enum: ['default', 'demo_short'], description: 'Project template (default "default").' },
1650
+ confirm_novel_id: { type: 'string', description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
1651
+ archive_op: { type: 'string', enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle operation.' },
1652
+ archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
1653
+ expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
1654
+ target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
1655
+ target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
1656
+ reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
1657
+ archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
1658
+ confirm: { type: 'boolean', description: 'Must be true for restore.' },
1580
1659
  },
1581
1660
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1582
1661
  timeoutMs,
1583
- async execute(args, exec) {
1584
- const client = clientFor(exec);
1585
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(args.novel_id)) {
1586
- throw new Error('novel_id must be 2-64 chars of letters, digits, "-" or "_"');
1662
+ isConcurrencySafe: () => false,
1663
+ async execute(args, exec) {
1664
+ switch (args.action) {
1665
+ case 'init': {
1666
+ if (args.novel_id === undefined)
1667
+ throw new Error('novel_id is required for novel_project(action="init")');
1668
+ if (args.title === undefined)
1669
+ throw new Error('title is required for novel_project(action="init")');
1670
+ const client = clientFor(exec);
1671
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(args.novel_id)) {
1672
+ throw new Error('novel_id must be 2-64 chars of letters, digits, "-" or "_"');
1673
+ }
1674
+ if (!args.title.trim() || args.title.length > 120)
1675
+ throw new Error('title must be non-empty and at most 120 characters');
1676
+ const body = { novel_id: args.novel_id, title: args.title };
1677
+ if (args.author !== undefined)
1678
+ body['author'] = args.author;
1679
+ if (args.language !== undefined)
1680
+ body['language'] = args.language;
1681
+ if (args.project_path !== undefined)
1682
+ body['project_path'] = args.project_path;
1683
+ if (args.template !== undefined)
1684
+ body['template'] = args.template;
1685
+ return await client.postJson('/api/project/init', body, exec.signal);
1686
+ }
1687
+ case 'open': {
1688
+ if (args.project_path === undefined)
1689
+ throw new Error('project_path is required for novel_project(action="open")');
1690
+ const client = clientFor(exec);
1691
+ if (!args.project_path.trim())
1692
+ throw new Error('project_path must be a non-empty string');
1693
+ return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
1694
+ }
1695
+ case 'delete': {
1696
+ if (args.project_path === undefined)
1697
+ throw new Error('project_path is required for novel_project(action="delete")');
1698
+ if (args.confirm_novel_id === undefined)
1699
+ throw new Error('confirm_novel_id is required for novel_project(action="delete")');
1700
+ const client = clientFor(exec);
1701
+ if (!args.project_path.trim() || !args.confirm_novel_id.trim())
1702
+ throw new Error('project_path and confirm are required');
1703
+ return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm_novel_id }, exec.signal);
1704
+ }
1705
+ case 'archive': {
1706
+ if (args.archive_op === undefined)
1707
+ throw new Error('archive_op is required for novel_project(action="archive")');
1708
+ const client = clientFor(exec);
1709
+ if (args.archive_op === 'preflight')
1710
+ return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
1711
+ if (args.archive_op === 'list')
1712
+ return await client.getJson('/api/project-archives', {}, exec.signal);
1713
+ if (args.archive_op === 'create') {
1714
+ if (!args.expected_preflight_revision?.trim())
1715
+ throw new Error('create requires expected_preflight_revision');
1716
+ return await client.postJson('/api/project-archives/create', {
1717
+ expected_preflight_revision: args.expected_preflight_revision,
1718
+ }, exec.signal);
1719
+ }
1720
+ const archiveId = args.archive_id?.trim();
1721
+ if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
1722
+ throw new Error(`${args.archive_op} requires a valid archive_id`);
1723
+ if (args.archive_op === 'get')
1724
+ return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
1725
+ if (!args.target_root?.trim())
1726
+ throw new Error(`${args.archive_op} requires target_root`);
1727
+ const body = {
1728
+ archive_id: archiveId,
1729
+ target_root: args.target_root,
1730
+ reference_policy: args.reference_policy ?? 'preserve_relative',
1731
+ };
1732
+ if (args.target_novel_id !== undefined)
1733
+ body['target_novel_id'] = args.target_novel_id;
1734
+ if (args.archive_op === 'restore_preview') {
1735
+ return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
1736
+ }
1737
+ if (!args.archive_sha256?.trim())
1738
+ throw new Error('restore requires archive_sha256');
1739
+ if (args.confirm !== true)
1740
+ throw new Error('restore requires confirm=true');
1741
+ body['archive_sha256'] = args.archive_sha256;
1742
+ body['confirm'] = true;
1743
+ return await client.postJson('/api/project-archives/restore', body, exec.signal);
1744
+ }
1745
+ case 'download': {
1746
+ if (args.archive_id === undefined)
1747
+ throw new Error('archive_id is required for novel_project(action="download")');
1748
+ if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
1749
+ throw new Error('archive_id must match owa_<24 lowercase hex>');
1750
+ const client = clientFor(exec);
1751
+ const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
1752
+ const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
1753
+ const exportDir = outputDirFor(outputDir, client);
1754
+ await mkdir(exportDir, { recursive: true });
1755
+ const path = join(exportDir, filename);
1756
+ await writeFile(path, download.content);
1757
+ return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
1758
+ }
1759
+ default: throw new Error(`novel_project: unknown action ${String(args.action)}`);
1587
1760
  }
1588
- if (!args.title.trim() || args.title.length > 120)
1589
- throw new Error('title must be non-empty and at most 120 characters');
1590
- const body = { novel_id: args.novel_id, title: args.title };
1591
- if (args.author !== undefined)
1592
- body['author'] = args.author;
1593
- if (args.language !== undefined)
1594
- body['language'] = args.language;
1595
- if (args.project_path !== undefined)
1596
- body['project_path'] = args.project_path;
1597
- if (args.template !== undefined)
1598
- body['template'] = args.template;
1599
- return await client.postJson('/api/project/init', body, exec.signal);
1600
- },
1601
- }));
1602
- ctx.tools.register(defineTool({
1603
- name: 'novel_project_open',
1604
- description: 'Switch the Studio server to another existing project directory (must contain novel_config.yaml). ' +
1605
- 'Works without a currently open project; afterwards every other novel_* tool targets the newly opened project. ' +
1606
- 'The server rejects the switch while a background task is active.',
1607
- parameters: {
1608
- project_path: { type: 'string', required: true, description: 'Absolute project directory path on the server machine.' },
1609
- },
1610
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1611
- timeoutMs,
1612
- async execute(args, exec) {
1613
- const client = clientFor(exec);
1614
- if (!args.project_path.trim())
1615
- throw new Error('project_path must be a non-empty string');
1616
- return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
1617
- },
1618
- }));
1619
- ctx.tools.register(defineTool({
1620
- name: 'novel_project_delete',
1621
- description: 'PERMANENTLY delete a project directory and all its contents from the server machine (recursive delete, ' +
1622
- 'not recoverable). The server requires confirm to exactly equal the project\'s novel_id as a safeguard.',
1623
- parameters: {
1624
- project_path: { type: 'string', required: true, description: 'Project directory to delete.' },
1625
- confirm: { type: 'string', required: true, description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
1626
- },
1627
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1628
- timeoutMs,
1629
- async execute(args, exec) {
1630
- const client = clientFor(exec);
1631
- if (!args.project_path.trim() || !args.confirm.trim())
1632
- throw new Error('project_path and confirm are required');
1633
- return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm }, exec.signal);
1634
1761
  },
1635
1762
  }));
1636
1763
  // ── chapters, documents, import, sync ────────────────────────────────────
@@ -1638,12 +1765,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
1638
1765
  name: 'novel_chapter_delete',
1639
1766
  description: 'Delete the LATEST manuscript chapter. The server enforces three safeguards: the path must be a chapter under ' +
1640
1767
  'data/manuscript, confirm must exactly equal the chapter id (e.g. "ch_0007"), and version must equal the ' +
1641
- 'document version fingerprint from a fresh novel_doc_read. Only the newest chapter can be deleted, and never ' +
1768
+ 'document version fingerprint from a fresh novel_doc(action="read"). Only the newest chapter can be deleted, and never ' +
1642
1769
  'while a background task is active.',
1643
1770
  parameters: {
1644
1771
  path: { type: 'string', required: true, description: 'Chapter path, e.g. "data/manuscript/ch_0007.md".' },
1645
1772
  confirm: { type: 'string', required: true, description: 'Must exactly equal the chapter id, e.g. "ch_0007".' },
1646
- version: { type: 'string', required: true, description: 'The document version fingerprint from novel_doc_read.' },
1773
+ version: { type: 'string', required: true, description: 'The document version fingerprint from novel_doc(action="read").' },
1647
1774
  },
1648
1775
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1649
1776
  timeoutMs,
@@ -1661,7 +1788,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1661
1788
  parameters: {
1662
1789
  keep_through: { type: 'string', required: true, description: 'Last chapter to retain, e.g. "ch_0030".' },
1663
1790
  confirm: { type: 'string', required: true, description: 'Must exactly equal DELETE_AFTER:<keep_through>.' },
1664
- version: { type: 'string', required: true, description: 'The latest chapter version fingerprint from a fresh novel_doc_read.' },
1791
+ version: { type: 'string', required: true, description: 'The latest chapter version fingerprint from a fresh novel_doc(action="read").' },
1665
1792
  },
1666
1793
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1667
1794
  timeoutMs,
@@ -1670,32 +1797,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
1670
1797
  return await client.postJson('/api/chapter/delete-batch', { keep_through: args.keep_through, confirm: args.confirm, version: args.version }, exec.signal);
1671
1798
  },
1672
1799
  }));
1800
+ // ── manuscript import ─────────────────────────────────────────────────────────
1673
1801
  ctx.tools.register(defineTool({
1674
- name: 'novel_doc_create',
1675
- description: 'Create a new project document by kind and name (e.g. a setting or character note), then returns the created document.',
1676
- parameters: {
1677
- kind: { type: 'string', required: true, description: 'Document kind (e.g. "setting", "character").' },
1678
- name: { type: 'string', required: true, description: 'Document name.' },
1679
- description: { type: 'string', description: 'Short description of the document.' },
1680
- },
1681
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1682
- timeoutMs,
1683
- async execute(args, exec) {
1684
- const client = clientFor(exec);
1685
- if (!args.kind.trim() || !args.name.trim())
1686
- throw new Error('kind and name must be non-empty strings');
1687
- const body = { kind: args.kind, name: args.name };
1688
- if (args.description !== undefined)
1689
- body['description'] = args.description;
1690
- return await client.postJson('/api/document/create', body, exec.signal);
1691
- },
1692
- }));
1693
- ctx.tools.register(defineTool({
1694
- name: 'novel_import_preview',
1695
- description: 'Preview a TXT/Markdown manuscript import without writing anything: detected chapters, numbering, and ' +
1696
- 'conflicts with existing chapters. Run this before novel_import.',
1802
+ name: 'novel_import',
1803
+ description: 'Preview an imported manuscript and then import it. Preview reports the parsed chapters, arc ' +
1804
+ 'mapping and conflicts without writing; import applies the reviewed plan.',
1697
1805
  parameters: {
1698
- content: { type: 'string', required: true, description: 'The manuscript text to import.' },
1806
+ action: { type: 'string', required: true, enum: ['preview', 'import'], description: 'Import step; preview never writes anything.' },
1807
+ content: { type: 'string', description: 'The manuscript text to import.' },
1699
1808
  filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1700
1809
  arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1701
1810
  start_number: { type: 'integer', description: 'First chapter number (default: after the last existing chapter).' },
@@ -1703,50 +1812,45 @@ export function registerNovelTools(ctx, clientFactory, options) {
1703
1812
  },
1704
1813
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1705
1814
  timeoutMs,
1706
- isConcurrencySafe: () => true,
1707
- async execute(args, exec) {
1708
- const client = clientFor(exec);
1709
- if (!args.content.trim())
1710
- throw new Error('content must be a non-empty string');
1711
- const body = { content: args.content };
1712
- if (args.filename !== undefined)
1713
- body['filename'] = args.filename;
1714
- if (args.arc_id !== undefined)
1715
- body['arc_id'] = args.arc_id;
1716
- if (args.start_number !== undefined)
1717
- body['start_number'] = args.start_number;
1718
- if (args.force !== undefined)
1719
- body['force'] = args.force;
1720
- return await client.postJson('/api/import/preview', body, exec.signal);
1721
- },
1722
- }));
1723
- ctx.tools.register(defineTool({
1724
- name: 'novel_import',
1725
- description: 'Import a TXT/Markdown manuscript as chapters (split on headings). Preview first with novel_import_preview; ' +
1726
- 'the server rejects conflicting chapter ids unless force is set.',
1727
- parameters: {
1728
- content: { type: 'string', required: true, description: 'The manuscript text to import.' },
1729
- filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1730
- arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1731
- start_number: { type: 'integer', description: 'First chapter number (default: continue numbering).' },
1732
- force: { type: 'boolean', description: 'Overwrite conflicting existing chapters (default false).' },
1733
- },
1734
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1735
- timeoutMs,
1736
- async execute(args, exec) {
1737
- const client = clientFor(exec);
1738
- if (!args.content.trim())
1739
- throw new Error('content must be a non-empty string');
1740
- const body = { content: args.content };
1741
- if (args.filename !== undefined)
1742
- body['filename'] = args.filename;
1743
- if (args.arc_id !== undefined)
1744
- body['arc_id'] = args.arc_id;
1745
- if (args.start_number !== undefined)
1746
- body['start_number'] = args.start_number;
1747
- if (args.force !== undefined)
1748
- body['force'] = args.force;
1749
- return await client.postJson('/api/import', body, exec.signal);
1815
+ isConcurrencySafe: args => ['preview'].includes(args.action),
1816
+ async execute(args, exec) {
1817
+ switch (args.action) {
1818
+ case 'preview': {
1819
+ if (args.content === undefined)
1820
+ throw new Error('content is required for novel_import(action="preview")');
1821
+ const client = clientFor(exec);
1822
+ if (!args.content.trim())
1823
+ throw new Error('content must be a non-empty string');
1824
+ const body = { content: args.content };
1825
+ if (args.filename !== undefined)
1826
+ body['filename'] = args.filename;
1827
+ if (args.arc_id !== undefined)
1828
+ body['arc_id'] = args.arc_id;
1829
+ if (args.start_number !== undefined)
1830
+ body['start_number'] = args.start_number;
1831
+ if (args.force !== undefined)
1832
+ body['force'] = args.force;
1833
+ return await client.postJson('/api/import/preview', body, exec.signal);
1834
+ }
1835
+ case 'import': {
1836
+ if (args.content === undefined)
1837
+ throw new Error('content is required for novel_import(action="import")');
1838
+ const client = clientFor(exec);
1839
+ if (!args.content.trim())
1840
+ throw new Error('content must be a non-empty string');
1841
+ const body = { content: args.content };
1842
+ if (args.filename !== undefined)
1843
+ body['filename'] = args.filename;
1844
+ if (args.arc_id !== undefined)
1845
+ body['arc_id'] = args.arc_id;
1846
+ if (args.start_number !== undefined)
1847
+ body['start_number'] = args.start_number;
1848
+ if (args.force !== undefined)
1849
+ body['force'] = args.force;
1850
+ return await client.postJson('/api/import', body, exec.signal);
1851
+ }
1852
+ default: throw new Error(`novel_import: unknown action ${String(args.action)}`);
1853
+ }
1750
1854
  },
1751
1855
  }));
1752
1856
  ctx.tools.register(defineTool({
@@ -1762,7 +1866,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1762
1866
  limit: { type: 'integer', description: 'Maximum operations for list (default 50).' },
1763
1867
  filename: { type: 'string', description: 'Source filename ending in .txt, .md, or .markdown.' },
1764
1868
  content: { type: 'string', description: 'The author\'s own manuscript text for prepare.' },
1765
- arc_id: { type: 'string', description: 'Target arc id, e.g. arc_001.' },
1869
+ arc_id: { type: 'string', description: 'Target arc id, e.g. arc_001. Optional for prepare; defaults to the project\'s current arc.' },
1766
1870
  start_number: { type: 'integer', description: 'Optional first chapter number.' },
1767
1871
  expected_preview_revision: { type: 'string', description: 'Current preview revision required by structure/confirm.' },
1768
1872
  chapters: { type: 'array', items: { type: 'json' }, description: 'Edited chapter objects: chapter_id, title, content.' },
@@ -1779,9 +1883,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
1779
1883
  if (args.action === 'prepare') {
1780
1884
  if (!args.content?.trim())
1781
1885
  throw new Error('prepare requires non-empty content');
1782
- if (!args.arc_id?.trim())
1783
- throw new Error('prepare requires arc_id');
1784
- const body = { filename: args.filename ?? 'import.md', content: args.content, arc_id: args.arc_id };
1886
+ // The server resolves the project's current arc when arc_id is absent,
1887
+ // exactly like the sibling novel_import tool. Demanding it here only
1888
+ // cost the agent a round trip.
1889
+ const body = { filename: args.filename ?? 'import.md', content: args.content };
1890
+ if (args.arc_id !== undefined)
1891
+ body['arc_id'] = args.arc_id;
1785
1892
  if (args.start_number !== undefined)
1786
1893
  body['start_number'] = args.start_number;
1787
1894
  return await client.postJson('/api/manuscript-imports/prepare', body, exec.signal);
@@ -1821,95 +1928,6 @@ export function registerNovelTools(ctx, clientFactory, options) {
1821
1928
  return await client.postJson('/api/manuscript-imports/discard', { import_id: importId, confirm: true }, exec.signal);
1822
1929
  },
1823
1930
  }));
1824
- ctx.tools.register(defineTool({
1825
- name: 'novel_project_archive_action',
1826
- description: 'Inspect, create, and restore complete .owarchive project archives. Use preflight before create; use ' +
1827
- 'restore_preview before restore to inspect path/reference rewrites, conflicts, warnings, checksum, and the ' +
1828
- 'archived-without-auto-resume task policy. Restore requires the preview archive_sha256 and explicit confirmation.',
1829
- parameters: {
1830
- action: { type: 'string', required: true, enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle action.' },
1831
- archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
1832
- expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
1833
- target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
1834
- target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
1835
- reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
1836
- archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
1837
- confirm: { type: 'boolean', description: 'Must be true for restore.' },
1838
- },
1839
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1840
- timeoutMs,
1841
- async execute(args, exec) {
1842
- const client = clientFor(exec);
1843
- if (args.action === 'preflight')
1844
- return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
1845
- if (args.action === 'list')
1846
- return await client.getJson('/api/project-archives', {}, exec.signal);
1847
- if (args.action === 'create') {
1848
- if (!args.expected_preflight_revision?.trim())
1849
- throw new Error('create requires expected_preflight_revision');
1850
- return await client.postJson('/api/project-archives/create', {
1851
- expected_preflight_revision: args.expected_preflight_revision,
1852
- }, exec.signal);
1853
- }
1854
- const archiveId = args.archive_id?.trim();
1855
- if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
1856
- throw new Error(`${args.action} requires a valid archive_id`);
1857
- if (args.action === 'get')
1858
- return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
1859
- if (!args.target_root?.trim())
1860
- throw new Error(`${args.action} requires target_root`);
1861
- const body = {
1862
- archive_id: archiveId,
1863
- target_root: args.target_root,
1864
- reference_policy: args.reference_policy ?? 'preserve_relative',
1865
- };
1866
- if (args.target_novel_id !== undefined)
1867
- body['target_novel_id'] = args.target_novel_id;
1868
- if (args.action === 'restore_preview') {
1869
- return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
1870
- }
1871
- if (!args.archive_sha256?.trim())
1872
- throw new Error('restore requires archive_sha256');
1873
- if (args.confirm !== true)
1874
- throw new Error('restore requires confirm=true');
1875
- body['archive_sha256'] = args.archive_sha256;
1876
- body['confirm'] = true;
1877
- return await client.postJson('/api/project-archives/restore', body, exec.signal);
1878
- },
1879
- }));
1880
- ctx.tools.register(defineTool({
1881
- name: 'novel_project_archive_download',
1882
- description: 'Download one complete .owarchive project archive and save it in this workspace\'s local export directory.',
1883
- parameters: {
1884
- archive_id: { type: 'string', required: true, description: 'Archive id shaped owa_<24 lowercase hex>.' },
1885
- },
1886
- output: {
1887
- schema: {
1888
- type: 'object',
1889
- additionalProperties: false,
1890
- properties: {
1891
- path: { type: 'string', required: true },
1892
- filename: { type: 'string', required: true },
1893
- archive_id: { type: 'string', required: true },
1894
- bytes: { type: 'integer', required: true },
1895
- },
1896
- },
1897
- render: (_args, value) => [{ type: 'text', text: `Downloaded ${value.filename} (${value.bytes} bytes) to ${value.path}` }],
1898
- },
1899
- timeoutMs,
1900
- async execute(args, exec) {
1901
- if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
1902
- throw new Error('archive_id must match owa_<24 lowercase hex>');
1903
- const client = clientFor(exec);
1904
- const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
1905
- const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
1906
- const exportDir = outputDirFor(outputDir, client);
1907
- await mkdir(exportDir, { recursive: true });
1908
- const path = join(exportDir, filename);
1909
- await writeFile(path, download.content);
1910
- return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
1911
- },
1912
- }));
1913
1931
  ctx.tools.register(defineTool({
1914
1932
  name: 'novel_sync',
1915
1933
  description: 'Synchronize derived caches that are direct src derivatives: outline hierarchy (data/hierarchy.yaml) and ' +
@@ -1930,7 +1948,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1930
1948
  'actual chapter prose (never rewriting it) and commit chapter memory, runtime-truth notes and a ' +
1931
1949
  'character-state index refresh. Run once after importing an old manuscript so later chapters, reviews and ' +
1932
1950
  'canon/continuity checks have real facts from earlier chapters (without it, review canon stays inconclusive ' +
1933
- 'and context lacks previous-chapter recaps). Returns a background task (tsk_*); poll with novel_task_get.',
1951
+ 'and context lacks previous-chapter recaps). Returns a background task (tsk_*); poll with novel_task(action="get").',
1934
1952
  parameters: {
1935
1953
  chapter_ids: {
1936
1954
  type: 'array',
@@ -1954,7 +1972,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1954
1972
  ctx.tools.register(defineTool({
1955
1973
  name: 'novel_writing_targets',
1956
1974
  description: 'Immediate compatibility write for targets the author has already authorized exactly; otherwise use ' +
1957
- 'novel_structured_change_plan. Update the project writing targets. Ranges: book_words 10000-5000000, chapter_words 200-50000, ' +
1975
+ 'novel_change_plan(action="structured"). Update the project writing targets. Ranges: book_words 10000-5000000, chapter_words 200-50000, ' +
1958
1976
  'outline_volume_words 100-5000, outline_act_words 80-3000, outline_section_words 50-2000, ' +
1959
1977
  'outline_chapter_words 30-1000. Omitted fields keep their current values.',
1960
1978
  parameters: {
@@ -2027,6 +2045,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2027
2045
  'confirm: true), "cancel" (run_id + revision, optional reason). All mutating actions are revision-gated: ' +
2028
2046
  'pass the run\'s current revision from a list/get call.',
2029
2047
  parameters: {
2048
+ include_archived: { type: 'boolean', description: 'For list: include historical runs moved to chapter_deletions when a chapter was deleted. Archived runs are read-only and include archive_directory.' },
2030
2049
  action: { type: 'string', required: true, enum: ['list', 'get', 'record_intervention', 'update_intervention', 'cancel', 'archive'], description: 'The chapter-run operation.' },
2031
2050
  run_id: { type: 'string', description: 'Run id (required for get/record_intervention/update_intervention/cancel).' },
2032
2051
  revision: { type: 'string', description: 'Current run revision (required for mutating actions).' },
@@ -2053,7 +2072,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2053
2072
  async execute(args, exec) {
2054
2073
  const client = clientFor(exec);
2055
2074
  const body = { action: args.action };
2056
- for (const key of ['run_id', 'revision', 'chapter_id', 'statuses', 'since', 'limit', 'scope', 'risk', 'request', 'affected_items', 'rewrite_required', 'intervention_id', 'state', 'facts_revision', 'impact', 'proposal', 'confirm', 'reason']) {
2075
+ for (const key of ['run_id', 'revision', 'chapter_id', 'statuses', 'since', 'limit', 'include_archived', 'scope', 'risk', 'request', 'affected_items', 'rewrite_required', 'intervention_id', 'state', 'facts_revision', 'impact', 'proposal', 'confirm', 'reason']) {
2057
2076
  const value = args[key];
2058
2077
  if (value !== undefined)
2059
2078
  body[key] = value;
@@ -2066,9 +2085,11 @@ export function registerNovelTools(ctx, clientFactory, options) {
2066
2085
  description: 'Manage rolling plan candidates (mid-range plot planning windows). Actions: "list" (limit?), "create" ' +
2067
2086
  '(current_arc?, window_size? default 50, max 50). current_window/accepted_window is accepted manuscript ' +
2068
2087
  'facts only; next_window/planned_window is outline plan, not facts. "get" (candidate_id), "stage" ' +
2069
- '(candidate_id + proposal + revision — stages a plan proposal against the candidate; revision-gated), ' +
2088
+ '(candidate_id + proposal + revision — stages a Markdown plan; chapter headings should read "第N章:标题" ' +
2089
+ 'or "第N章 标题" and Chinese numerals are accepted; revision-gated), ' +
2070
2090
  '"delete" (candidate_id + revision), "apply" (candidate_id + revision — appends staged draft chapters ' +
2071
- 'that do not collide with the canonical outline into src/outline.md; never writes accepted facts).',
2091
+ 'that do not collide with the canonical outline into src/outline.md; never writes accepted facts; ' +
2092
+ 'a proposal without recognizable chapter headings is reported as such instead of as "already exists").',
2072
2093
  parameters: {
2073
2094
  action: { type: 'string', required: true, enum: ['list', 'create', 'get', 'stage', 'delete', 'apply'], description: 'The rolling-plan operation.' },
2074
2095
  limit: { type: 'integer', description: 'List limit (default 20).' },
@@ -2132,7 +2153,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2132
2153
  '"compare" (chapter_id + version_id — preview current-to-snapshot differences without writing), ' +
2133
2154
  '"restore" (chapter_id + version_id + revision — REVERTS the chapter to a snapshot; revision-gated and ' +
2134
2155
  'confirmation-gated: the server demands confirm: true), "annotations" (chapter_id), "annotate" (chapter_id + ' +
2135
- 'revision + quote + note, optional start_hint/end_hint and color amber|rose|sky|lime|violet), ' +
2156
+ 'revision + quote + note, optional start_hint/end_hint (derived from the quote when omitted) and color amber|rose|sky|lime|violet), ' +
2136
2157
  '"resolve_annotation" (chapter_id + annotation_id).',
2137
2158
  parameters: {
2138
2159
  action: { type: 'string', required: true, enum: ['drafts', 'restore_draft', 'versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
@@ -2159,18 +2180,52 @@ export function registerNovelTools(ctx, clientFactory, options) {
2159
2180
  if (args.action === 'restore_draft') {
2160
2181
  if (!args.run_id_v2)
2161
2182
  throw new Error('run_id_v2 is required; list drafts first');
2183
+ // A blank expected_revision is not "no guard": the server computes
2184
+ // `expected = ""` when the chapter file is absent, so `'' != ''` is
2185
+ // false and the revision check is silently satisfied. Sending
2186
+ // `confirm: false` likewise guarantees CONFIRMATION_REQUIRED.
2187
+ if (args.revision === undefined || !String(args.revision).trim()) {
2188
+ throw new Error('revision is required for novel_manuscript_edit_action(action="restore_draft"); read the current chapter revision first');
2189
+ }
2190
+ if (args.confirm !== true) {
2191
+ throw new Error('confirm: true is required for novel_manuscript_edit_action(action="restore_draft")');
2192
+ }
2162
2193
  return await client.postJson('/api/manuscript/drafts/restore', {
2163
- run_id_v2: args.run_id_v2, expected_revision: args.revision ?? '', confirm: args.confirm === true,
2194
+ run_id_v2: args.run_id_v2, expected_revision: args.revision, confirm: true,
2164
2195
  }, exec.signal);
2165
2196
  }
2166
2197
  if (!args.chapter_id || !CHAPTER_ID_PATTERN.test(args.chapter_id))
2167
2198
  throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2199
+ // An empty quote selects `content[0:0]`, which trivially "matches", so the
2200
+ // server would persist a junk annotation and consume the chapter revision.
2201
+ if (args.action === 'annotate') {
2202
+ if (!args.quote?.trim())
2203
+ throw new Error('quote is required for novel_manuscript_edit_action(action="annotate")');
2204
+ if (!args.note?.trim())
2205
+ throw new Error('note is required for novel_manuscript_edit_action(action="annotate")');
2206
+ if (args.revision === undefined || !String(args.revision).trim()) {
2207
+ throw new Error('revision is required for novel_manuscript_edit_action(action="annotate")');
2208
+ }
2209
+ }
2168
2210
  const body = { action: args.action, chapter_id: args.chapter_id };
2169
- for (const key of ['version_id', 'label', 'revision', 'confirm', 'quote', 'note', 'start_hint', 'end_hint', 'color', 'annotation_id']) {
2211
+ let startHint = args.start_hint;
2212
+ let endHint = args.end_hint;
2213
+ if (args.action === 'annotate' && (startHint === undefined || endHint === undefined)) {
2214
+ // The server matches content[start_hint:end_hint] literally against the
2215
+ // quote, so optional hints must still produce exact offsets.
2216
+ const derived = await resolveAnnotationHints(client, args.chapter_id, args.quote, exec.signal);
2217
+ startHint = derived.start;
2218
+ endHint = derived.end;
2219
+ }
2220
+ for (const key of ['version_id', 'label', 'revision', 'confirm', 'quote', 'note', 'color', 'annotation_id']) {
2170
2221
  const value = args[key];
2171
2222
  if (value !== undefined)
2172
2223
  body[key] = value;
2173
2224
  }
2225
+ if (startHint !== undefined)
2226
+ body['start_hint'] = startHint;
2227
+ if (endHint !== undefined)
2228
+ body['end_hint'] = endHint;
2174
2229
  return await client.postJson('/api/manuscript-editing', body, exec.signal);
2175
2230
  },
2176
2231
  }));
@@ -2193,15 +2248,22 @@ export function registerNovelTools(ctx, clientFactory, options) {
2193
2248
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2194
2249
  timeoutMs,
2195
2250
  async execute(args, exec) {
2196
- if (args.chapter_id !== undefined && !CHAPTER_ID_PATTERN.test(args.chapter_id)) {
2251
+ // An empty chapter id is the project-level operation, not a malformed
2252
+ // one: the server resolves it to the first chapter and covers the book.
2253
+ // Rejecting "" here made the documented project-level call unreachable
2254
+ // for any caller that passes the field as an empty string.
2255
+ const chapterId = args.chapter_id?.trim();
2256
+ if (chapterId !== undefined && chapterId !== '' && !CHAPTER_ID_PATTERN.test(chapterId)) {
2197
2257
  throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2198
2258
  }
2199
2259
  const body = {};
2200
- for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm', 'mode', 'no_model']) {
2260
+ for (const key of ['operation_id', 'domains', 'confirm', 'mode', 'no_model']) {
2201
2261
  const value = args[key];
2202
2262
  if (value !== undefined)
2203
2263
  body[key] = value;
2204
2264
  }
2265
+ if (chapterId)
2266
+ body['chapter_id'] = chapterId;
2205
2267
  if (args.action === 'acknowledge' && args.domains === undefined)
2206
2268
  body['domains'] = ['outline', 'foreshadowing'];
2207
2269
  const route = args.action === 'acknowledge' ? 'ack' : args.action === 'resume' ? 'reconcile' : args.action;
@@ -2227,8 +2289,19 @@ export function registerNovelTools(ctx, clientFactory, options) {
2227
2289
  const client = clientFor(exec);
2228
2290
  if (args.action === 'get')
2229
2291
  return await client.getJson('/api/writing-assets', {}, exec.signal);
2230
- if (args.action === 'save_anchor')
2231
- return await client.postJson('/api/writing-assets', { action: args.action, name: args.name ?? '', content: args.content ?? '', expected_revision: args.expected_revision ?? '' }, exec.signal);
2292
+ if (args.action === 'save_anchor') {
2293
+ // A blank name/content reaches the server as INVALID_WRITING_ASSETS
2294
+ // (400), which is also what a stale expected_revision answers — the two
2295
+ // are indistinguishable by code. Validate the payload locally so the
2296
+ // caller is told which parameter is actually wrong.
2297
+ if (!args.name?.trim())
2298
+ throw new Error('name is required for novel_writing_assets(action="save_anchor")');
2299
+ if (!/^[\w-]{1,80}\.md$/.test(args.name))
2300
+ throw new Error(`save_anchor name must be a simple .md filename, got "${args.name}"`);
2301
+ if (!args.content?.trim())
2302
+ throw new Error('content is required for novel_writing_assets(action="save_anchor")');
2303
+ return await client.postJson('/api/writing-assets', { action: args.action, name: args.name, content: args.content, expected_revision: args.expected_revision ?? '' }, exec.signal);
2304
+ }
2232
2305
  if (args.action === 'mark_usage')
2233
2306
  return await client.postJson('/api/writing-assets', { action: args.action, chapter_id: args.chapter_id ?? '', groups: requireRecord(args.groups, 'groups') }, exec.signal);
2234
2307
  const assets = requireRecord(args.assets, 'assets');
@@ -2310,7 +2383,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2310
2383
  chunk_id: { type: 'string', description: 'Chunk to retry (retry_v2).' },
2311
2384
  source_ids: { type: 'array', items: { type: 'string' }, description: 'Sources to synthesize (synthesize_v2).' },
2312
2385
  profile_id: { type: 'string', description: 'Analysis profile id (profile_v2/promotion_preview_v2).' },
2313
- target: { type: 'string', description: 'Promotion target (promotion_preview_v2/promote, default "all").' },
2386
+ target: { type: 'string', description: 'Promotion target: style/rules/inspiration/setting_candidates for promotion_preview_v2 (required); style/setting/world/all for legacy promote (default "all").' },
2314
2387
  preview_id: { type: 'string', description: 'Promotion preview id (promote_v2).' },
2315
2388
  confirm: { type: 'boolean', description: 'Confirm a gated promotion (default false).' },
2316
2389
  },
@@ -2319,6 +2392,15 @@ export function registerNovelTools(ctx, clientFactory, options) {
2319
2392
  isConcurrencySafe: (args) => args.action === 'status_v2' || args.action === 'profile_v2' || args.action === 'promotion_preview_v2',
2320
2393
  async execute(args, exec) {
2321
2394
  const client = clientFor(exec);
2395
+ if (args.action === 'promotion_preview_v2') {
2396
+ const target = args.target?.trim();
2397
+ if (!target) {
2398
+ throw new Error('target is required for novel_source_action(action="promotion_preview_v2"); choose style, rules, inspiration, or setting_candidates');
2399
+ }
2400
+ if (!['style', 'rules', 'inspiration', 'setting_candidates'].includes(target)) {
2401
+ throw new Error('target must be style, rules, inspiration, or setting_candidates for promotion_preview_v2');
2402
+ }
2403
+ }
2322
2404
  const body = { action: args.action };
2323
2405
  for (const key of ['source_id', 'content', 'focus', 'relative_name', 'input_budget_tokens', 'chunk_id', 'source_ids', 'profile_id', 'target', 'preview_id', 'confirm']) {
2324
2406
  const value = args[key];
@@ -2356,7 +2438,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2356
2438
  },
2357
2439
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2358
2440
  timeoutMs,
2359
- isConcurrencySafe: (args) => args.action === 'list' || args.action === 'status' || args.action === 'profile' || args.action === 'adoption_preview',
2441
+ isConcurrencySafe: (args) => args.action === 'list' || args.action === 'status' || args.action === 'profile',
2360
2442
  async execute(args, exec) {
2361
2443
  const client = clientFor(exec);
2362
2444
  const body = { action: args.action };
@@ -2421,7 +2503,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2421
2503
  // ── deep research ────────────────────────────────────────────────────────
2422
2504
  ctx.tools.register(defineTool({
2423
2505
  name: 'novel_research_status',
2424
- description: 'Get the deep-research surface: settings status, report list, and the configured model route. Run a research job via novel_task_create with type "research".',
2506
+ description: 'Get the deep-research surface: settings status, report list, and the configured model route. Run a research job via novel_task(action="create") with type "research".',
2425
2507
  parameters: {},
2426
2508
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2427
2509
  timeoutMs,
@@ -2464,16 +2546,100 @@ export function registerNovelTools(ctx, clientFactory, options) {
2464
2546
  },
2465
2547
  }));
2466
2548
  // ── model configuration ──────────────────────────────────────────────────
2549
+ // ── model configuration ───────────────────────────────────────────────────────
2467
2550
  ctx.tools.register(defineTool({
2468
2551
  name: 'novel_model_profiles',
2469
- description: 'List the server\'s model profiles and per-task routing (which provider/model OpenWrite uses for writing, review, etc.).',
2470
- parameters: {},
2552
+ description: 'List, save, delete and test OpenWrite model profiles, per-task routing and the active model ' +
2553
+ 'settings. Profiles select which provider/model each task uses; routes map tasks to profiles.',
2554
+ parameters: {
2555
+ action: { type: 'string', required: true, enum: ['list', 'save', 'delete', 'save_routes', 'configure', 'test'], description: 'Model configuration operation.' },
2556
+ profile: { type: 'json', description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
2557
+ api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
2558
+ remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2559
+ profile_id: { type: 'string', description: 'Profile id to delete.' },
2560
+ fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
2561
+ routes: { type: 'json', description: 'Object mapping operation names to profile ids.' },
2562
+ provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2563
+ model: { type: 'string', description: 'Model name (max 120 characters).' },
2564
+ base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2565
+ api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2566
+ context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
2567
+ max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
2568
+ id: { type: 'string', description: 'Stored profile id to test.' },
2569
+ },
2471
2570
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2472
2571
  timeoutMs,
2473
- isConcurrencySafe: () => true,
2474
- async execute(_args, exec) {
2475
- const client = clientFor(exec);
2476
- return await client.getJson('/api/model/profiles', {}, exec.signal);
2572
+ isConcurrencySafe: args => ['list', 'test'].includes(args.action),
2573
+ async execute(args, exec) {
2574
+ switch (args.action) {
2575
+ case 'list': {
2576
+ const client = clientFor(exec);
2577
+ return await client.getJson('/api/model/profiles', {}, exec.signal);
2578
+ }
2579
+ case 'save': {
2580
+ if (args.profile === undefined)
2581
+ throw new Error('profile is required for novel_model_profiles(action="save")');
2582
+ const client = clientFor(exec);
2583
+ if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
2584
+ throw new Error('profile must be a JSON object');
2585
+ }
2586
+ const body = { ...args.profile };
2587
+ for (const key of ['api_key', 'remember_api_key']) {
2588
+ const value = args[key];
2589
+ if (value !== undefined)
2590
+ body[key] = value;
2591
+ }
2592
+ return await client.postJson('/api/model/profiles', body, exec.signal);
2593
+ }
2594
+ case 'delete': {
2595
+ if (args.profile_id === undefined)
2596
+ throw new Error('profile_id is required for novel_model_profiles(action="delete")');
2597
+ const client = clientFor(exec);
2598
+ if (!args.profile_id.trim())
2599
+ throw new Error('profile_id must be a non-empty string');
2600
+ const body = { profile_id: args.profile_id };
2601
+ if (args.fallback_id !== undefined)
2602
+ body['fallback_id'] = args.fallback_id;
2603
+ return await client.postJson('/api/model/profiles/delete', body, exec.signal);
2604
+ }
2605
+ case 'save_routes': {
2606
+ if (args.routes === undefined)
2607
+ throw new Error('routes is required for novel_model_profiles(action="save_routes")');
2608
+ const client = clientFor(exec);
2609
+ if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
2610
+ throw new Error('routes must be a JSON object');
2611
+ }
2612
+ return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
2613
+ }
2614
+ case 'configure': {
2615
+ if (args.model === undefined)
2616
+ throw new Error('model is required for novel_model_profiles(action="configure")');
2617
+ const client = clientFor(exec);
2618
+ if (!args.model.trim() || args.model.length > 120)
2619
+ throw new Error('model must be non-empty and at most 120 characters');
2620
+ const body = { model: args.model };
2621
+ for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
2622
+ const value = args[key];
2623
+ if (value !== undefined)
2624
+ body[key] = value;
2625
+ }
2626
+ return await client.postJson('/api/model', body, exec.signal);
2627
+ }
2628
+ case 'test': {
2629
+ const client = clientFor(exec);
2630
+ if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
2631
+ throw new Error('either id (stored profile) or model (candidate) is required');
2632
+ }
2633
+ const body = {};
2634
+ for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
2635
+ const value = args[key];
2636
+ if (value !== undefined)
2637
+ body[key] = value;
2638
+ }
2639
+ return await client.postJson('/api/model/test', body, exec.signal);
2640
+ }
2641
+ default: throw new Error(`novel_model_profiles: unknown action ${String(args.action)}`);
2642
+ }
2477
2643
  },
2478
2644
  }));
2479
2645
  ctx.tools.register(defineTool({
@@ -2532,151 +2698,4 @@ export function registerNovelTools(ctx, clientFactory, options) {
2532
2698
  return await client.postJson('/api/benchmarks', body, exec.signal);
2533
2699
  },
2534
2700
  }));
2535
- ctx.tools.register(defineTool({
2536
- name: 'novel_model_configure',
2537
- description: 'Apply and persist the server\'s active model connection (OpenWrite\'s own LLM for writing/review — ' +
2538
- 'independent of the dsh model). max_tokens must stay below context_tokens.',
2539
- parameters: {
2540
- provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2541
- model: { type: 'string', required: true, description: 'Model name (max 120 characters).' },
2542
- api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2543
- base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2544
- api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2545
- context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
2546
- max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
2547
- remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2548
- },
2549
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2550
- timeoutMs,
2551
- async execute(args, exec) {
2552
- const client = clientFor(exec);
2553
- if (!args.model.trim() || args.model.length > 120)
2554
- throw new Error('model must be non-empty and at most 120 characters');
2555
- const body = { model: args.model };
2556
- for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
2557
- const value = args[key];
2558
- if (value !== undefined)
2559
- body[key] = value;
2560
- }
2561
- return await client.postJson('/api/model', body, exec.signal);
2562
- },
2563
- }));
2564
- ctx.tools.register(defineTool({
2565
- name: 'novel_model_test',
2566
- description: 'Test a model connection without changing active settings. Pass id to test a stored profile, or a full ' +
2567
- 'candidate (provider/model/base_url/api_key...).',
2568
- parameters: {
2569
- id: { type: 'string', description: 'Stored profile id to test.' },
2570
- provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2571
- model: { type: 'string', description: 'Model name (required when id is omitted).' },
2572
- base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2573
- api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2574
- api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2575
- context_tokens: { type: 'integer', description: 'Context budget.' },
2576
- max_tokens: { type: 'integer', description: 'Max output tokens.' },
2577
- },
2578
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2579
- timeoutMs,
2580
- isConcurrencySafe: () => true,
2581
- async execute(args, exec) {
2582
- const client = clientFor(exec);
2583
- if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
2584
- throw new Error('either id (stored profile) or model (candidate) is required');
2585
- }
2586
- const body = {};
2587
- for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
2588
- const value = args[key];
2589
- if (value !== undefined)
2590
- body[key] = value;
2591
- }
2592
- return await client.postJson('/api/model/test', body, exec.signal);
2593
- },
2594
- }));
2595
- ctx.tools.register(defineTool({
2596
- name: 'novel_model_embedding_test',
2597
- description: 'Probe an independent Embedding profile without changing Chat profiles.',
2598
- parameters: {
2599
- id: { type: 'string', description: 'Stored profile id to probe.' },
2600
- candidate: { type: 'json', description: 'Embedding candidate settings object (merged into the request).' },
2601
- api_key: { type: 'string', description: 'Embedding API key override.' },
2602
- },
2603
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2604
- timeoutMs,
2605
- isConcurrencySafe: () => true,
2606
- async execute(args, exec) {
2607
- const client = clientFor(exec);
2608
- const body = {};
2609
- if (args.candidate !== undefined) {
2610
- if (typeof args.candidate !== 'object' || args.candidate === null || Array.isArray(args.candidate)) {
2611
- throw new Error('candidate must be a JSON object');
2612
- }
2613
- Object.assign(body, args.candidate);
2614
- }
2615
- for (const key of ['id', 'api_key']) {
2616
- const value = args[key];
2617
- if (value !== undefined)
2618
- body[key] = value;
2619
- }
2620
- return await client.postJson('/api/model/embedding/test', body, exec.signal);
2621
- },
2622
- }));
2623
- ctx.tools.register(defineTool({
2624
- name: 'novel_model_profile_save',
2625
- description: 'Save (create or update) a named model profile on the server, with an optional API key.',
2626
- parameters: {
2627
- profile: { type: 'json', required: true, description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
2628
- api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
2629
- remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2630
- },
2631
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2632
- timeoutMs,
2633
- async execute(args, exec) {
2634
- const client = clientFor(exec);
2635
- if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
2636
- throw new Error('profile must be a JSON object');
2637
- }
2638
- const body = { ...args.profile };
2639
- for (const key of ['api_key', 'remember_api_key']) {
2640
- const value = args[key];
2641
- if (value !== undefined)
2642
- body[key] = value;
2643
- }
2644
- return await client.postJson('/api/model/profiles', body, exec.signal);
2645
- },
2646
- }));
2647
- ctx.tools.register(defineTool({
2648
- name: 'novel_model_profile_delete',
2649
- description: 'Delete a stored model profile. Routes pointing at it fall back to fallback_id.',
2650
- parameters: {
2651
- profile_id: { type: 'string', required: true, description: 'Profile id to delete.' },
2652
- fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
2653
- },
2654
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2655
- timeoutMs,
2656
- async execute(args, exec) {
2657
- const client = clientFor(exec);
2658
- if (!args.profile_id.trim())
2659
- throw new Error('profile_id must be a non-empty string');
2660
- const body = { profile_id: args.profile_id };
2661
- if (args.fallback_id !== undefined)
2662
- body['fallback_id'] = args.fallback_id;
2663
- return await client.postJson('/api/model/profiles/delete', body, exec.signal);
2664
- },
2665
- }));
2666
- ctx.tools.register(defineTool({
2667
- name: 'novel_model_routes_save',
2668
- description: 'Save per-task model routing: map operation names (e.g. "chapter_write", "review", "research") to profile ids.',
2669
- parameters: {
2670
- routes: { type: 'json', required: true, description: 'Object mapping operation names to profile ids.' },
2671
- },
2672
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2673
- timeoutMs,
2674
- async execute(args, exec) {
2675
- const client = clientFor(exec);
2676
- if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
2677
- throw new Error('routes must be a JSON object');
2678
- }
2679
- return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
2680
- },
2681
- }));
2682
2701
  }