dsh-openwrite 0.2.28 → 0.2.34

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 (125) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +49 -8
  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 +1 -1
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +1 -1
  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 +2 -2
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +4 -4
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUE_123_FOLLOWUP.md +3 -2
  18. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  19. package/docs/LEARNING.md +1 -1
  20. package/docs/MOBILE_READINESS.md +62 -0
  21. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  22. package/docs/RELEASE_ACCEPTANCE.md +20 -3
  23. package/docs/REVIEW_ROUND_22.md +67 -0
  24. package/docs/REVIEW_ROUND_23.md +48 -0
  25. package/docs/REVIEW_ROUND_25.md +51 -0
  26. package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
  27. package/docs/TOOLS.md +181 -604
  28. package/docs/WORKFLOWS.md +5 -5
  29. package/package.json +17 -21
  30. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  31. package/packages/openwrite-bridge/lib/client.js +90 -9
  32. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  33. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  34. package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
  35. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  36. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  37. package/packages/openwrite-bridge/lib/domain.js +250 -29
  38. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  39. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  40. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  41. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  43. package/packages/openwrite-bridge/lib/index.js +150 -6
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
  46. package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
  47. package/packages/openwrite-bridge/lib/tools.js +1223 -1227
  48. package/packages/openwrite-bridge/package.json +7 -5
  49. package/packages/studio-panel/lib/client.js +123207 -121534
  50. package/packages/studio-panel/lib/client.js.map +1 -1
  51. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  52. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  53. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  54. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  55. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  57. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  58. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
  59. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  60. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  61. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  62. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  63. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  64. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  65. package/packages/studio-panel/package.json +5 -3
  66. package/plugin.mjs +159 -20
  67. package/preset-definition.mjs +144 -4
  68. package/presets/openwrite/agent.cordis.yml +7 -9
  69. package/presets/openwrite/ops-integration.mjs +28 -0
  70. package/presets/openwrite/preset.yml +2 -1
  71. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  72. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  73. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  74. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  75. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  76. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  77. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  78. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  79. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  80. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  81. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  82. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  83. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  84. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  85. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  86. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  87. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  88. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  89. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  90. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  91. package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  92. package/release/runtime-manifest.json +4 -13
  93. package/release/source.json +21 -21
  94. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  95. package/scripts/maintenance.mjs +67 -7
  96. package/scripts/plugin-doctor.mjs +179 -0
  97. package/scripts/prepare.mjs +26 -4
  98. package/scripts/safe-upgrade.mjs +38 -5
  99. package/dog-tools.mjs +0 -13
  100. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  101. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  102. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  103. package/scripts/dog/delivery-stage.js +0 -32
  104. package/scripts/dog/import-chapter.js +0 -20
  105. package/scripts/dog/import-record.js +0 -35
  106. package/scripts/dog/review-dimension.js +0 -41
  107. package/scripts/dog/review-record.js +0 -42
  108. package/vendor/dsh-dog/LICENSE +0 -29
  109. package/vendor/dsh-dog/README.md +0 -114
  110. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  111. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  112. package/vendor/dsh-dog/lib/client.js +0 -2960
  113. package/vendor/dsh-dog/lib/client.js.map +0 -1
  114. package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
  115. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  116. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  117. package/vendor/dsh-dog/lib/core.js +0 -2
  118. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  119. package/vendor/dsh-dog/lib/index.js +0 -1230
  120. package/vendor/dsh-dog/package.json +0 -110
  121. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  122. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  123. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  124. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  125. 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,12 @@ 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
926
  'Preview context first with novel_context_preview and consult novel_outline_read for the recommendation. ' +
707
- 'Cancel with novel_task_cancel. Generated partial prose is retained; resume_from_draft continues it in a new run when the source/context revisions still match.',
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.',
708
928
  parameters: {
709
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.' },
710
930
  run_id_v2: { type: 'string', description: 'Resume this completed draft without generating it again.' },
@@ -761,8 +981,20 @@ export function registerNovelTools(ctx, clientFactory, options) {
761
981
  timeoutMs,
762
982
  async execute(args, exec) {
763
983
  const client = clientFor(exec);
764
- if (args.action === 'versions' || args.action === 'compare')
765
- 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
+ }
766
998
  if (args.dimensions !== undefined && args.dimensions.some(value => !Number.isInteger(value) || value < 1 || value > 37)) {
767
999
  throw new Error('dimensions must contain only integers from 1 to 37');
768
1000
  }
@@ -800,42 +1032,10 @@ export function registerNovelTools(ctx, clientFactory, options) {
800
1032
  return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
801
1033
  },
802
1034
  }));
803
- ctx.tools.register(defineTool({
804
- name: 'novel_asset_update',
805
- description: 'Immediate compatibility write for an asset change the author has already authorized exactly; otherwise use ' +
806
- 'novel_structured_change_plan. Update a structured asset (character / world / progression). Revision-gated: pass the `revision` ' +
807
- 'fingerprint from novel_assets_list; the server rejects the update with a conflict if the asset changed. ' +
808
- 'Pass field changes via `data` (merged into the asset front matter), `body_markdown` to replace the ' +
809
- 'markdown body, or `raw_text` to replace the whole document.',
810
- parameters: {
811
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
812
- id: { type: 'string', required: true, description: 'Asset id.' },
813
- revision: { type: 'string', required: true, description: 'The asset revision fingerprint from a fresh novel_assets_list call.' },
814
- data: { type: 'json', description: 'Object of asset fields to merge (e.g. name, summary, aliases).' },
815
- body_markdown: { type: 'string', description: 'Replacement markdown body (character/world assets).' },
816
- raw_text: { type: 'string', description: 'Full replacement document text, validated by the server.' },
817
- related_mode: { type: 'string', enum: ['merge', 'replace'], description: 'Preserve and merge existing related entries by default; replace explicitly clears or replaces relations.' },
818
- },
819
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
820
- timeoutMs,
821
- async execute(args, exec) {
822
- const client = clientFor(exec);
823
- const body = { kind: args.kind, id: args.id, revision: args.revision };
824
- if (args.data !== undefined)
825
- body['data'] = args.data;
826
- if (args.body_markdown !== undefined)
827
- body['body_markdown'] = args.body_markdown;
828
- if (args.raw_text !== undefined)
829
- body['raw_text'] = args.raw_text;
830
- if (args.related_mode !== undefined)
831
- body['related_mode'] = args.related_mode;
832
- return await client.postJson('/api/assets/update', body, exec.signal);
833
- },
834
- }));
835
1035
  ctx.tools.register(defineTool({
836
1036
  name: 'novel_foreshadowing',
837
1037
  description: 'Immediate compatibility write for a foreshadowing change the author has already authorized exactly; otherwise use ' +
838
- '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), ' +
839
1039
  'layer (default "支线"), target_chapter. action "update": node_id + status (e.g. planted/paid-off).',
840
1040
  parameters: {
841
1041
  action: { type: 'string', required: true, enum: ['create', 'update'], description: 'Create a new foreshadowing node or update an existing one.' },
@@ -866,113 +1066,81 @@ export function registerNovelTools(ctx, clientFactory, options) {
866
1066
  return await client.postJson('/api/foreshadowing', body, exec.signal);
867
1067
  },
868
1068
  }));
1069
+ // ── change plans ──────────────────────────────────────────────────────────────
869
1070
  ctx.tools.register(defineTool({
870
- name: 'novel_doc_write',
871
- description: 'Write a project document (path relative to the novel data root). Uses a version-based optimistic lock: ' +
872
- 'pass the `version` returned by novel_doc_read; the server rejects with a conflict if the document changed. ' +
873
- 'Omit version to create a new document; set force to bypass the lock.',
874
- parameters: {
875
- path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
876
- content: { type: 'string', required: true, description: 'Full new document content (max 2 MB).' },
877
- version: { type: 'string', description: 'The version fingerprint from novel_doc_read (optimistic lock).' },
878
- force: { type: 'boolean', description: 'Bypass the version check (default false).' },
879
- },
880
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
881
- timeoutMs,
882
- async execute(args, exec) {
883
- const client = clientFor(exec);
884
- if (!args.path.trim())
885
- throw new Error('path must be a non-empty string');
886
- const body = { path: args.path, content: args.content };
887
- if (args.version !== undefined)
888
- body['version'] = args.version;
889
- if (args.force !== undefined)
890
- body['force'] = args.force;
891
- return await client.putJson('/api/document', body, exec.signal);
892
- },
893
- }));
894
- ctx.tools.register(defineTool({
895
- name: 'novel_document_change_plan',
896
- description: 'Stage and review revision-gated changes to an existing novel document. Use action "preview" with path and edits ' +
897
- 'to receive an immutable preview_token and real before/after summary. Use "apply" only after author acceptance, ' +
898
- 'passing that token and confirm: true; "reject" discards it, "retry" rebuilds it against the current source, and "undo" safely ' +
899
- 'applies an undo_preview_token returned by a prior apply. Each edit uses old_text/new_text or unique start_text/' +
900
- 'end_text/new_text anchors. Preview returns applied:false; changed/would_change describe only the proposed difference. ' +
901
- 'OLD_TEXT_NOT_FOUND is an anchor mismatch (400), while revision conflicts are 409; use the exact suggested bytes and reread the source before retrying. ' +
902
- '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.',
903
1075
  parameters: {
904
- 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.' },
905
1078
  confirm: { type: 'boolean', description: 'Required true for apply and undo after author acceptance.' },
906
1079
  path: { type: 'string', description: 'Existing document path for preview, relative to the novel root.' },
907
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).' },
908
1081
  preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
909
- },
910
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
911
- timeoutMs,
912
- async execute(args, exec) {
913
- if (args.action === 'preview' && (!Array.isArray(args.edits) || (args.edits.length < 1 || args.edits.length > 50))) {
914
- throw new Error('preview requires between 1 and 50 edits ' +
915
- `(received ${describeValue(args.edits)})`);
916
- }
917
- if (args.action !== 'preview' && !args.preview_token?.trim()) {
918
- throw new Error(`${args.action} requires preview_token`);
919
- }
920
- if (['apply', 'undo'].includes(args.action) && args.confirm !== true)
921
- throw new Error('apply/undo requires confirm: true');
922
- const client = clientFor(exec);
923
- const body = { action: args.action };
924
- if (args.confirm !== undefined)
925
- body['confirm'] = args.confirm;
926
- if (args.path !== undefined)
927
- body['path'] = args.path;
928
- if (args.edits !== undefined)
929
- body['edits'] = args.edits;
930
- if (args.preview_token !== undefined)
931
- body['preview_token'] = args.preview_token;
932
- return await client.postJson('/api/document/change-plan', body, exec.signal);
933
- },
934
- }));
935
- ctx.tools.register(defineTool({
936
- name: 'novel_structured_change_plan',
937
- description: 'Stage one structured OpenWrite domain change without writing it. Supported change_kind values are "outline", ' +
938
- '"asset", "focus", "foreshadowing", and "writing_targets"; `change` uses the corresponding direct tool payload. ' +
939
- 'Use action "preview" to receive an immutable token and entity-level before/after summary. Only after author ' +
940
- 'acceptance pass that token unchanged to "apply". "reject" consumes it, "retry" rebuilds the same semantic ' +
941
- 'change against current source, and "undo" accepts only the revision-bound undo token returned by apply. The ' +
942
- 'server writes the exact validated preview and rejects source conflicts.',
943
- parameters: {
944
- action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
945
1082
  change_kind: { type: 'string', enum: ['outline', 'asset', 'focus', 'foreshadowing', 'writing_targets'], description: 'Structured domain for a preview.' },
946
1083
  change: { type: 'json', description: 'One direct-tool-shaped change object, required for preview.' },
947
- preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
948
1084
  },
949
1085
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
950
1086
  timeoutMs,
951
- async execute(args, exec) {
952
- if (args.action === 'preview') {
953
- if (!args.change_kind)
954
- throw new Error('preview requires change_kind');
955
- if (args.change === null || typeof args.change !== 'object' || Array.isArray(args.change)) {
956
- 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);
957
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)}`);
958
1138
  }
959
- else if (!args.preview_token?.trim()) {
960
- throw new Error(`${args.action} requires preview_token`);
961
- }
962
- const client = clientFor(exec);
963
- const body = { action: args.action };
964
- if (args.change_kind !== undefined)
965
- body['change_kind'] = args.change_kind;
966
- if (args.change !== undefined)
967
- body['change'] = args.change;
968
- if (args.preview_token !== undefined)
969
- body['preview_token'] = args.preview_token;
970
- return await client.postJson('/api/structured/change-plan', body, exec.signal);
971
1139
  },
972
1140
  }));
973
1141
  ctx.tools.register(defineTool({
974
1142
  name: 'novel_focus',
975
- 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.',
976
1144
  parameters: {
977
1145
  goal: { type: 'string', required: true, description: 'The current writing goal.' },
978
1146
  must_keep: { type: 'array', items: { type: 'string' }, description: 'Constraints that must be kept.' },
@@ -993,557 +1161,435 @@ export function registerNovelTools(ctx, clientFactory, options) {
993
1161
  return await client.postJson('/api/focus', body, exec.signal);
994
1162
  },
995
1163
  }));
996
- ctx.tools.register(defineTool({
997
- name: 'novel_export_preflight',
998
- description: 'Inspect the current manuscript export order, structural defects, writing targets, metadata, review freshness, ' +
999
- 'manuscript acceptance, blockers, and warnings. Pass the returned preflight_revision unchanged to novel_export ' +
1000
- 'so the downloaded file is bound to the manuscript state that was inspected.',
1001
- parameters: {
1002
- format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
1003
- purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
1004
- },
1005
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1006
- timeoutMs,
1007
- isConcurrencySafe: () => true,
1008
- async execute(args, exec) {
1009
- const client = clientFor(exec);
1010
- return await client.getJson('/api/export/preflight', {
1011
- format: args.format ?? 'md',
1012
- purpose: args.purpose ?? 'delivery',
1013
- }, exec.signal);
1014
- },
1015
- }));
1164
+ // ── export ────────────────────────────────────────────────────────────────────
1016
1165
  ctx.tools.register(defineTool({
1017
1166
  name: 'novel_export',
1018
- description: 'Export the manuscript as a file download and save it locally. Inspect novel_export_preflight first and pass its ' +
1019
- '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.',
1020
1169
  parameters: {
1170
+ action: { type: 'string', required: true, enum: ['preflight', 'export'], description: 'Export step; preflight reports what would be written without writing it.' },
1021
1171
  format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
1022
- purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery").' },
1023
- preflight_revision: { type: 'string', description: 'Revision returned by novel_export_preflight for the same format and purpose.' },
1024
- },
1025
- output: {
1026
- schema: {
1027
- type: 'object',
1028
- additionalProperties: false,
1029
- properties: {
1030
- path: { type: 'string', required: true, description: 'Absolute path of the saved export file.' },
1031
- filename: { type: 'string', required: true },
1032
- format: { type: 'string', required: true },
1033
- bytes: { type: 'integer', required: true },
1034
- },
1035
- },
1036
- render: (_args, value) => [{
1037
- type: 'text',
1038
- text: `Exported ${value.filename} (${value.bytes} bytes) to ${value.path}`,
1039
- }],
1040
- },
1041
- timeoutMs,
1042
- async execute(args, exec) {
1043
- const client = clientFor(exec);
1044
- const format = args.format ?? 'md';
1045
- const purpose = args.purpose ?? 'delivery';
1046
- const download = await client.download('/api/export', {
1047
- format,
1048
- purpose,
1049
- ...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
1050
- }, exec.signal);
1051
- const filename = exportFilename(download.filename, `novel.${format}`);
1052
- const exportDir = outputDirFor(outputDir, client);
1053
- await mkdir(exportDir, { recursive: true });
1054
- const path = join(exportDir, filename);
1055
- await writeFile(path, download.content);
1056
- return { path, filename, format, bytes: download.content.byteLength };
1057
- },
1058
- }));
1059
- // ── assets: create / read / packages ────────────────────────────────────
1060
- ctx.tools.register(defineTool({
1061
- name: 'novel_asset_read',
1062
- description: 'Read one structured asset by kind and id, including its relation view.',
1063
- parameters: {
1064
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
1065
- id: { type: 'string', required: true, description: 'Asset id.' },
1066
- },
1067
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1068
- timeoutMs,
1069
- isConcurrencySafe: () => true,
1070
- async execute(args, exec) {
1071
- const client = clientFor(exec);
1072
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
1073
- throw new Error(`invalid asset id "${args.id}"`);
1074
- return await client.getJson(`/api/assets/${args.kind}/${encodeURIComponent(args.id)}`, {}, exec.signal);
1075
- },
1076
- }));
1077
- ctx.tools.register(defineTool({
1078
- name: 'novel_asset_create',
1079
- description: 'Create a structured asset. character/world assets take front-matter fields via `data` plus an optional ' +
1080
- '`body_markdown`; progression assets take their YAML mapping via `data`. Fails with a conflict if the id exists — ' +
1081
- 'use novel_asset_update instead.',
1082
- parameters: {
1083
- kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
1084
- id: { type: 'string', required: true, description: 'Asset id (letters, digits, "_", "-", ".").' },
1085
- data: { type: 'json', description: 'Object of asset fields (e.g. name, summary, aliases).' },
1086
- 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.' },
1087
1174
  },
1088
1175
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1089
1176
  timeoutMs,
1177
+ isConcurrencySafe: args => ['preflight'].includes(args.action),
1090
1178
  async execute(args, exec) {
1091
- const client = clientFor(exec);
1092
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
1093
- throw new Error(`invalid asset id "${args.id}"`);
1094
- const body = { kind: args.kind, id: args.id };
1095
- if (args.data !== undefined)
1096
- body['data'] = args.data;
1097
- if (args.body_markdown !== undefined)
1098
- body['body_markdown'] = args.body_markdown;
1099
- return await client.postJson('/api/assets', body, exec.signal);
1100
- },
1101
- }));
1102
- ctx.tools.register(defineTool({
1103
- name: 'novel_assets_package_export',
1104
- description: 'Export assets as an .owasset.zip package and save it locally. Omit selections to export all assets.',
1105
- parameters: {
1106
- selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
1107
- },
1108
- output: {
1109
- schema: {
1110
- type: 'object',
1111
- additionalProperties: false,
1112
- properties: {
1113
- path: { type: 'string', required: true, description: 'Absolute path of the saved package file.' },
1114
- filename: { type: 'string', required: true },
1115
- bytes: { type: 'integer', required: true },
1116
- },
1117
- },
1118
- render: (_args, value) => [{
1119
- type: 'text',
1120
- text: `Exported asset package ${value.filename} (${value.bytes} bytes) to ${value.path}`,
1121
- }],
1122
- },
1123
- timeoutMs,
1124
- isConcurrencySafe: () => true,
1125
- async execute(args, exec) {
1126
- const client = clientFor(exec);
1127
- const selections = args.selections ?? [];
1128
- for (const item of selections) {
1129
- if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
1130
- 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);
1131
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)}`);
1132
1204
  }
1133
- const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
1134
- const filename = exportFilename(download.filename, 'assets.owasset.zip');
1135
- const exportDir = outputDirFor(outputDir, client);
1136
- await mkdir(exportDir, { recursive: true });
1137
- const path = join(exportDir, filename);
1138
- await writeFile(path, download.content);
1139
- return { path, filename, bytes: download.content.byteLength };
1140
- },
1141
- }));
1142
- ctx.tools.register(defineTool({
1143
- name: 'novel_assets_package_preview',
1144
- description: 'Stage an .owasset.zip asset package (base64) and preview its contents and conflicts without importing. ' +
1145
- 'Returns an upload_id to pass to novel_assets_package_import.',
1146
- parameters: {
1147
- package_base64: { type: 'string', required: true, description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
1148
- },
1149
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1150
- timeoutMs,
1151
- async execute(args, exec) {
1152
- const client = clientFor(exec);
1153
- if (!args.package_base64.trim())
1154
- throw new Error('package_base64 must be a non-empty string');
1155
- return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
1156
1205
  },
1157
1206
  }));
1207
+ // ── assets: create / read / packages ────────────────────────────────────
1208
+ // ── asset packages ────────────────────────────────────────────────────────────
1158
1209
  ctx.tools.register(defineTool({
1159
- name: 'novel_assets_package_import',
1160
- description: 'Import a previously staged asset package (see novel_assets_package_preview). Resolves per-asset conflicts ' +
1161
- '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.',
1162
1213
  parameters: {
1163
- upload_id: { type: 'string', required: true, description: 'The upload_id returned by novel_assets_package_preview.' },
1164
- 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.' },
1165
1219
  resolutions: { type: 'json', description: 'Object mapping conflicting asset keys to resolutions.' },
1166
1220
  allow_missing_dependencies: { type: 'boolean', description: 'Allow import despite missing asset dependencies (default false).' },
1167
1221
  },
1168
1222
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1169
1223
  timeoutMs,
1170
- async execute(args, exec) {
1171
- const client = clientFor(exec);
1172
- const body = { upload_id: args.upload_id };
1173
- if (args.package_sha256 !== undefined)
1174
- body['package_sha256'] = args.package_sha256;
1175
- if (args.resolutions !== undefined)
1176
- body['resolutions'] = args.resolutions;
1177
- if (args.allow_missing_dependencies !== undefined)
1178
- body['allow_missing_dependencies'] = args.allow_missing_dependencies;
1179
- 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
+ }
1180
1274
  },
1181
1275
  }));
1182
1276
  // ── revisions ────────────────────────────────────────────────────────────
1277
+ // ── revisions ─────────────────────────────────────────────────────────────────
1183
1278
  ctx.tools.register(defineTool({
1184
- name: 'novel_revisions_list',
1185
- 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.',
1186
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.' },
1187
1285
  chapter: { type: 'string', description: 'Chapter id filter (e.g. "ch_0007").' },
1188
1286
  status: { type: 'string', description: 'Status filter (e.g. "proposed", "applied", "rejected").' },
1189
- },
1190
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1191
- timeoutMs,
1192
- isConcurrencySafe: () => true,
1193
- async execute(args, exec) {
1194
- const client = clientFor(exec);
1195
- const params = {};
1196
- if (args.chapter !== undefined)
1197
- params['chapter'] = args.chapter;
1198
- if (args.status !== undefined)
1199
- params['status'] = args.status;
1200
- return await client.getJson('/api/revisions', params, exec.signal);
1201
- },
1202
- }));
1203
- ctx.tools.register(defineTool({
1204
- name: 'novel_revision_get',
1205
- description: 'Get one revision proposal with its diff hunks by proposal id (rev_*).',
1206
- parameters: {
1207
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1208
- },
1209
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1210
- timeoutMs,
1211
- isConcurrencySafe: () => true,
1212
- async execute(args, exec) {
1213
- const client = clientFor(exec);
1214
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1215
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1216
- return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
1217
- },
1218
- }));
1219
- ctx.tools.register(defineTool({
1220
- name: 'novel_revision_create_selection',
1221
- description: 'Create a revision proposal for a text selection in a chapter (LONG-RUNNING, model-generated). ' +
1222
- 'start/end are Unicode code-point offsets in the exact saved text, including original CR/LF characters; pass the selected text as original_text and the current expected_document_revision. The proposal is only staged — ' +
1223
- 'apply it with novel_revision_apply.',
1224
- parameters: {
1225
- chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1226
- start: { type: 'integer', required: true, description: 'Selection start offset.' },
1227
- end: { type: 'integer', required: true, description: 'Selection end offset.' },
1228
- original_text: { type: 'string', description: 'The selected original text.' },
1229
- expected_document_revision: { type: 'string', description: 'Current exact manuscript revision; reject before generation if the source changed.' },
1230
- instruction: { type: 'string', description: 'Revision instruction (max 4000 characters).' },
1231
- action: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
1232
- target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1233
- full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
1234
- },
1235
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1236
- timeoutMs,
1237
- async execute(args, exec) {
1238
- const client = clientFor(exec);
1239
- if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1240
- throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1241
- const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
1242
- if (args.original_text !== undefined)
1243
- body['original_text'] = args.original_text;
1244
- if (args.expected_document_revision !== undefined)
1245
- body['expected_document_revision'] = args.expected_document_revision;
1246
- if (args.instruction !== undefined)
1247
- body['instruction'] = args.instruction;
1248
- if (args.action !== undefined)
1249
- body['action'] = args.action;
1250
- if (args.target_units !== undefined)
1251
- body['target_units'] = args.target_units;
1252
- if (args.full_chapter !== undefined)
1253
- body['full_chapter'] = args.full_chapter;
1254
- return await client.postJson('/api/revisions/selection', body, exec.signal);
1255
- },
1256
- }));
1257
- ctx.tools.register(defineTool({
1258
- name: 'novel_revision_create_from_review',
1259
- description: 'Create a revision proposal that addresses selected issues from a chapter review (LONG-RUNNING, model-generated). ' +
1260
- 'Issue ids come from novel_review_chapter\'s issue_details. Pass selection to constrain rewriting to an exact manuscript range; overlapping issue evidence never expands that range. The proposal is only staged — apply it with novel_revision_apply.',
1261
- parameters: {
1262
- chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1263
- 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".' },
1264
1291
  selection: { type: 'object', additionalProperties: false, description: 'Optional exact saved-text range in Unicode code points; preserve raw CR/LF characters.', properties: {
1265
- start: { type: 'integer', required: true, description: 'Inclusive source code-point offset.' },
1266
- end: { type: 'integer', required: true, description: 'Exclusive source code-point offset.' },
1292
+ start: { type: 'integer', description: 'Inclusive source code-point offset.' },
1293
+ end: { type: 'integer', description: 'Exclusive source code-point offset.' },
1267
1294
  original_text: { type: 'string', required: true, description: 'Exact saved text inside this range.' },
1268
1295
  } },
1269
1296
  instruction: { type: 'string', description: 'Additional revision instruction.' },
1270
1297
  target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1271
- expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
1272
- expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
1273
- },
1274
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1275
- timeoutMs,
1276
- async execute(args, exec) {
1277
- const client = clientFor(exec);
1278
- if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1279
- throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1280
- if (args.issue_ids.length === 0)
1281
- throw new Error('issue_ids must be a non-empty array');
1282
- const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
1283
- if (args.selection !== undefined)
1284
- body['selection'] = args.selection;
1285
- if (args.instruction !== undefined)
1286
- body['instruction'] = args.instruction;
1287
- if (args.target_units !== undefined)
1288
- body['target_units'] = args.target_units;
1289
- if (args.expected_review_revision !== undefined)
1290
- body['expected_review_revision'] = args.expected_review_revision;
1291
- if (args.expected_document_revision !== undefined)
1292
- body['expected_document_revision'] = args.expected_document_revision;
1293
- const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
1294
- try {
1295
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
1296
- }
1297
- catch (error) {
1298
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1299
- }
1300
- },
1301
- }));
1302
- ctx.tools.register(defineTool({
1303
- name: 'novel_revision_apply',
1304
- description: 'Apply a staged revision proposal to the manuscript. Optionally override the generated text or apply only ' +
1305
- 'selected diff hunks. Fails with a conflict if the document changed since the proposal was created.',
1306
- parameters: {
1307
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1308
- replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
1309
- selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
1310
- },
1311
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1312
- timeoutMs,
1313
- async execute(args, exec) {
1314
- const client = clientFor(exec);
1315
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1316
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1317
- const body = {};
1318
- if (args.replacement_text !== undefined)
1319
- body['replacement_text'] = args.replacement_text;
1320
- if (args.selected_hunk_ids !== undefined)
1321
- body['selected_hunk_ids'] = args.selected_hunk_ids;
1322
- const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
1323
- const responseValue = objectValue(response);
1324
- const proposal = objectValue(responseValue['proposal'] ?? response);
1325
- const chapterId = String(proposal['chapter_id'] ?? '');
1326
- try {
1327
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1328
- }
1329
- catch (error) {
1330
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1331
- }
1332
- },
1333
- }));
1334
- ctx.tools.register(defineTool({
1335
- name: 'novel_revision_reject',
1336
- description: 'Reject and discard a staged revision proposal. This permanently drops the generated text — it cannot be ' +
1337
- 'recovered except by regenerating.',
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)}/reject`, {}, 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
- ctx.tools.register(defineTool({
1358
- name: 'novel_revision_regenerate',
1359
- description: 'Regenerate the text of a staged revision proposal (LONG-RUNNING, model-generated).',
1360
- parameters: {
1361
- proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1362
- },
1363
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1364
- timeoutMs,
1365
- async execute(args, exec) {
1366
- const client = clientFor(exec);
1367
- if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1368
- throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1369
- const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
1370
- const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1371
- try {
1372
- return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1373
- }
1374
- catch (error) {
1375
- return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1376
- }
1377
- },
1378
- }));
1379
- // ── background tasks ─────────────────────────────────────────────────────
1380
- ctx.tools.register(defineTool({
1381
- name: 'novel_tasks_list',
1382
- description: 'List background tasks (queued/running/finished AI jobs) with per-status counts.',
1383
- parameters: {
1384
- since: { type: 'string', description: 'ISO timestamp filter for requests whose response was lost.' },
1385
- limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
1386
- },
1387
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1388
- timeoutMs,
1389
- isConcurrencySafe: () => true,
1390
- async execute(args, exec) {
1391
- const client = clientFor(exec);
1392
- const params = {};
1393
- if (args.since !== undefined)
1394
- params['since'] = args.since;
1395
- if (args.limit !== undefined)
1396
- params['limit'] = String(args.limit);
1397
- return await client.getJson('/api/tasks', params, exec.signal);
1398
- },
1399
- }));
1400
- ctx.tools.register(defineTool({
1401
- name: 'novel_task_get',
1402
- 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.',
1403
- parameters: {
1404
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1405
- },
1406
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1407
- timeoutMs,
1408
- async execute(args, exec) {
1409
- const client = clientFor(exec);
1410
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1411
- throw new Error(`invalid task_id "${args.task_id}"`);
1412
- const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
1413
- const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
1414
- ? response
1415
- : {};
1416
- const taskValue = responseRecord['task'] ?? response;
1417
- const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
1418
- ? taskValue
1419
- : {};
1420
- if (taskRecord['status'] !== 'completed')
1421
- return response;
1422
- const taskType = String(taskRecord['type'] ?? '');
1423
- if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
1424
- return response;
1425
- const taskResult = objectValue(taskRecord['result']);
1426
- const taskInput = objectValue(taskRecord['input']);
1427
- const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
1428
- || String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
1429
- || '';
1430
- const value = response !== null && typeof response === 'object' && !Array.isArray(response)
1431
- ? response
1432
- : { task: response };
1433
- if (!/^ch_\d+$/.test(chapterId)) {
1434
- return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
1435
- }
1436
- let workspace;
1437
- try {
1438
- workspace = await client.getJson('/api/workspace', {}, exec.signal);
1439
- }
1440
- catch (error) {
1441
- const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1442
- return taskType === 'chapter_review'
1443
- ? { ...value, dog_review: unavailable, dog_delivery: unavailable }
1444
- : { ...value, dog_delivery: unavailable };
1445
- }
1446
- let dogDelivery;
1447
- try {
1448
- dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
1449
- }
1450
- catch (error) {
1451
- dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1452
- }
1453
- if (taskType !== 'chapter_review')
1454
- return { ...value, dog_delivery: dogDelivery };
1455
- let dogReview;
1456
- try {
1457
- const framework = await client.getJson('/api/review/framework', {}, exec.signal);
1458
- dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, 70, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
1459
- }
1460
- catch (error) {
1461
- dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1462
- }
1463
- return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
1464
- },
1465
- }));
1466
- ctx.tools.register(defineTool({
1467
- name: 'novel_task_create',
1468
- description: 'Queue a background AI task and return immediately; poll with novel_task_get. Task types and their `input` keys: ' +
1469
- 'chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
1470
- 'chapter_review {chapter_id | path, strict?, dimensions?}; ' +
1471
- 'revision_selection / revision_from_review (same inputs as the novel_revision_create_* tools); ' +
1472
- 'source_operation / reference_operation (same inputs as novel_source_action / novel_reference_library_action); ' +
1473
- 'manuscript_import (same input as novel_import); research {prompt, search?, language?, quality?, llm?}; ' +
1474
- 'manuscript_reconcile (durable acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); ' +
1475
- 'settle_backfill {chapter_ids?, only_missing?} — prefer the novel_settle_backfill convenience tool; ' +
1476
- 'continuous_write — prefer the novel_multi_write convenience tool.',
1477
- parameters: {
1478
- type: {
1479
- type: 'string',
1480
- required: true,
1481
- enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
1482
- description: 'Task type.',
1483
- },
1484
- input: { type: 'json', required: true, description: 'Task input object; shape depends on the task type (see description).' },
1485
- },
1486
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1487
- timeoutMs,
1488
- async execute(args, exec) {
1489
- const client = clientFor(exec);
1490
- if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
1491
- throw new Error('input must be a JSON object');
1492
- }
1493
- return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
1494
- },
1495
- }));
1496
- ctx.tools.register(defineTool({
1497
- name: 'novel_task_cancel',
1498
- 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.',
1499
- parameters: {
1500
- wait_seconds: { type: 'number', description: 'Wait up to 30 seconds for actual cancellation; running model calls may finish later.' },
1501
- task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1502
- },
1503
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1504
- timeoutMs,
1505
- async execute(args, exec) {
1506
- const client = clientFor(exec);
1507
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1508
- throw new Error(`invalid task_id "${args.task_id}"`);
1509
- return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, { wait_seconds: args.wait_seconds ?? 0 }, exec.signal);
1510
- },
1511
- }));
1512
- ctx.tools.register(defineTool({
1513
- name: 'novel_task_retry',
1514
- description: 'Retry a failed or cancelled background task.',
1515
- parameters: {
1516
- 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.' },
1517
1307
  },
1518
1308
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1519
1309
  timeoutMs,
1310
+ isConcurrencySafe: args => ['list', 'get'].includes(args.action),
1520
1311
  async execute(args, exec) {
1521
- const client = clientFor(exec);
1522
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1523
- throw new Error(`invalid task_id "${args.task_id}"`);
1524
- 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
+ }
1525
1443
  },
1526
1444
  }));
1527
- ctx.tools.register(defineTool({
1528
- name: 'novel_task_confirm',
1529
- description: 'Confirm a background task that is awaiting confirmation (e.g. a continuous_write run paused between chapters), letting it resume.',
1530
- parameters: {
1531
- 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.' },
1532
1470
  },
1533
1471
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1534
1472
  timeoutMs,
1473
+ isConcurrencySafe: args => ['list'].includes(args.action),
1535
1474
  async execute(args, exec) {
1536
- const client = clientFor(exec);
1537
- if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1538
- throw new Error(`invalid task_id "${args.task_id}"`);
1539
- 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
+ }
1540
1586
  },
1541
1587
  }));
1542
1588
  ctx.tools.register(defineTool({
1543
1589
  name: 'novel_multi_write',
1544
1590
  description: 'Queue a BACKGROUND continuous-writing run: writes up to max_chapters chapters following the outline, ' +
1545
1591
  'reviewing each; stops early on review blockers, continuity errors, low scores, or budget limits. ' +
1546
- '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.',
1547
1593
  parameters: {
1548
1594
  max_chapters: { type: 'integer', description: 'Chapters to write, 1-10 (default 1).' },
1549
1595
  minimum_review_score: { type: 'integer', description: 'Stop when a chapter scores below this, 0-100 (default 82).' },
@@ -1555,7 +1601,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1555
1601
  temperature: { type: 'number', description: 'Sampling temperature (default 0.7).' },
1556
1602
  stop_on_blocker: { type: 'boolean', description: 'Stop when a review reports a blocker issue (default true).' },
1557
1603
  stop_on_continuity_error: { type: 'boolean', description: 'Stop on high-severity continuity issues (default true).' },
1558
- 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).' },
1559
1605
  },
1560
1606
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1561
1607
  timeoutMs,
@@ -1588,71 +1634,130 @@ export function registerNovelTools(ctx, clientFactory, options) {
1588
1634
  },
1589
1635
  }));
1590
1636
  // ── project lifecycle ────────────────────────────────────────────────────
1637
+ // ── projects ──────────────────────────────────────────────────────────────────
1591
1638
  ctx.tools.register(defineTool({
1592
- name: 'novel_project_init',
1593
- description: 'Create a new OpenWrite novel project on the server machine and make it the active project. ' +
1594
- '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.',
1595
1642
  parameters: {
1596
- novel_id: { type: 'string', required: true, description: 'Project id, e.g. "my_novel" (2-64 chars: letters, digits, "-", "_").' },
1597
- 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).' },
1598
1646
  author: { type: 'string', description: 'Author credit for delivery export (max 120 characters).' },
1599
1647
  language: { type: 'string', description: 'Book language tag, e.g. zh-CN or en (default zh-CN).' },
1600
1648
  project_path: { type: 'string', description: 'Target directory on the server; omit to use the server default location.' },
1601
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.' },
1602
1659
  },
1603
1660
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1604
1661
  timeoutMs,
1605
- async execute(args, exec) {
1606
- const client = clientFor(exec);
1607
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(args.novel_id)) {
1608
- 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)}`);
1609
1760
  }
1610
- if (!args.title.trim() || args.title.length > 120)
1611
- throw new Error('title must be non-empty and at most 120 characters');
1612
- const body = { novel_id: args.novel_id, title: args.title };
1613
- if (args.author !== undefined)
1614
- body['author'] = args.author;
1615
- if (args.language !== undefined)
1616
- body['language'] = args.language;
1617
- if (args.project_path !== undefined)
1618
- body['project_path'] = args.project_path;
1619
- if (args.template !== undefined)
1620
- body['template'] = args.template;
1621
- return await client.postJson('/api/project/init', body, exec.signal);
1622
- },
1623
- }));
1624
- ctx.tools.register(defineTool({
1625
- name: 'novel_project_open',
1626
- description: 'Switch the Studio server to another existing project directory (must contain novel_config.yaml). ' +
1627
- 'Works without a currently open project; afterwards every other novel_* tool targets the newly opened project. ' +
1628
- 'The server rejects the switch while a background task is active.',
1629
- parameters: {
1630
- project_path: { type: 'string', required: true, description: 'Absolute project directory path on the server machine.' },
1631
- },
1632
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1633
- timeoutMs,
1634
- async execute(args, exec) {
1635
- const client = clientFor(exec);
1636
- if (!args.project_path.trim())
1637
- throw new Error('project_path must be a non-empty string');
1638
- return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
1639
- },
1640
- }));
1641
- ctx.tools.register(defineTool({
1642
- name: 'novel_project_delete',
1643
- description: 'PERMANENTLY delete a project directory and all its contents from the server machine (recursive delete, ' +
1644
- 'not recoverable). The server requires confirm to exactly equal the project\'s novel_id as a safeguard.',
1645
- parameters: {
1646
- project_path: { type: 'string', required: true, description: 'Project directory to delete.' },
1647
- confirm: { type: 'string', required: true, description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
1648
- },
1649
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1650
- timeoutMs,
1651
- async execute(args, exec) {
1652
- const client = clientFor(exec);
1653
- if (!args.project_path.trim() || !args.confirm.trim())
1654
- throw new Error('project_path and confirm are required');
1655
- return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm }, exec.signal);
1656
1761
  },
1657
1762
  }));
1658
1763
  // ── chapters, documents, import, sync ────────────────────────────────────
@@ -1660,12 +1765,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
1660
1765
  name: 'novel_chapter_delete',
1661
1766
  description: 'Delete the LATEST manuscript chapter. The server enforces three safeguards: the path must be a chapter under ' +
1662
1767
  'data/manuscript, confirm must exactly equal the chapter id (e.g. "ch_0007"), and version must equal the ' +
1663
- '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 ' +
1664
1769
  'while a background task is active.',
1665
1770
  parameters: {
1666
1771
  path: { type: 'string', required: true, description: 'Chapter path, e.g. "data/manuscript/ch_0007.md".' },
1667
1772
  confirm: { type: 'string', required: true, description: 'Must exactly equal the chapter id, e.g. "ch_0007".' },
1668
- 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").' },
1669
1774
  },
1670
1775
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1671
1776
  timeoutMs,
@@ -1683,7 +1788,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1683
1788
  parameters: {
1684
1789
  keep_through: { type: 'string', required: true, description: 'Last chapter to retain, e.g. "ch_0030".' },
1685
1790
  confirm: { type: 'string', required: true, description: 'Must exactly equal DELETE_AFTER:<keep_through>.' },
1686
- 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").' },
1687
1792
  },
1688
1793
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1689
1794
  timeoutMs,
@@ -1692,32 +1797,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
1692
1797
  return await client.postJson('/api/chapter/delete-batch', { keep_through: args.keep_through, confirm: args.confirm, version: args.version }, exec.signal);
1693
1798
  },
1694
1799
  }));
1800
+ // ── manuscript import ─────────────────────────────────────────────────────────
1695
1801
  ctx.tools.register(defineTool({
1696
- name: 'novel_doc_create',
1697
- description: 'Create a new project document by kind and name (e.g. a setting or character note), then returns the created document.',
1698
- parameters: {
1699
- kind: { type: 'string', required: true, description: 'Document kind (e.g. "setting", "character").' },
1700
- name: { type: 'string', required: true, description: 'Document name.' },
1701
- description: { type: 'string', description: 'Short description of the document.' },
1702
- },
1703
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1704
- timeoutMs,
1705
- async execute(args, exec) {
1706
- const client = clientFor(exec);
1707
- if (!args.kind.trim() || !args.name.trim())
1708
- throw new Error('kind and name must be non-empty strings');
1709
- const body = { kind: args.kind, name: args.name };
1710
- if (args.description !== undefined)
1711
- body['description'] = args.description;
1712
- return await client.postJson('/api/document/create', body, exec.signal);
1713
- },
1714
- }));
1715
- ctx.tools.register(defineTool({
1716
- name: 'novel_import_preview',
1717
- description: 'Preview a TXT/Markdown manuscript import without writing anything: detected chapters, numbering, and ' +
1718
- '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.',
1719
1805
  parameters: {
1720
- 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.' },
1721
1808
  filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1722
1809
  arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1723
1810
  start_number: { type: 'integer', description: 'First chapter number (default: after the last existing chapter).' },
@@ -1725,50 +1812,45 @@ export function registerNovelTools(ctx, clientFactory, options) {
1725
1812
  },
1726
1813
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1727
1814
  timeoutMs,
1728
- isConcurrencySafe: () => true,
1729
- async execute(args, exec) {
1730
- const client = clientFor(exec);
1731
- if (!args.content.trim())
1732
- throw new Error('content must be a non-empty string');
1733
- const body = { content: args.content };
1734
- if (args.filename !== undefined)
1735
- body['filename'] = args.filename;
1736
- if (args.arc_id !== undefined)
1737
- body['arc_id'] = args.arc_id;
1738
- if (args.start_number !== undefined)
1739
- body['start_number'] = args.start_number;
1740
- if (args.force !== undefined)
1741
- body['force'] = args.force;
1742
- return await client.postJson('/api/import/preview', body, exec.signal);
1743
- },
1744
- }));
1745
- ctx.tools.register(defineTool({
1746
- name: 'novel_import',
1747
- description: 'Import a TXT/Markdown manuscript as chapters (split on headings). Preview first with novel_import_preview; ' +
1748
- 'the server rejects conflicting chapter ids unless force is set.',
1749
- parameters: {
1750
- content: { type: 'string', required: true, description: 'The manuscript text to import.' },
1751
- filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1752
- arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1753
- start_number: { type: 'integer', description: 'First chapter number (default: continue numbering).' },
1754
- force: { type: 'boolean', description: 'Overwrite conflicting existing chapters (default false).' },
1755
- },
1756
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1757
- timeoutMs,
1758
- async execute(args, exec) {
1759
- const client = clientFor(exec);
1760
- if (!args.content.trim())
1761
- throw new Error('content must be a non-empty string');
1762
- const body = { content: args.content };
1763
- if (args.filename !== undefined)
1764
- body['filename'] = args.filename;
1765
- if (args.arc_id !== undefined)
1766
- body['arc_id'] = args.arc_id;
1767
- if (args.start_number !== undefined)
1768
- body['start_number'] = args.start_number;
1769
- if (args.force !== undefined)
1770
- body['force'] = args.force;
1771
- 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
+ }
1772
1854
  },
1773
1855
  }));
1774
1856
  ctx.tools.register(defineTool({
@@ -1784,7 +1866,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1784
1866
  limit: { type: 'integer', description: 'Maximum operations for list (default 50).' },
1785
1867
  filename: { type: 'string', description: 'Source filename ending in .txt, .md, or .markdown.' },
1786
1868
  content: { type: 'string', description: 'The author\'s own manuscript text for prepare.' },
1787
- 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.' },
1788
1870
  start_number: { type: 'integer', description: 'Optional first chapter number.' },
1789
1871
  expected_preview_revision: { type: 'string', description: 'Current preview revision required by structure/confirm.' },
1790
1872
  chapters: { type: 'array', items: { type: 'json' }, description: 'Edited chapter objects: chapter_id, title, content.' },
@@ -1801,9 +1883,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
1801
1883
  if (args.action === 'prepare') {
1802
1884
  if (!args.content?.trim())
1803
1885
  throw new Error('prepare requires non-empty content');
1804
- if (!args.arc_id?.trim())
1805
- throw new Error('prepare requires arc_id');
1806
- 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;
1807
1892
  if (args.start_number !== undefined)
1808
1893
  body['start_number'] = args.start_number;
1809
1894
  return await client.postJson('/api/manuscript-imports/prepare', body, exec.signal);
@@ -1843,95 +1928,6 @@ export function registerNovelTools(ctx, clientFactory, options) {
1843
1928
  return await client.postJson('/api/manuscript-imports/discard', { import_id: importId, confirm: true }, exec.signal);
1844
1929
  },
1845
1930
  }));
1846
- ctx.tools.register(defineTool({
1847
- name: 'novel_project_archive_action',
1848
- description: 'Inspect, create, and restore complete .owarchive project archives. Use preflight before create; use ' +
1849
- 'restore_preview before restore to inspect path/reference rewrites, conflicts, warnings, checksum, and the ' +
1850
- 'archived-without-auto-resume task policy. Restore requires the preview archive_sha256 and explicit confirmation.',
1851
- parameters: {
1852
- action: { type: 'string', required: true, enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle action.' },
1853
- archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
1854
- expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
1855
- target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
1856
- target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
1857
- reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
1858
- archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
1859
- confirm: { type: 'boolean', description: 'Must be true for restore.' },
1860
- },
1861
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1862
- timeoutMs,
1863
- async execute(args, exec) {
1864
- const client = clientFor(exec);
1865
- if (args.action === 'preflight')
1866
- return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
1867
- if (args.action === 'list')
1868
- return await client.getJson('/api/project-archives', {}, exec.signal);
1869
- if (args.action === 'create') {
1870
- if (!args.expected_preflight_revision?.trim())
1871
- throw new Error('create requires expected_preflight_revision');
1872
- return await client.postJson('/api/project-archives/create', {
1873
- expected_preflight_revision: args.expected_preflight_revision,
1874
- }, exec.signal);
1875
- }
1876
- const archiveId = args.archive_id?.trim();
1877
- if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
1878
- throw new Error(`${args.action} requires a valid archive_id`);
1879
- if (args.action === 'get')
1880
- return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
1881
- if (!args.target_root?.trim())
1882
- throw new Error(`${args.action} requires target_root`);
1883
- const body = {
1884
- archive_id: archiveId,
1885
- target_root: args.target_root,
1886
- reference_policy: args.reference_policy ?? 'preserve_relative',
1887
- };
1888
- if (args.target_novel_id !== undefined)
1889
- body['target_novel_id'] = args.target_novel_id;
1890
- if (args.action === 'restore_preview') {
1891
- return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
1892
- }
1893
- if (!args.archive_sha256?.trim())
1894
- throw new Error('restore requires archive_sha256');
1895
- if (args.confirm !== true)
1896
- throw new Error('restore requires confirm=true');
1897
- body['archive_sha256'] = args.archive_sha256;
1898
- body['confirm'] = true;
1899
- return await client.postJson('/api/project-archives/restore', body, exec.signal);
1900
- },
1901
- }));
1902
- ctx.tools.register(defineTool({
1903
- name: 'novel_project_archive_download',
1904
- description: 'Download one complete .owarchive project archive and save it in this workspace\'s local export directory.',
1905
- parameters: {
1906
- archive_id: { type: 'string', required: true, description: 'Archive id shaped owa_<24 lowercase hex>.' },
1907
- },
1908
- output: {
1909
- schema: {
1910
- type: 'object',
1911
- additionalProperties: false,
1912
- properties: {
1913
- path: { type: 'string', required: true },
1914
- filename: { type: 'string', required: true },
1915
- archive_id: { type: 'string', required: true },
1916
- bytes: { type: 'integer', required: true },
1917
- },
1918
- },
1919
- render: (_args, value) => [{ type: 'text', text: `Downloaded ${value.filename} (${value.bytes} bytes) to ${value.path}` }],
1920
- },
1921
- timeoutMs,
1922
- async execute(args, exec) {
1923
- if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
1924
- throw new Error('archive_id must match owa_<24 lowercase hex>');
1925
- const client = clientFor(exec);
1926
- const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
1927
- const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
1928
- const exportDir = outputDirFor(outputDir, client);
1929
- await mkdir(exportDir, { recursive: true });
1930
- const path = join(exportDir, filename);
1931
- await writeFile(path, download.content);
1932
- return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
1933
- },
1934
- }));
1935
1931
  ctx.tools.register(defineTool({
1936
1932
  name: 'novel_sync',
1937
1933
  description: 'Synchronize derived caches that are direct src derivatives: outline hierarchy (data/hierarchy.yaml) and ' +
@@ -1952,7 +1948,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1952
1948
  'actual chapter prose (never rewriting it) and commit chapter memory, runtime-truth notes and a ' +
1953
1949
  'character-state index refresh. Run once after importing an old manuscript so later chapters, reviews and ' +
1954
1950
  'canon/continuity checks have real facts from earlier chapters (without it, review canon stays inconclusive ' +
1955
- '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").',
1956
1952
  parameters: {
1957
1953
  chapter_ids: {
1958
1954
  type: 'array',
@@ -1976,7 +1972,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
1976
1972
  ctx.tools.register(defineTool({
1977
1973
  name: 'novel_writing_targets',
1978
1974
  description: 'Immediate compatibility write for targets the author has already authorized exactly; otherwise use ' +
1979
- '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, ' +
1980
1976
  'outline_volume_words 100-5000, outline_act_words 80-3000, outline_section_words 50-2000, ' +
1981
1977
  'outline_chapter_words 30-1000. Omitted fields keep their current values.',
1982
1978
  parameters: {
@@ -2089,9 +2085,11 @@ export function registerNovelTools(ctx, clientFactory, options) {
2089
2085
  description: 'Manage rolling plan candidates (mid-range plot planning windows). Actions: "list" (limit?), "create" ' +
2090
2086
  '(current_arc?, window_size? default 50, max 50). current_window/accepted_window is accepted manuscript ' +
2091
2087
  'facts only; next_window/planned_window is outline plan, not facts. "get" (candidate_id), "stage" ' +
2092
- '(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), ' +
2093
2090
  '"delete" (candidate_id + revision), "apply" (candidate_id + revision — appends staged draft chapters ' +
2094
- '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").',
2095
2093
  parameters: {
2096
2094
  action: { type: 'string', required: true, enum: ['list', 'create', 'get', 'stage', 'delete', 'apply'], description: 'The rolling-plan operation.' },
2097
2095
  limit: { type: 'integer', description: 'List limit (default 20).' },
@@ -2155,7 +2153,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2155
2153
  '"compare" (chapter_id + version_id — preview current-to-snapshot differences without writing), ' +
2156
2154
  '"restore" (chapter_id + version_id + revision — REVERTS the chapter to a snapshot; revision-gated and ' +
2157
2155
  'confirmation-gated: the server demands confirm: true), "annotations" (chapter_id), "annotate" (chapter_id + ' +
2158
- '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), ' +
2159
2157
  '"resolve_annotation" (chapter_id + annotation_id).',
2160
2158
  parameters: {
2161
2159
  action: { type: 'string', required: true, enum: ['drafts', 'restore_draft', 'versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
@@ -2182,18 +2180,52 @@ export function registerNovelTools(ctx, clientFactory, options) {
2182
2180
  if (args.action === 'restore_draft') {
2183
2181
  if (!args.run_id_v2)
2184
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
+ }
2185
2193
  return await client.postJson('/api/manuscript/drafts/restore', {
2186
- 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,
2187
2195
  }, exec.signal);
2188
2196
  }
2189
2197
  if (!args.chapter_id || !CHAPTER_ID_PATTERN.test(args.chapter_id))
2190
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
+ }
2191
2210
  const body = { action: args.action, chapter_id: args.chapter_id };
2192
- 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']) {
2193
2221
  const value = args[key];
2194
2222
  if (value !== undefined)
2195
2223
  body[key] = value;
2196
2224
  }
2225
+ if (startHint !== undefined)
2226
+ body['start_hint'] = startHint;
2227
+ if (endHint !== undefined)
2228
+ body['end_hint'] = endHint;
2197
2229
  return await client.postJson('/api/manuscript-editing', body, exec.signal);
2198
2230
  },
2199
2231
  }));
@@ -2216,15 +2248,22 @@ export function registerNovelTools(ctx, clientFactory, options) {
2216
2248
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2217
2249
  timeoutMs,
2218
2250
  async execute(args, exec) {
2219
- 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)) {
2220
2257
  throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2221
2258
  }
2222
2259
  const body = {};
2223
- 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']) {
2224
2261
  const value = args[key];
2225
2262
  if (value !== undefined)
2226
2263
  body[key] = value;
2227
2264
  }
2265
+ if (chapterId)
2266
+ body['chapter_id'] = chapterId;
2228
2267
  if (args.action === 'acknowledge' && args.domains === undefined)
2229
2268
  body['domains'] = ['outline', 'foreshadowing'];
2230
2269
  const route = args.action === 'acknowledge' ? 'ack' : args.action === 'resume' ? 'reconcile' : args.action;
@@ -2250,8 +2289,19 @@ export function registerNovelTools(ctx, clientFactory, options) {
2250
2289
  const client = clientFor(exec);
2251
2290
  if (args.action === 'get')
2252
2291
  return await client.getJson('/api/writing-assets', {}, exec.signal);
2253
- if (args.action === 'save_anchor')
2254
- 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
+ }
2255
2305
  if (args.action === 'mark_usage')
2256
2306
  return await client.postJson('/api/writing-assets', { action: args.action, chapter_id: args.chapter_id ?? '', groups: requireRecord(args.groups, 'groups') }, exec.signal);
2257
2307
  const assets = requireRecord(args.assets, 'assets');
@@ -2333,7 +2383,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2333
2383
  chunk_id: { type: 'string', description: 'Chunk to retry (retry_v2).' },
2334
2384
  source_ids: { type: 'array', items: { type: 'string' }, description: 'Sources to synthesize (synthesize_v2).' },
2335
2385
  profile_id: { type: 'string', description: 'Analysis profile id (profile_v2/promotion_preview_v2).' },
2336
- 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").' },
2337
2387
  preview_id: { type: 'string', description: 'Promotion preview id (promote_v2).' },
2338
2388
  confirm: { type: 'boolean', description: 'Confirm a gated promotion (default false).' },
2339
2389
  },
@@ -2342,6 +2392,15 @@ export function registerNovelTools(ctx, clientFactory, options) {
2342
2392
  isConcurrencySafe: (args) => args.action === 'status_v2' || args.action === 'profile_v2' || args.action === 'promotion_preview_v2',
2343
2393
  async execute(args, exec) {
2344
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
+ }
2345
2404
  const body = { action: args.action };
2346
2405
  for (const key of ['source_id', 'content', 'focus', 'relative_name', 'input_budget_tokens', 'chunk_id', 'source_ids', 'profile_id', 'target', 'preview_id', 'confirm']) {
2347
2406
  const value = args[key];
@@ -2379,7 +2438,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2379
2438
  },
2380
2439
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2381
2440
  timeoutMs,
2382
- 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',
2383
2442
  async execute(args, exec) {
2384
2443
  const client = clientFor(exec);
2385
2444
  const body = { action: args.action };
@@ -2444,7 +2503,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2444
2503
  // ── deep research ────────────────────────────────────────────────────────
2445
2504
  ctx.tools.register(defineTool({
2446
2505
  name: 'novel_research_status',
2447
- 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".',
2448
2507
  parameters: {},
2449
2508
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2450
2509
  timeoutMs,
@@ -2487,16 +2546,100 @@ export function registerNovelTools(ctx, clientFactory, options) {
2487
2546
  },
2488
2547
  }));
2489
2548
  // ── model configuration ──────────────────────────────────────────────────
2549
+ // ── model configuration ───────────────────────────────────────────────────────
2490
2550
  ctx.tools.register(defineTool({
2491
2551
  name: 'novel_model_profiles',
2492
- description: 'List the server\'s model profiles and per-task routing (which provider/model OpenWrite uses for writing, review, etc.).',
2493
- 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
+ },
2494
2570
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2495
2571
  timeoutMs,
2496
- isConcurrencySafe: () => true,
2497
- async execute(_args, exec) {
2498
- const client = clientFor(exec);
2499
- 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
+ }
2500
2643
  },
2501
2644
  }));
2502
2645
  ctx.tools.register(defineTool({
@@ -2555,151 +2698,4 @@ export function registerNovelTools(ctx, clientFactory, options) {
2555
2698
  return await client.postJson('/api/benchmarks', body, exec.signal);
2556
2699
  },
2557
2700
  }));
2558
- ctx.tools.register(defineTool({
2559
- name: 'novel_model_configure',
2560
- description: 'Apply and persist the server\'s active model connection (OpenWrite\'s own LLM for writing/review — ' +
2561
- 'independent of the dsh model). max_tokens must stay below context_tokens.',
2562
- parameters: {
2563
- provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2564
- model: { type: 'string', required: true, description: 'Model name (max 120 characters).' },
2565
- api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2566
- base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2567
- api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2568
- context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
2569
- max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
2570
- remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2571
- },
2572
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2573
- timeoutMs,
2574
- async execute(args, exec) {
2575
- const client = clientFor(exec);
2576
- if (!args.model.trim() || args.model.length > 120)
2577
- throw new Error('model must be non-empty and at most 120 characters');
2578
- const body = { model: args.model };
2579
- for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
2580
- const value = args[key];
2581
- if (value !== undefined)
2582
- body[key] = value;
2583
- }
2584
- return await client.postJson('/api/model', body, exec.signal);
2585
- },
2586
- }));
2587
- ctx.tools.register(defineTool({
2588
- name: 'novel_model_test',
2589
- description: 'Test a model connection without changing active settings. Pass id to test a stored profile, or a full ' +
2590
- 'candidate (provider/model/base_url/api_key...).',
2591
- parameters: {
2592
- id: { type: 'string', description: 'Stored profile id to test.' },
2593
- provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2594
- model: { type: 'string', description: 'Model name (required when id is omitted).' },
2595
- base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2596
- api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2597
- api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2598
- context_tokens: { type: 'integer', description: 'Context budget.' },
2599
- max_tokens: { type: 'integer', description: 'Max output tokens.' },
2600
- },
2601
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2602
- timeoutMs,
2603
- isConcurrencySafe: () => true,
2604
- async execute(args, exec) {
2605
- const client = clientFor(exec);
2606
- if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
2607
- throw new Error('either id (stored profile) or model (candidate) is required');
2608
- }
2609
- const body = {};
2610
- for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
2611
- const value = args[key];
2612
- if (value !== undefined)
2613
- body[key] = value;
2614
- }
2615
- return await client.postJson('/api/model/test', body, exec.signal);
2616
- },
2617
- }));
2618
- ctx.tools.register(defineTool({
2619
- name: 'novel_model_embedding_test',
2620
- description: 'Probe an independent Embedding profile without changing Chat profiles.',
2621
- parameters: {
2622
- id: { type: 'string', description: 'Stored profile id to probe.' },
2623
- candidate: { type: 'json', description: 'Embedding candidate settings object (merged into the request).' },
2624
- api_key: { type: 'string', description: 'Embedding API key override.' },
2625
- },
2626
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2627
- timeoutMs,
2628
- isConcurrencySafe: () => true,
2629
- async execute(args, exec) {
2630
- const client = clientFor(exec);
2631
- const body = {};
2632
- if (args.candidate !== undefined) {
2633
- if (typeof args.candidate !== 'object' || args.candidate === null || Array.isArray(args.candidate)) {
2634
- throw new Error('candidate must be a JSON object');
2635
- }
2636
- Object.assign(body, args.candidate);
2637
- }
2638
- for (const key of ['id', 'api_key']) {
2639
- const value = args[key];
2640
- if (value !== undefined)
2641
- body[key] = value;
2642
- }
2643
- return await client.postJson('/api/model/embedding/test', body, exec.signal);
2644
- },
2645
- }));
2646
- ctx.tools.register(defineTool({
2647
- name: 'novel_model_profile_save',
2648
- description: 'Save (create or update) a named model profile on the server, with an optional API key.',
2649
- parameters: {
2650
- profile: { type: 'json', required: true, description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
2651
- api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
2652
- remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
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 (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
2659
- throw new Error('profile must be a JSON object');
2660
- }
2661
- const body = { ...args.profile };
2662
- for (const key of ['api_key', 'remember_api_key']) {
2663
- const value = args[key];
2664
- if (value !== undefined)
2665
- body[key] = value;
2666
- }
2667
- return await client.postJson('/api/model/profiles', body, exec.signal);
2668
- },
2669
- }));
2670
- ctx.tools.register(defineTool({
2671
- name: 'novel_model_profile_delete',
2672
- description: 'Delete a stored model profile. Routes pointing at it fall back to fallback_id.',
2673
- parameters: {
2674
- profile_id: { type: 'string', required: true, description: 'Profile id to delete.' },
2675
- fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
2676
- },
2677
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2678
- timeoutMs,
2679
- async execute(args, exec) {
2680
- const client = clientFor(exec);
2681
- if (!args.profile_id.trim())
2682
- throw new Error('profile_id must be a non-empty string');
2683
- const body = { profile_id: args.profile_id };
2684
- if (args.fallback_id !== undefined)
2685
- body['fallback_id'] = args.fallback_id;
2686
- return await client.postJson('/api/model/profiles/delete', body, exec.signal);
2687
- },
2688
- }));
2689
- ctx.tools.register(defineTool({
2690
- name: 'novel_model_routes_save',
2691
- description: 'Save per-task model routing: map operation names (e.g. "chapter_write", "review", "research") to profile ids.',
2692
- parameters: {
2693
- routes: { type: 'json', required: true, description: 'Object mapping operation names to profile ids.' },
2694
- },
2695
- output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2696
- timeoutMs,
2697
- async execute(args, exec) {
2698
- const client = clientFor(exec);
2699
- if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
2700
- throw new Error('routes must be a JSON object');
2701
- }
2702
- return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
2703
- },
2704
- }));
2705
2701
  }