dsh-openwrite 0.2.8

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 (275) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +107 -0
  3. package/THIRD_PARTY_NOTICES.md +33 -0
  4. package/assets/logo-dark.svg +31 -0
  5. package/assets/logo-light.svg +31 -0
  6. package/cordis.patch.yml +11 -0
  7. package/docs/BENCHMARK_TASKS.md +54 -0
  8. package/docs/COST_MEASUREMENT.md +39 -0
  9. package/docs/DEVELOPMENT.md +65 -0
  10. package/docs/DSH_STORE.md +58 -0
  11. package/docs/FRAMEWORK_ADVANTAGES.md +288 -0
  12. package/docs/IMPROVEMENT_PLAN.md +252 -0
  13. package/docs/INSTALL.md +99 -0
  14. package/docs/LEARNING.md +68 -0
  15. package/docs/OPEN_SOURCE_NOVEL_AUDIT.md +191 -0
  16. package/docs/PLUGIN_MAINTENANCE.md +54 -0
  17. package/docs/PLUGIN_MAINTENANCE_RC7.md +157 -0
  18. package/docs/QA_20260908.md +103 -0
  19. package/docs/QA_20260909_BENCHMARK.md +24 -0
  20. package/docs/RELEASE_ACCEPTANCE.md +116 -0
  21. package/docs/REVIEW_DAG_FRAMEWORK.md +82 -0
  22. package/docs/TARGETED_NOVEL_PROJECT_REVIEW.md +167 -0
  23. package/docs/WORKFLOWS.md +74 -0
  24. package/docs/WORKSPACE_CONTEXT_CONTRACT.md +125 -0
  25. package/docs/WORKSPACE_INTEGRATION_GOAL.md +278 -0
  26. package/docs/research/autosave-race-repro.mjs +133 -0
  27. package/docs/research/framework-stats-2026-09-05.json +116 -0
  28. package/dog-tools.mjs +11 -0
  29. package/package.json +132 -0
  30. package/packages/openwrite-bridge/LICENSE +201 -0
  31. package/packages/openwrite-bridge/cordis.patch.yml +10 -0
  32. package/packages/openwrite-bridge/lib/client.d.ts +95 -0
  33. package/packages/openwrite-bridge/lib/client.js +193 -0
  34. package/packages/openwrite-bridge/lib/contracts-generated.d.ts +367 -0
  35. package/packages/openwrite-bridge/lib/contracts-generated.js +1764 -0
  36. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +12 -0
  37. package/packages/openwrite-bridge/lib/dog-delivery.js +362 -0
  38. package/packages/openwrite-bridge/lib/dog-review.d.ts +50 -0
  39. package/packages/openwrite-bridge/lib/dog-review.js +470 -0
  40. package/packages/openwrite-bridge/lib/domain.d.ts +70 -0
  41. package/packages/openwrite-bridge/lib/domain.js +496 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +17 -0
  43. package/packages/openwrite-bridge/lib/index.js +89 -0
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +95 -0
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +510 -0
  46. package/packages/openwrite-bridge/lib/preset-tools.d.ts +13 -0
  47. package/packages/openwrite-bridge/lib/preset-tools.js +18 -0
  48. package/packages/openwrite-bridge/lib/tool-policy.d.ts +6 -0
  49. package/packages/openwrite-bridge/lib/tool-policy.js +56 -0
  50. package/packages/openwrite-bridge/lib/tools.d.ts +24 -0
  51. package/packages/openwrite-bridge/lib/tools.js +2404 -0
  52. package/packages/openwrite-bridge/package.json +67 -0
  53. package/packages/studio-panel/LICENSE +21 -0
  54. package/packages/studio-panel/README.md +51 -0
  55. package/packages/studio-panel/cordis.patch.yml +5 -0
  56. package/packages/studio-panel/lib/client.js +158087 -0
  57. package/packages/studio-panel/lib/client.js.map +1 -0
  58. package/packages/studio-panel/lib/index.js +50 -0
  59. package/packages/studio-panel/lib/types/client/AssetEditor.d.ts +122 -0
  60. package/packages/studio-panel/lib/types/client/AssetsView.d.ts +43 -0
  61. package/packages/studio-panel/lib/types/client/BenchmarkView.d.ts +9 -0
  62. package/packages/studio-panel/lib/types/client/ContinuousReader.d.ts +13 -0
  63. package/packages/studio-panel/lib/types/client/CreationView.d.ts +6 -0
  64. package/packages/studio-panel/lib/types/client/DagCanvas.d.ts +11 -0
  65. package/packages/studio-panel/lib/types/client/DiscardDraftDialog.d.ts +8 -0
  66. package/packages/studio-panel/lib/types/client/DomainToolCard.d.ts +9 -0
  67. package/packages/studio-panel/lib/types/client/GraphView.d.ts +46 -0
  68. package/packages/studio-panel/lib/types/client/HeaderChrome.d.ts +18 -0
  69. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +13 -0
  70. package/packages/studio-panel/lib/types/client/LibraryView.d.ts +6 -0
  71. package/packages/studio-panel/lib/types/client/MarkdownText.d.ts +3 -0
  72. package/packages/studio-panel/lib/types/client/ModelView.d.ts +9 -0
  73. package/packages/studio-panel/lib/types/client/OperationsView.d.ts +6 -0
  74. package/packages/studio-panel/lib/types/client/OutlinePlans.d.ts +37 -0
  75. package/packages/studio-panel/lib/types/client/OutlineView.d.ts +29 -0
  76. package/packages/studio-panel/lib/types/client/ResearchView.d.ts +7 -0
  77. package/packages/studio-panel/lib/types/client/ReviewCard.d.ts +28 -0
  78. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +55 -0
  79. package/packages/studio-panel/lib/types/client/SearchView.d.ts +33 -0
  80. package/packages/studio-panel/lib/types/client/TasksView.d.ts +11 -0
  81. package/packages/studio-panel/lib/types/client/TransferWorkspaces.d.ts +19 -0
  82. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +62 -0
  83. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +55 -0
  84. package/packages/studio-panel/lib/types/client/WorkbenchStore.d.ts +121 -0
  85. package/packages/studio-panel/lib/types/client/api.d.ts +72 -0
  86. package/packages/studio-panel/lib/types/client/asset-drafts.d.ts +20 -0
  87. package/packages/studio-panel/lib/types/client/benchmark-options.d.ts +43 -0
  88. package/packages/studio-panel/lib/types/client/draft-store.d.ts +26 -0
  89. package/packages/studio-panel/lib/types/client/dto.d.ts +517 -0
  90. package/packages/studio-panel/lib/types/client/index.d.ts +4 -0
  91. package/packages/studio-panel/lib/types/client/locales.d.ts +15 -0
  92. package/packages/studio-panel/lib/types/client/manuscript-annotations.d.ts +59 -0
  93. package/packages/studio-panel/lib/types/client/manuscript-markers.d.ts +61 -0
  94. package/packages/studio-panel/lib/types/client/manuscript-mentions.d.ts +30 -0
  95. package/packages/studio-panel/lib/types/client/manuscript-overlay-dom.d.ts +7 -0
  96. package/packages/studio-panel/lib/types/client/manuscript-overlay.d.ts +42 -0
  97. package/packages/studio-panel/lib/types/client/manuscript-selection.d.ts +59 -0
  98. package/packages/studio-panel/lib/types/client/model-view-memory.d.ts +17 -0
  99. package/packages/studio-panel/lib/types/client/storage.d.ts +8 -0
  100. package/packages/studio-panel/lib/types/client/workbench-epochs.d.ts +16 -0
  101. package/packages/studio-panel/lib/types/client/workspace-context.d.ts +52 -0
  102. package/packages/studio-panel/lib/types/client/writing-scope.d.ts +7 -0
  103. package/packages/studio-panel/lib/types/index.d.ts +5 -0
  104. package/packages/studio-panel/package.json +105 -0
  105. package/packages/studio-panel/vendor/vditor/LICENSE +21 -0
  106. package/packages/studio-panel/vendor/vditor/dist/css/content-theme/dark.css +123 -0
  107. package/packages/studio-panel/vendor/vditor/dist/css/content-theme/light.css +101 -0
  108. package/packages/studio-panel/vendor/vditor/dist/index.css +1823 -0
  109. package/packages/studio-panel/vendor/vditor/dist/index.min.js +1 -0
  110. package/packages/studio-panel/vendor/vditor/dist/js/i18n/zh_CN.js +86 -0
  111. package/packages/studio-panel/vendor/vditor/dist/js/icons/ant.js +174 -0
  112. package/packages/studio-panel/vendor/vditor/dist/js/lute/lute.min.js +72 -0
  113. package/plugin.d.ts +3 -0
  114. package/plugin.mjs +80 -0
  115. package/presets/README.md +32 -0
  116. package/presets/openwrite/agent.cordis.yml +317 -0
  117. package/presets/openwrite/preset.yml +3 -0
  118. package/presets/openwrite/skills/canon/SKILL.md +15 -0
  119. package/presets/openwrite/skills/dialoguequality/SKILL.md +166 -0
  120. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +40 -0
  121. package/presets/openwrite/skills/dog-import-query/SKILL.md +27 -0
  122. package/presets/openwrite/skills/dog-review-query/SKILL.md +40 -0
  123. package/presets/openwrite/skills/export-book/SKILL.md +14 -0
  124. package/presets/openwrite/skills/foreshadow/SKILL.md +14 -0
  125. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +125 -0
  126. package/presets/openwrite/skills/learn/SKILL.md +24 -0
  127. package/presets/openwrite/skills/novel-creator/SKILL.md +88 -0
  128. package/presets/openwrite/skills/novel-creator/templates/beat_templates.yaml +179 -0
  129. package/presets/openwrite/skills/novel-creator/templates/example_usage.yaml +74 -0
  130. package/presets/openwrite/skills/novel-reviewer/SKILL.md +85 -0
  131. package/presets/openwrite/skills/oh-story-deslop/LICENSE +21 -0
  132. package/presets/openwrite/skills/oh-story-deslop/SKILL.md +36 -0
  133. package/presets/openwrite/skills/oh-story-deslop/agents/openai.yaml +4 -0
  134. package/presets/openwrite/skills/oh-story-deslop/references/method.md +22 -0
  135. package/presets/openwrite/skills/oh-story-long-analyze/LICENSE +21 -0
  136. package/presets/openwrite/skills/oh-story-long-analyze/SKILL.md +37 -0
  137. package/presets/openwrite/skills/oh-story-long-analyze/agents/openai.yaml +4 -0
  138. package/presets/openwrite/skills/oh-story-long-analyze/references/method.md +22 -0
  139. package/presets/openwrite/skills/oh-story-long-scan/LICENSE +21 -0
  140. package/presets/openwrite/skills/oh-story-long-scan/SKILL.md +36 -0
  141. package/presets/openwrite/skills/oh-story-long-scan/agents/openai.yaml +4 -0
  142. package/presets/openwrite/skills/oh-story-long-scan/references/method.md +21 -0
  143. package/presets/openwrite/skills/oh-story-long-write/LICENSE +21 -0
  144. package/presets/openwrite/skills/oh-story-long-write/SKILL.md +37 -0
  145. package/presets/openwrite/skills/oh-story-long-write/agents/openai.yaml +4 -0
  146. package/presets/openwrite/skills/oh-story-long-write/references/method.md +33 -0
  147. package/presets/openwrite/skills/oh-story-review/LICENSE +21 -0
  148. package/presets/openwrite/skills/oh-story-review/SKILL.md +36 -0
  149. package/presets/openwrite/skills/oh-story-review/agents/openai.yaml +4 -0
  150. package/presets/openwrite/skills/oh-story-review/references/method.md +25 -0
  151. package/presets/openwrite/skills/progress/SKILL.md +16 -0
  152. package/presets/openwrite/skills/review-chapter/SKILL.md +17 -0
  153. package/presets/openwrite/skills/revise-span/SKILL.md +16 -0
  154. package/presets/openwrite/skills/style-system/SKILL.md +135 -0
  155. package/presets/openwrite/skills/style-system/prompts/analyze_style.md +214 -0
  156. package/presets/openwrite/skills/style-system/prompts/batch_extract_style.md +66 -0
  157. package/presets/openwrite/skills/style-system/prompts/compose_style.md +83 -0
  158. package/presets/openwrite/skills/style-system/prompts/extract_style.md +84 -0
  159. package/presets/openwrite/skills/style-system/prompts/initialize_style.md +52 -0
  160. package/presets/openwrite/skills/style-system/templates/analysis_report.yaml +78 -0
  161. package/presets/openwrite/skills/style-system/templates/composed_style.md +96 -0
  162. package/presets/openwrite/skills/style-system/templates/extraction_report.yaml +80 -0
  163. package/presets/openwrite/skills/style-system/templates/fingerprint.yaml +61 -0
  164. package/presets/openwrite/skills/style-system/tools/README.md +33 -0
  165. package/presets/openwrite/skills/truth-validation/SKILL.md +119 -0
  166. package/presets/openwrite/skills/workflow-manager/SKILL.md +81 -0
  167. package/presets/openwrite/skills/world-query/SKILL.md +45 -0
  168. package/presets/openwrite/skills/write-next/SKILL.md +17 -0
  169. package/release/core-requirements.in +12 -0
  170. package/release/licenses/@deepseek-ai__cosmokit@1.8.3/LICENSE +21 -0
  171. package/release/licenses/@deepseek-ai__schemastery@3.18.2/LICENSE +21 -0
  172. package/release/licenses/@isaacs__fs-minipass@4.0.1/LICENSE +15 -0
  173. package/release/licenses/@standard-schema__spec@1.1.0/LICENSE +21 -0
  174. package/release/licenses/@types__d3-color@3.1.3/LICENSE +21 -0
  175. package/release/licenses/@types__d3-drag@3.0.7/LICENSE +21 -0
  176. package/release/licenses/@types__d3-interpolate@3.0.4/LICENSE +21 -0
  177. package/release/licenses/@types__d3-selection@3.0.11/LICENSE +21 -0
  178. package/release/licenses/@types__d3-transition@3.0.9/LICENSE +21 -0
  179. package/release/licenses/@types__d3-zoom@3.0.8/LICENSE +21 -0
  180. package/release/licenses/@xyflow__react@12.11.6/LICENSE +21 -0
  181. package/release/licenses/@xyflow__system@0.0.82/LICENSE +21 -0
  182. package/release/licenses/ajv-formats@3.0.1/LICENSE +21 -0
  183. package/release/licenses/ajv@8.20.0/LICENSE +22 -0
  184. package/release/licenses/chownr@3.0.0/LICENSE.md +63 -0
  185. package/release/licenses/classcat@5.0.5/LICENSE.md +7 -0
  186. package/release/licenses/commander@7.2.0/LICENSE +22 -0
  187. package/release/licenses/d3-array@3.2.4/LICENSE +13 -0
  188. package/release/licenses/d3-axis@3.0.0/LICENSE +13 -0
  189. package/release/licenses/d3-brush@3.0.0/LICENSE +13 -0
  190. package/release/licenses/d3-chord@3.0.1/LICENSE +13 -0
  191. package/release/licenses/d3-color@3.1.0/LICENSE +13 -0
  192. package/release/licenses/d3-contour@4.0.2/LICENSE +13 -0
  193. package/release/licenses/d3-delaunay@6.0.4/LICENSE +14 -0
  194. package/release/licenses/d3-dispatch@3.0.1/LICENSE +13 -0
  195. package/release/licenses/d3-drag@3.0.0/LICENSE +13 -0
  196. package/release/licenses/d3-dsv@3.0.1/LICENSE +13 -0
  197. package/release/licenses/d3-ease@3.0.1/LICENSE +28 -0
  198. package/release/licenses/d3-fetch@3.0.1/LICENSE +13 -0
  199. package/release/licenses/d3-force@3.0.0/LICENSE +13 -0
  200. package/release/licenses/d3-format@3.1.2/LICENSE +13 -0
  201. package/release/licenses/d3-geo@3.1.1/LICENSE +34 -0
  202. package/release/licenses/d3-hierarchy@3.1.2/LICENSE +13 -0
  203. package/release/licenses/d3-interpolate@3.0.1/LICENSE +13 -0
  204. package/release/licenses/d3-path@3.1.0/LICENSE +13 -0
  205. package/release/licenses/d3-polygon@3.0.1/LICENSE +13 -0
  206. package/release/licenses/d3-quadtree@3.0.1/LICENSE +13 -0
  207. package/release/licenses/d3-random@3.0.1/LICENSE +13 -0
  208. package/release/licenses/d3-scale-chromatic@3.1.0/LICENSE +28 -0
  209. package/release/licenses/d3-scale@4.0.2/LICENSE +13 -0
  210. package/release/licenses/d3-selection@3.0.0/LICENSE +13 -0
  211. package/release/licenses/d3-shape@3.2.0/LICENSE +13 -0
  212. package/release/licenses/d3-time-format@4.1.0/LICENSE +13 -0
  213. package/release/licenses/d3-time@3.1.0/LICENSE +13 -0
  214. package/release/licenses/d3-timer@3.0.1/LICENSE +13 -0
  215. package/release/licenses/d3-transition@3.0.1/LICENSE +13 -0
  216. package/release/licenses/d3-zoom@3.0.0/LICENSE +13 -0
  217. package/release/licenses/d3@7.9.0/LICENSE +13 -0
  218. package/release/licenses/delaunator@5.1.0/LICENSE +15 -0
  219. package/release/licenses/elkjs@0.12.0/LICENSE.md +263 -0
  220. package/release/licenses/fast-deep-equal@3.1.3/LICENSE +21 -0
  221. package/release/licenses/fast-uri@3.1.7/LICENSE +30 -0
  222. package/release/licenses/fflate@0.8.2/LICENSE +21 -0
  223. package/release/licenses/graceful-fs@4.2.11/LICENSE +15 -0
  224. package/release/licenses/iconv-lite@0.6.3/LICENSE +21 -0
  225. package/release/licenses/internmap@2.0.3/LICENSE +13 -0
  226. package/release/licenses/json-schema-traverse@1.0.0/LICENSE +21 -0
  227. package/release/licenses/lucide-react@0.468.0/LICENSE +15 -0
  228. package/release/licenses/minipass@7.1.3/LICENSE.md +55 -0
  229. package/release/licenses/minizlib@3.1.0/LICENSE +26 -0
  230. package/release/licenses/proper-lockfile@4.1.2/LICENSE +21 -0
  231. package/release/licenses/require-from-string@2.0.2/license +21 -0
  232. package/release/licenses/retry@0.12.0/License +21 -0
  233. package/release/licenses/robust-predicates@3.0.3/LICENSE +24 -0
  234. package/release/licenses/rw@1.3.3/LICENSE +26 -0
  235. package/release/licenses/safer-buffer@2.1.2/LICENSE +21 -0
  236. package/release/licenses/signal-exit@3.0.7/LICENSE.txt +16 -0
  237. package/release/licenses/tar@7.5.7/LICENSE.md +55 -0
  238. package/release/licenses/use-sync-external-store@1.6.0/LICENSE +21 -0
  239. package/release/licenses/yallist@5.0.0/LICENSE.md +63 -0
  240. package/release/licenses/zustand@4.5.7/LICENSE +21 -0
  241. package/release/openwrite-5.8.1-py3-none-any.whl +0 -0
  242. package/release/platform-overrides.in +5 -0
  243. package/release/python-constraints.in +96 -0
  244. package/release/requirements.lock +2927 -0
  245. package/release/runtime-manifest.json +98 -0
  246. package/release/sdk-compatibility.json +240 -0
  247. package/release/source.json +859 -0
  248. package/release/wheels/cryptography-50.0.1-cp312-abi3-macosx_13_0_x86_64.whl +0 -0
  249. package/release/wheels/licenses/LICENSE.txt +177 -0
  250. package/release/wheels/provenance.json +15 -0
  251. package/scripts/dog/delivery-stage.js +32 -0
  252. package/scripts/dog/import-chapter.js +20 -0
  253. package/scripts/dog/import-record.js +35 -0
  254. package/scripts/dog/review-dimension.js +41 -0
  255. package/scripts/dog/review-record.js +42 -0
  256. package/scripts/maintenance.mjs +179 -0
  257. package/scripts/prepare.mjs +21 -0
  258. package/vendor/dsh-dog/LICENSE +29 -0
  259. package/vendor/dsh-dog/README.md +114 -0
  260. package/vendor/dsh-dog/UPSTREAM.md +22 -0
  261. package/vendor/dsh-dog/cordis.patch.yml +14 -0
  262. package/vendor/dsh-dog/lib/client.js +2898 -0
  263. package/vendor/dsh-dog/lib/client.js.map +1 -0
  264. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +495 -0
  265. package/vendor/dsh-dog/lib/core-DdfMcrB4.js +2074 -0
  266. package/vendor/dsh-dog/lib/core.d.ts +2 -0
  267. package/vendor/dsh-dog/lib/core.js +2 -0
  268. package/vendor/dsh-dog/lib/index.d.ts +133 -0
  269. package/vendor/dsh-dog/lib/index.js +967 -0
  270. package/vendor/dsh-dog/package.json +97 -0
  271. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +139 -0
  272. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +87 -0
  273. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +259 -0
  274. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +105 -0
  275. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +40 -0
@@ -0,0 +1,2404 @@
1
+ /**
2
+ * The `novel_*` tool definitions bridging dsh agents to the OpenWrite Studio
3
+ * HTTP action surface. Payload shapes are verified against OpenWrite
4
+ * `tools/studio_http.py` (POST_ROUTES / do_GET / do_PUT) and
5
+ * `tools/studio_application.py` (the dispatched methods).
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { realpathSync } from 'node:fs';
9
+ import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { isAbsolute, join } from 'node:path';
11
+ import { defineTool } from '@deepseek-ai/dsh-tools';
12
+ import { StudioError } from './client.js';
13
+ import { materializeChapterDelivery } from './dog-delivery.js';
14
+ import { materializeCompletedDogTaskReview, materializeDogReview, reviewChapterId } from './dog-review.js';
15
+ /** Render an arbitrary JSON payload as pretty-printed model-facing text. */
16
+ function renderJson(value) {
17
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }];
18
+ }
19
+ /** The schema for tools whose canonical value is an opaque Studio JSON payload. */
20
+ const JSON_OUTPUT_SCHEMA = {
21
+ type: 'json',
22
+ description: 'The JSON payload returned by the OpenWrite Studio API.',
23
+ };
24
+ /** Manuscript chapter ids accepted by OpenWrite (`ch_<digits>`). */
25
+ const CHAPTER_ID_PATTERN = /^ch_\d+$/;
26
+ /** Resolve a review target: an explicit manuscript path, or a chapter id mapped to `data/manuscript/<id>.md`. */
27
+ function resolveReviewPath(args) {
28
+ const path = args.path?.trim();
29
+ if (path)
30
+ return path;
31
+ const chapterId = args.chapter_id?.trim();
32
+ if (!chapterId)
33
+ throw new Error('either path or chapter_id is required');
34
+ if (!CHAPTER_ID_PATTERN.test(chapterId))
35
+ throw new Error(`chapter_id must match ch_<digits>, got "${chapterId}"`);
36
+ return `data/manuscript/${chapterId}.md`;
37
+ }
38
+ function objectValue(value) {
39
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
40
+ }
41
+ async function deliveryForResponse(client, response, chapterId, signal, threshold) {
42
+ const embedded = objectValue(response)['workspace'];
43
+ const workspace = embedded !== null && typeof embedded === 'object' && !Array.isArray(embedded)
44
+ ? embedded
45
+ : await client.getJson('/api/workspace', {}, signal);
46
+ return await materializeChapterDelivery(workspace, chapterId, threshold, client.context?.workspaceRoot);
47
+ }
48
+ /**
49
+ * Derive the request-scoped Workspace context from the tool execution, failing
50
+ * closed (docs/WORKSPACE_CONTEXT_CONTRACT.md §2.1): the stamped session cwd is
51
+ * the only root source — never a fallback to the plugin cwd or a fixed project.
52
+ */
53
+ export function workspaceContextFromExec(exec) {
54
+ const header = exec.agent?.session?.header;
55
+ const cwd = header?.cwd;
56
+ if (cwd === undefined || cwd.trim() === '') {
57
+ throw new StudioError('novel_* tool call lacks a Workspace context: exec.agent.session.header.cwd is missing', 400, 'WORKSPACE_CONTEXT_MISSING');
58
+ }
59
+ if (!isAbsolute(cwd)) {
60
+ throw new StudioError(`Workspace root is not an absolute path: ${cwd}`, 400, 'WORKSPACE_ROOT_INVALID', { reason: 'not_absolute' });
61
+ }
62
+ let workspaceRoot;
63
+ try {
64
+ workspaceRoot = realpathSync(cwd);
65
+ }
66
+ catch {
67
+ throw new StudioError(`Workspace root cannot be canonicalized: ${cwd}`, 400, 'WORKSPACE_ROOT_INVALID', { reason: 'not_found' });
68
+ }
69
+ return {
70
+ workspaceRoot,
71
+ sessionId: header?.id,
72
+ toolCallId: String(exec.callId),
73
+ rootCallId: String(exec.rootCallId ?? exec.callId),
74
+ toolName: exec.name,
75
+ };
76
+ }
77
+ /** Per-root export directory: `<outputDir>/<sha256(workspaceRoot)[:12]>`. */
78
+ function outputDirFor(outputDir, client) {
79
+ const workspaceRoot = client.context?.workspaceRoot;
80
+ // Unreachable in practice: clientFor fails closed before any handler runs.
81
+ if (workspaceRoot === undefined)
82
+ return outputDir;
83
+ return join(outputDir, createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 12));
84
+ }
85
+ /** A server-supplied download name is a filename, never a relative path. */
86
+ function exportFilename(filename, fallback) {
87
+ const name = filename === 'export.bin' ? fallback : filename;
88
+ // Check both platforms' separators, drive prefixes and control characters
89
+ // before joining: exports may later be moved between macOS and Windows.
90
+ if (name.trim() === '' || name === '.' || name === '..' || /[\\/\u0000-\u001f\u007f]/.test(name) || /^[A-Za-z]:/.test(name)) {
91
+ throw new StudioError('Studio returned an unsafe export filename', 502, 'INVALID_EXPORT_FILENAME', { reason: 'not_basename' });
92
+ }
93
+ return name;
94
+ }
95
+ /** Register every `novel_*` tool into the host tools registry. */
96
+ export function registerNovelTools(ctx, clientFactory, options) {
97
+ const { timeoutMs, outputDir } = options;
98
+ // Every handler resolves its own request-scoped client from the execution;
99
+ // a missing/invalid Workspace context fails closed before any HTTP call.
100
+ const clientFor = (exec) => clientFactory(exec);
101
+ // ── reads ──────────────────────────────────────────────────────────────
102
+ ctx.tools.register(defineTool({
103
+ name: 'novel_embedding_profiles',
104
+ description: 'List independent Embedding profiles and the active selection. Embedding is configured separately from Chat profiles.',
105
+ parameters: {},
106
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
107
+ timeoutMs,
108
+ isConcurrencySafe: () => true,
109
+ async execute(_args, exec) {
110
+ return await clientFor(exec).getJson('/api/model/embedding', {}, exec.signal);
111
+ },
112
+ }));
113
+ ctx.tools.register(defineTool({
114
+ name: 'novel_embedding_profile_save',
115
+ description: 'Create or update an independent Embedding profile; it is never attached to a Chat profile.',
116
+ parameters: {
117
+ profile: { type: 'json', required: true, description: 'Embedding profile: id, label, provider, model, base_url, dimension, max_tokens.' },
118
+ api_key: { type: 'string', description: 'Embedding API key.' },
119
+ remember_api_key: { type: 'boolean', description: 'Persist the key (default true).' },
120
+ },
121
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
122
+ timeoutMs,
123
+ async execute(args, exec) {
124
+ if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))
125
+ throw new Error('profile must be a JSON object');
126
+ const body = { ...args.profile };
127
+ for (const key of ['api_key', 'remember_api_key'])
128
+ if (args[key] !== undefined)
129
+ body[key] = args[key];
130
+ return await clientFor(exec).postJson('/api/model/embedding', body, exec.signal);
131
+ },
132
+ }));
133
+ ctx.tools.register(defineTool({
134
+ name: 'novel_embedding_profile_select',
135
+ description: 'Select the active independent Embedding profile used by search.',
136
+ parameters: { profile_id: { type: 'string', required: true } },
137
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
138
+ timeoutMs,
139
+ async execute(args, exec) {
140
+ if (!args.profile_id.trim())
141
+ throw new Error('profile_id must be non-empty');
142
+ return await clientFor(exec).postJson('/api/model/embedding/select', { profile_id: args.profile_id }, exec.signal);
143
+ },
144
+ }));
145
+ ctx.tools.register(defineTool({
146
+ name: 'novel_embedding_profile_delete',
147
+ description: 'Delete an independent Embedding profile; the active profile automatically moves to another one.',
148
+ parameters: { profile_id: { type: 'string', required: true } },
149
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
150
+ timeoutMs,
151
+ async execute(args, exec) {
152
+ if (!args.profile_id.trim())
153
+ throw new Error('profile_id must be non-empty');
154
+ return await clientFor(exec).postJson('/api/model/embedding/delete', { profile_id: args.profile_id }, exec.signal);
155
+ },
156
+ }));
157
+ ctx.tools.register(defineTool({
158
+ name: 'novel_status',
159
+ description: 'Get a snapshot of the current OpenWrite novel project: title, chapters, word counts, writing targets, and focus compass.',
160
+ parameters: {},
161
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
162
+ timeoutMs,
163
+ isConcurrencySafe: () => true,
164
+ async execute(_args, exec) {
165
+ const client = clientFor(exec);
166
+ return await client.getJson('/api/workspace', {}, exec.signal);
167
+ },
168
+ }));
169
+ ctx.tools.register(defineTool({
170
+ name: 'novel_review_framework',
171
+ description: 'Inspect the canonical reusable chapter-review DAG blueprint: its version, revision, six quality domains, 37 checks, hard gates, dependencies, invariants, and extension points.',
172
+ parameters: {},
173
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
174
+ timeoutMs,
175
+ isConcurrencySafe: () => true,
176
+ async execute(_args, exec) {
177
+ return await clientFor(exec).getJson('/api/review/framework', {}, exec.signal);
178
+ },
179
+ }));
180
+ ctx.tools.register(defineTool({
181
+ name: 'novel_manuscript_acceptance',
182
+ description: 'Inspect the canonical manuscript acceptance ledger: current and accepted content revisions, drift, ' +
183
+ 'recoverable operations, derived-result impacts, and chapters that require author review.',
184
+ parameters: {},
185
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
186
+ timeoutMs,
187
+ isConcurrencySafe: () => true,
188
+ async execute(_args, exec) {
189
+ return await clientFor(exec).getJson('/api/manuscript/acceptance', {}, exec.signal);
190
+ },
191
+ }));
192
+ ctx.tools.register(defineTool({
193
+ name: 'novel_trace_list',
194
+ description: 'Inspect recent redacted Prompt→context→model→tool→domain traces. Raw prompts, context, model output, credentials, chain-of-thought, and mutation values are never stored.',
195
+ parameters: {
196
+ limit: { type: 'number', description: 'Maximum records to return (1-100, default 20).' },
197
+ },
198
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
199
+ timeoutMs,
200
+ isConcurrencySafe: () => true,
201
+ async execute(args, exec) {
202
+ const limit = Math.max(1, Math.min(100, Math.trunc(args.limit ?? 20)));
203
+ return await clientFor(exec).getJson('/api/traces', { limit: String(limit) }, exec.signal);
204
+ },
205
+ }));
206
+ ctx.tools.register(defineTool({
207
+ name: 'novel_context_preview',
208
+ description: 'Preview the context packet OpenWrite would assemble for writing a chapter (relevant outline, assets, foreshadowing, and prior-chapter memory).',
209
+ parameters: {
210
+ chapter: { type: 'string', description: 'Chapter id (e.g. "ch_0007"); omit or pass "next" for the next chapter to write.' },
211
+ },
212
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
213
+ timeoutMs,
214
+ isConcurrencySafe: () => true,
215
+ async execute(args, exec) {
216
+ const client = clientFor(exec);
217
+ return await client.getJson('/api/context', { chapter: args.chapter?.trim() || 'next' }, exec.signal);
218
+ },
219
+ }));
220
+ ctx.tools.register(defineTool({
221
+ name: 'novel_outline_read',
222
+ description: 'Read the live outline tree. The response carries a `revision` fingerprint required by novel_outline_edit and a chapter `recommendation`.',
223
+ parameters: {
224
+ chapter: { type: 'string', description: 'Optional chapter id to get a writing recommendation for.' },
225
+ },
226
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
227
+ timeoutMs,
228
+ isConcurrencySafe: () => true,
229
+ async execute(args, exec) {
230
+ const client = clientFor(exec);
231
+ const params = {};
232
+ const chapter = args.chapter?.trim();
233
+ if (chapter)
234
+ params['chapter'] = chapter;
235
+ return await client.getJson('/api/outline', params, exec.signal);
236
+ },
237
+ }));
238
+ ctx.tools.register(defineTool({
239
+ name: 'novel_reading_order',
240
+ description: 'Read the canonical manuscript order with stable document/occurrence ids, volume membership, previous/next links, revision, and explicit duplicate or missing-file issues.',
241
+ parameters: {},
242
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
243
+ timeoutMs,
244
+ isConcurrencySafe: () => true,
245
+ async execute(_args, exec) {
246
+ return await clientFor(exec).getJson('/api/reading-order', {}, exec.signal);
247
+ },
248
+ }));
249
+ ctx.tools.register(defineTool({
250
+ name: 'novel_reading_packet',
251
+ description: 'Read a bounded continuous-reading window around one stable manuscript document or occurrence id.',
252
+ parameters: {
253
+ document_id: { type: 'string', required: true, description: 'Stable document_id or occurrence_id from novel_reading_order.' },
254
+ before: { type: 'integer', description: 'Number of preceding chapters (0-20, default 1).' },
255
+ after: { type: 'integer', description: 'Number of following chapters (0-20, default 1).' },
256
+ },
257
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
258
+ timeoutMs,
259
+ isConcurrencySafe: () => true,
260
+ async execute(args, exec) {
261
+ if (!args.document_id.trim())
262
+ throw new Error('document_id must be non-empty');
263
+ const params = { document_id: args.document_id };
264
+ if (args.before !== undefined)
265
+ params['before'] = String(args.before);
266
+ if (args.after !== undefined)
267
+ params['after'] = String(args.after);
268
+ return await clientFor(exec).getJson('/api/reading-packet', params, exec.signal);
269
+ },
270
+ }));
271
+ ctx.tools.register(defineTool({
272
+ name: 'novel_chapter_work',
273
+ description: 'Read one chapter work brief: manuscript/acceptance/review revisions, writing target, recent edits, tasks, and categorized foreshadowing obligations.',
274
+ parameters: {
275
+ chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
276
+ document_id: { type: 'string', description: 'Stable document id used to bind recent activity.' },
277
+ recent_limit: { type: 'integer', description: 'Maximum recent edit records (default 20).' },
278
+ },
279
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
280
+ timeoutMs,
281
+ isConcurrencySafe: () => true,
282
+ async execute(args, exec) {
283
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
284
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
285
+ const params = {};
286
+ if (args.document_id !== undefined)
287
+ params['document_id'] = args.document_id;
288
+ if (args.recent_limit !== undefined)
289
+ params['recent_limit'] = String(args.recent_limit);
290
+ return await clientFor(exec).getJson(`/api/chapters/${encodeURIComponent(args.chapter_id)}/work-brief`, params, exec.signal);
291
+ },
292
+ }));
293
+ ctx.tools.register(defineTool({
294
+ name: 'novel_scene_structure',
295
+ description: 'Read the canonical scene structure with stable scene ids, reading/story-time order, chapter revisions, references, freshness, and explicit issues.',
296
+ parameters: {},
297
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
298
+ timeoutMs,
299
+ isConcurrencySafe: () => true,
300
+ async execute(_args, exec) {
301
+ return await clientFor(exec).getJson('/api/scenes', {}, exec.signal);
302
+ },
303
+ }));
304
+ ctx.tools.register(defineTool({
305
+ name: 'novel_chapter_scenes',
306
+ description: 'Read the revision-labelled scene slice for one chapter.',
307
+ parameters: {
308
+ chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
309
+ },
310
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
311
+ timeoutMs,
312
+ isConcurrencySafe: () => true,
313
+ async execute(args, exec) {
314
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
315
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
316
+ return await clientFor(exec).getJson(`/api/chapters/${encodeURIComponent(args.chapter_id)}/scenes`, {}, exec.signal);
317
+ },
318
+ }));
319
+ ctx.tools.register(defineTool({
320
+ name: 'novel_scene_migration_preview',
321
+ description: 'Build a read-only, exact-revision preview that segments existing chapters into stable scenes. This does not write a sidecar.',
322
+ parameters: {},
323
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
324
+ timeoutMs,
325
+ isConcurrencySafe: () => true,
326
+ async execute(_args, exec) {
327
+ return await clientFor(exec).getJson('/api/scenes/migration-preview', {}, exec.signal);
328
+ },
329
+ }));
330
+ ctx.tools.register(defineTool({
331
+ name: 'novel_assets_list',
332
+ description: 'List structured assets (characters, world entities, progression systems) of the current novel project.',
333
+ parameters: {
334
+ kind: { type: 'string', enum: ['character', 'world', 'progression'], description: 'Restrict to one asset kind; omit to list all kinds.' },
335
+ },
336
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
337
+ timeoutMs,
338
+ isConcurrencySafe: () => true,
339
+ async execute(args, exec) {
340
+ const client = clientFor(exec);
341
+ return await client.getJson('/api/assets', { kind: args.kind ?? '' }, exec.signal);
342
+ },
343
+ }));
344
+ ctx.tools.register(defineTool({
345
+ name: 'novel_search',
346
+ description: 'Search the novel project (semantic + exact match) across chapters, outline, and assets.',
347
+ parameters: {
348
+ q: { type: 'string', required: true, description: 'The search query.' },
349
+ scope: { type: 'string', description: 'Search scope, e.g. "all" (default), "chapters", "outline", "assets".' },
350
+ limit: { type: 'integer', description: 'Maximum number of results (server default 20).' },
351
+ },
352
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
353
+ timeoutMs,
354
+ isConcurrencySafe: () => true,
355
+ async execute(args, exec) {
356
+ const client = clientFor(exec);
357
+ if (!args.q.trim())
358
+ throw new Error('q must be a non-empty string');
359
+ const params = { q: args.q };
360
+ if (args.scope !== undefined)
361
+ params['scope'] = args.scope;
362
+ if (args.limit !== undefined)
363
+ params['limit'] = String(args.limit);
364
+ return await client.getJson('/api/search', params, exec.signal);
365
+ },
366
+ }));
367
+ ctx.tools.register(defineTool({
368
+ name: 'novel_doc_read',
369
+ 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.',
370
+ parameters: {
371
+ path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
372
+ },
373
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
374
+ timeoutMs,
375
+ isConcurrencySafe: () => true,
376
+ async execute(args, exec) {
377
+ const client = clientFor(exec);
378
+ if (!args.path.trim())
379
+ throw new Error('path must be a non-empty string');
380
+ return await client.getJson('/api/document', { path: args.path }, exec.signal);
381
+ },
382
+ }));
383
+ // ── writes ─────────────────────────────────────────────────────────────
384
+ ctx.tools.register(defineTool({
385
+ name: 'novel_outline_edit',
386
+ description: 'Immediate compatibility write for an outline change the author has already authorized exactly; otherwise use ' +
387
+ 'novel_structured_change_plan. Apply ONE atomic structural edit to the outline tree. Edits are revision-gated: ' +
388
+ 'read the current `revision` via novel_outline_read first and pass it back; the server ' +
389
+ 'rejects the edit with a conflict if the outline changed in between. ' +
390
+ 'Operations: "rename" (node_id + title), "update_summary" (node_id + summary), ' +
391
+ '"add_child" (node_id = parent, + title/summary/kind), "add_after" (node_id = sibling, + title/summary/kind), ' +
392
+ '"delete" (node_id).',
393
+ parameters: {
394
+ operation: { type: 'string', required: true, enum: ['rename', 'update_summary', 'add_child', 'add_after', 'delete'], description: 'The structural operation to apply.' },
395
+ revision: { type: 'string', required: true, description: 'The outline revision fingerprint from a fresh novel_outline_read call.' },
396
+ node_id: { type: 'string', description: 'Target node id (parent for add_child, sibling for add_after); not needed for add_child at root.' },
397
+ title: { type: 'string', description: 'Node title (rename / add_child / add_after).' },
398
+ summary: { type: 'string', description: 'Node summary (update_summary / add_child / add_after).' },
399
+ kind: { type: 'string', description: 'Node kind, e.g. "volume" or "chapter" (add_child / add_after).' },
400
+ },
401
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
402
+ timeoutMs,
403
+ async execute(args, exec) {
404
+ const client = clientFor(exec);
405
+ const body = { operation: args.operation, revision: args.revision };
406
+ if (args.node_id !== undefined)
407
+ body['node_id'] = args.node_id;
408
+ if (args.title !== undefined)
409
+ body['title'] = args.title;
410
+ if (args.summary !== undefined)
411
+ body['summary'] = args.summary;
412
+ if (args.kind !== undefined)
413
+ body['kind'] = args.kind;
414
+ return await client.postJson('/api/outline/edit', body, exec.signal);
415
+ },
416
+ }));
417
+ ctx.tools.register(defineTool({
418
+ name: 'novel_reading_order_move',
419
+ description: 'Move one manuscript document within or across volumes. Requires the fresh reading-order revision and rejects ambiguous, missing, or concurrently changed order.',
420
+ parameters: {
421
+ document_id: { type: 'string', required: true, description: 'Stable document_id or unambiguous occurrence_id.' },
422
+ target_volume_id: { type: 'string', required: true, description: 'Target volume_id from novel_reading_order.' },
423
+ target_index: { type: 'integer', required: true, description: 'Zero-based position inside the target volume.' },
424
+ expected_revision: { type: 'string', required: true, description: 'Fresh reading-order revision used for CAS.' },
425
+ },
426
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
427
+ timeoutMs,
428
+ async execute(args, exec) {
429
+ if (!args.document_id.trim())
430
+ throw new Error('document_id must be non-empty');
431
+ if (!args.target_volume_id.trim())
432
+ throw new Error('target_volume_id must be non-empty');
433
+ if (!args.expected_revision.trim())
434
+ throw new Error('expected_revision must be non-empty');
435
+ return await clientFor(exec).postJson('/api/reading-order/move', {
436
+ document_id: args.document_id,
437
+ target_volume_id: args.target_volume_id,
438
+ target_index: args.target_index,
439
+ expected_revision: args.expected_revision,
440
+ }, exec.signal);
441
+ },
442
+ }));
443
+ ctx.tools.register(defineTool({
444
+ name: 'novel_scene_migration_apply',
445
+ description: 'Apply one freshly read scene migration preview. Requires the exact preview revision and explicit confirmation.',
446
+ parameters: {
447
+ expected_preview_revision: { type: 'string', required: true, description: 'Exact preview_revision from novel_scene_migration_preview.' },
448
+ confirm: { type: 'boolean', required: true, description: 'Must be true after the author approves the preview.' },
449
+ },
450
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
451
+ timeoutMs,
452
+ async execute(args, exec) {
453
+ if (!args.expected_preview_revision.trim())
454
+ throw new Error('expected_preview_revision must be non-empty');
455
+ if (args.confirm !== true)
456
+ throw new Error('confirm must be true');
457
+ return await clientFor(exec).postJson('/api/scenes/migration/apply', {
458
+ expected_preview_revision: args.expected_preview_revision,
459
+ confirm: true,
460
+ }, exec.signal);
461
+ },
462
+ }));
463
+ ctx.tools.register(defineTool({
464
+ name: 'novel_scene_migration_rollback',
465
+ description: 'Restore the byte-exact scene sidecar that preceded a migration, guarded by the current scene-structure revision.',
466
+ parameters: {
467
+ migration_id: { type: 'string', required: true, description: 'Migration id returned by novel_scene_migration_apply.' },
468
+ expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
469
+ },
470
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
471
+ timeoutMs,
472
+ async execute(args, exec) {
473
+ if (!args.migration_id.trim() || !args.expected_revision.trim())
474
+ throw new Error('migration_id and expected_revision must be non-empty');
475
+ return await clientFor(exec).postJson('/api/scenes/migration/rollback', {
476
+ migration_id: args.migration_id,
477
+ expected_revision: args.expected_revision,
478
+ }, exec.signal);
479
+ },
480
+ }));
481
+ ctx.tools.register(defineTool({
482
+ name: 'novel_scene_update',
483
+ description: 'CAS-update scene title, story time, and character/location/event references without changing manuscript prose.',
484
+ parameters: {
485
+ scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
486
+ expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
487
+ title: { type: 'string', description: 'Optional scene title.' },
488
+ story_time_sort_key: { type: 'string', description: 'Optional lexical story-time sort key.' },
489
+ story_time_label: { type: 'string', description: 'Optional author-facing story-time label.' },
490
+ characters: { type: 'array', items: { type: 'string' }, description: 'Character references; replaces the current list when supplied.' },
491
+ locations: { type: 'array', items: { type: 'string' }, description: 'Location references; replaces the current list when supplied.' },
492
+ events: { type: 'array', items: { type: 'string' }, description: 'Event references; replaces the current list when supplied.' },
493
+ },
494
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
495
+ timeoutMs,
496
+ async execute(args, exec) {
497
+ if (!args.scene_id.trim() || !args.expected_revision.trim())
498
+ throw new Error('scene_id and expected_revision must be non-empty');
499
+ const body = { scene_id: args.scene_id, expected_revision: args.expected_revision };
500
+ if (args.title !== undefined)
501
+ body['title'] = args.title;
502
+ if (args.story_time_sort_key !== undefined)
503
+ body['story_time_sort_key'] = args.story_time_sort_key;
504
+ if (args.story_time_label !== undefined)
505
+ body['story_time_label'] = args.story_time_label;
506
+ if (args.characters !== undefined)
507
+ body['characters'] = args.characters;
508
+ if (args.locations !== undefined)
509
+ body['locations'] = args.locations;
510
+ if (args.events !== undefined)
511
+ body['events'] = args.events;
512
+ return await clientFor(exec).postJson('/api/scenes/metadata', body, exec.signal);
513
+ },
514
+ }));
515
+ ctx.tools.register(defineTool({
516
+ name: 'novel_scene_move',
517
+ description: 'Move one complete scene within or across chapters with scene/source/target revision guards. Cross-chapter moves start the existing manuscript acceptance flow.',
518
+ parameters: {
519
+ scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
520
+ target_chapter_id: { type: 'string', required: true, description: 'Target chapter id (ch_<digits>).' },
521
+ target_index: { type: 'integer', required: true, description: 'Zero-based scene position inside the target chapter.' },
522
+ expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision.' },
523
+ expected_source_revision: { type: 'string', required: true, description: 'Fresh source chapter revision.' },
524
+ expected_target_revision: { type: 'string', required: true, description: 'Fresh target chapter revision.' },
525
+ },
526
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
527
+ timeoutMs,
528
+ async execute(args, exec) {
529
+ if (!args.scene_id.trim() || !CHAPTER_ID_PATTERN.test(args.target_chapter_id))
530
+ throw new Error('scene_id and a valid target_chapter_id are required');
531
+ if (!args.expected_revision.trim() || !args.expected_source_revision.trim() || !args.expected_target_revision.trim())
532
+ throw new Error('all scene and chapter revisions are required');
533
+ return await clientFor(exec).postJson('/api/scenes/move', {
534
+ scene_id: args.scene_id,
535
+ target_chapter_id: args.target_chapter_id,
536
+ target_index: args.target_index,
537
+ expected_revision: args.expected_revision,
538
+ expected_source_revision: args.expected_source_revision,
539
+ expected_target_revision: args.expected_target_revision,
540
+ }, exec.signal);
541
+ },
542
+ }));
543
+ ctx.tools.register(defineTool({
544
+ name: 'novel_write_chapter',
545
+ description: 'Write a chapter with OpenWrite (LONG-RUNNING, may take several minutes). Omit chapter_id to write ' +
546
+ 'the outline-recommended next chapter; a given chapter_id must match the current recommendation. ' +
547
+ 'Preview context first with novel_context_preview and consult novel_outline_read for the recommendation.',
548
+ parameters: {
549
+ target_words: { type: 'integer', description: 'Target word count, 200-12000 (default 3000).' },
550
+ guidance: { type: 'string', description: 'Writing guidance for this chapter (plot beats, tone, POV notes).' },
551
+ chapter_id: { type: 'string', description: 'Chapter id to write; must equal the current outline recommendation. Omit for the next chapter.' },
552
+ temperature: { type: 'number', description: 'Sampling temperature (default 0.7).' },
553
+ outline_revision: { type: 'string', description: 'Optional outline revision guard from novel_outline_read; the server rejects the write if the outline changed.' },
554
+ },
555
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
556
+ timeoutMs,
557
+ async execute(args, exec) {
558
+ const client = clientFor(exec);
559
+ const targetWords = args.target_words ?? 3000;
560
+ if (!Number.isInteger(targetWords) || targetWords < 200 || targetWords > 12000) {
561
+ throw new Error(`target_words must be an integer between 200 and 12000, got ${String(args.target_words)}`);
562
+ }
563
+ const body = { target_words: targetWords, guidance: args.guidance ?? '' };
564
+ const chapterId = args.chapter_id?.trim();
565
+ if (chapterId)
566
+ body['chapter_id'] = chapterId;
567
+ if (args.temperature !== undefined)
568
+ body['temperature'] = args.temperature;
569
+ if (args.outline_revision !== undefined)
570
+ body['outline_revision'] = args.outline_revision;
571
+ const response = await client.postJson('/api/write', body, exec.signal);
572
+ const writeValue = objectValue(response);
573
+ const writeResult = objectValue(writeValue['result'] ?? response);
574
+ const writtenChapterId = chapterId || String(writeResult['chapter_id'] ?? '');
575
+ try {
576
+ const dogDelivery = await deliveryForResponse(client, response, writtenChapterId, exec.signal);
577
+ return { ...objectValue(response), dog_delivery: dogDelivery };
578
+ }
579
+ catch (error) {
580
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
581
+ }
582
+ },
583
+ }));
584
+ ctx.tools.register(defineTool({
585
+ name: 'novel_review_chapter',
586
+ description: 'Run OpenWrite\'s multi-dimensional review on a drafted manuscript chapter (LONG-RUNNING). ' +
587
+ 'Pass either the manuscript path (e.g. "data/manuscript/ch_0007.md") or a chapter_id like "ch_0007".',
588
+ parameters: {
589
+ path: { type: 'string', description: 'Manuscript path relative to the novel data root. Takes precedence over chapter_id.' },
590
+ chapter_id: { type: 'string', description: 'Chapter id (ch_<digits>), resolved client-side to data/manuscript/<id>.md.' },
591
+ strict: { type: 'boolean', description: 'Enable strict review mode (default false).' },
592
+ dimensions: { type: 'array', items: { type: 'integer' }, description: 'Optional list of review dimension numbers (1-37) to restrict to.' },
593
+ dog_threshold: { type: 'integer', description: 'DoG aggregate score threshold (default 70); this does not change OpenWrite review.' },
594
+ },
595
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
596
+ timeoutMs,
597
+ async execute(args, exec) {
598
+ const client = clientFor(exec);
599
+ if (args.dimensions !== undefined && args.dimensions.some(value => !Number.isInteger(value) || value < 1 || value > 37)) {
600
+ throw new Error('dimensions must contain only integers from 1 to 37');
601
+ }
602
+ const dogThreshold = args.dog_threshold ?? 70;
603
+ if (!Number.isInteger(dogThreshold) || dogThreshold < 0 || dogThreshold > 100) {
604
+ throw new Error(`dog_threshold must be an integer between 0 and 100, got ${String(args.dog_threshold)}`);
605
+ }
606
+ const body = { path: resolveReviewPath(args) };
607
+ if (args.strict !== undefined)
608
+ body['strict'] = args.strict;
609
+ if (args.dimensions !== undefined)
610
+ body['dimensions'] = args.dimensions;
611
+ const response = await client.postJson('/api/review', body, exec.signal);
612
+ const value = response !== null && typeof response === 'object' && !Array.isArray(response)
613
+ ? response
614
+ : { result: response };
615
+ let dogReview;
616
+ try {
617
+ dogReview = await materializeDogReview(response, reviewChapterId(args), dogThreshold, client.context?.workspaceRoot);
618
+ }
619
+ catch (error) {
620
+ dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
621
+ }
622
+ let dogDelivery;
623
+ try {
624
+ dogDelivery = await deliveryForResponse(client, response, reviewChapterId(args), exec.signal, dogThreshold);
625
+ }
626
+ catch (error) {
627
+ dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
628
+ }
629
+ return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
630
+ },
631
+ }));
632
+ ctx.tools.register(defineTool({
633
+ name: 'novel_asset_update',
634
+ description: 'Immediate compatibility write for an asset change the author has already authorized exactly; otherwise use ' +
635
+ 'novel_structured_change_plan. Update a structured asset (character / world / progression). Revision-gated: pass the `revision` ' +
636
+ 'fingerprint from novel_assets_list; the server rejects the update with a conflict if the asset changed. ' +
637
+ 'Pass field changes via `data` (merged into the asset front matter), `body_markdown` to replace the ' +
638
+ 'markdown body, or `raw_text` to replace the whole document.',
639
+ parameters: {
640
+ kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
641
+ id: { type: 'string', required: true, description: 'Asset id.' },
642
+ revision: { type: 'string', required: true, description: 'The asset revision fingerprint from a fresh novel_assets_list call.' },
643
+ data: { type: 'json', description: 'Object of asset fields to merge (e.g. name, summary, aliases).' },
644
+ body_markdown: { type: 'string', description: 'Replacement markdown body (character/world assets).' },
645
+ raw_text: { type: 'string', description: 'Full replacement document text, validated by the server.' },
646
+ },
647
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
648
+ timeoutMs,
649
+ async execute(args, exec) {
650
+ const client = clientFor(exec);
651
+ const body = { kind: args.kind, id: args.id, revision: args.revision };
652
+ if (args.data !== undefined)
653
+ body['data'] = args.data;
654
+ if (args.body_markdown !== undefined)
655
+ body['body_markdown'] = args.body_markdown;
656
+ if (args.raw_text !== undefined)
657
+ body['raw_text'] = args.raw_text;
658
+ return await client.postJson('/api/assets/update', body, exec.signal);
659
+ },
660
+ }));
661
+ ctx.tools.register(defineTool({
662
+ name: 'novel_foreshadowing',
663
+ description: 'Immediate compatibility write for a foreshadowing change the author has already authorized exactly; otherwise use ' +
664
+ 'novel_structured_change_plan. Manage the foreshadowing DAG. action "create": node_id + content, optional weight (1-10, default 5), ' +
665
+ 'layer (default "支线"), target_chapter. action "update": node_id + status (e.g. planted/paid-off).',
666
+ parameters: {
667
+ action: { type: 'string', required: true, enum: ['create', 'update'], description: 'Create a new foreshadowing node or update an existing one.' },
668
+ node_id: { type: 'string', required: true, description: 'Foreshadowing node id.' },
669
+ content: { type: 'string', description: 'Node content (create).' },
670
+ weight: { type: 'integer', description: 'Narrative weight 1-10 (create, default 5).' },
671
+ layer: { type: 'string', description: 'Story layer, e.g. "主线" or "支线" (create, default "支线").' },
672
+ target_chapter: { type: 'string', description: 'Chapter id the foreshadowing should pay off at (create).' },
673
+ status: { type: 'string', description: 'New node status (update).' },
674
+ },
675
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
676
+ timeoutMs,
677
+ async execute(args, exec) {
678
+ const client = clientFor(exec);
679
+ if (!args.node_id.trim())
680
+ throw new Error('node_id must be a non-empty string');
681
+ const body = { action: args.action, node_id: args.node_id };
682
+ if (args.content !== undefined)
683
+ body['content'] = args.content;
684
+ if (args.weight !== undefined)
685
+ body['weight'] = args.weight;
686
+ if (args.layer !== undefined)
687
+ body['layer'] = args.layer;
688
+ if (args.target_chapter !== undefined)
689
+ body['target_chapter'] = args.target_chapter;
690
+ if (args.status !== undefined)
691
+ body['status'] = args.status;
692
+ return await client.postJson('/api/foreshadowing', body, exec.signal);
693
+ },
694
+ }));
695
+ ctx.tools.register(defineTool({
696
+ name: 'novel_doc_write',
697
+ description: 'Write a project document (path relative to the novel data root). Uses a version-based optimistic lock: ' +
698
+ 'pass the `version` returned by novel_doc_read; the server rejects with a conflict if the document changed. ' +
699
+ 'Omit version to create a new document; set force to bypass the lock.',
700
+ parameters: {
701
+ path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
702
+ content: { type: 'string', required: true, description: 'Full new document content (max 2 MB).' },
703
+ version: { type: 'string', description: 'The version fingerprint from novel_doc_read (optimistic lock).' },
704
+ force: { type: 'boolean', description: 'Bypass the version check (default false).' },
705
+ },
706
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
707
+ timeoutMs,
708
+ async execute(args, exec) {
709
+ const client = clientFor(exec);
710
+ if (!args.path.trim())
711
+ throw new Error('path must be a non-empty string');
712
+ const body = { path: args.path, content: args.content };
713
+ if (args.version !== undefined)
714
+ body['version'] = args.version;
715
+ if (args.force !== undefined)
716
+ body['force'] = args.force;
717
+ return await client.putJson('/api/document', body, exec.signal);
718
+ },
719
+ }));
720
+ ctx.tools.register(defineTool({
721
+ name: 'novel_document_change_plan',
722
+ description: 'Stage and review revision-gated changes to an existing novel document. Use action "preview" with path and edits ' +
723
+ 'to receive an immutable preview_token and real before/after summary. Use "apply" only after author acceptance, ' +
724
+ 'passing only that token; "reject" discards it, "retry" rebuilds it against the current source, and "undo" safely ' +
725
+ 'applies an undo_preview_token returned by a prior apply. Each edit uses old_text/new_text or unique start_text/' +
726
+ 'end_text/new_text anchors. The server rechecks the source revision and writes exactly the stored preview.',
727
+ parameters: {
728
+ action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
729
+ path: { type: 'string', description: 'Existing document path for preview, relative to the novel root.' },
730
+ edits: { type: 'json', description: 'Preview edits: [{old_text,new_text}] or [{start_text,end_text,new_text}].' },
731
+ preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
732
+ },
733
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
734
+ timeoutMs,
735
+ async execute(args, exec) {
736
+ if (args.action === 'preview' && (!Array.isArray(args.edits) || args.edits.length !== 1)) {
737
+ throw new Error('preview requires exactly one edit so the author can accept or reject each change independently');
738
+ }
739
+ if (args.action !== 'preview' && !args.preview_token?.trim()) {
740
+ throw new Error(`${args.action} requires preview_token`);
741
+ }
742
+ const client = clientFor(exec);
743
+ const body = { action: args.action };
744
+ if (args.path !== undefined)
745
+ body['path'] = args.path;
746
+ if (args.edits !== undefined)
747
+ body['edits'] = args.edits;
748
+ if (args.preview_token !== undefined)
749
+ body['preview_token'] = args.preview_token;
750
+ return await client.postJson('/api/document/change-plan', body, exec.signal);
751
+ },
752
+ }));
753
+ ctx.tools.register(defineTool({
754
+ name: 'novel_structured_change_plan',
755
+ description: 'Stage one structured OpenWrite domain change without writing it. Supported change_kind values are "outline", ' +
756
+ '"asset", "focus", "foreshadowing", and "writing_targets"; `change` uses the corresponding direct tool payload. ' +
757
+ 'Use action "preview" to receive an immutable token and entity-level before/after summary. Only after author ' +
758
+ 'acceptance pass that token unchanged to "apply". "reject" consumes it, "retry" rebuilds the same semantic ' +
759
+ 'change against current source, and "undo" accepts only the revision-bound undo token returned by apply. The ' +
760
+ 'server writes the exact validated preview and rejects source conflicts.',
761
+ parameters: {
762
+ action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
763
+ change_kind: { type: 'string', enum: ['outline', 'asset', 'focus', 'foreshadowing', 'writing_targets'], description: 'Structured domain for a preview.' },
764
+ change: { type: 'json', description: 'One direct-tool-shaped change object, required for preview.' },
765
+ preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
766
+ },
767
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
768
+ timeoutMs,
769
+ async execute(args, exec) {
770
+ if (args.action === 'preview') {
771
+ if (!args.change_kind)
772
+ throw new Error('preview requires change_kind');
773
+ if (args.change === null || typeof args.change !== 'object' || Array.isArray(args.change)) {
774
+ throw new Error('preview requires one change object');
775
+ }
776
+ }
777
+ else if (!args.preview_token?.trim()) {
778
+ throw new Error(`${args.action} requires preview_token`);
779
+ }
780
+ const client = clientFor(exec);
781
+ const body = { action: args.action };
782
+ if (args.change_kind !== undefined)
783
+ body['change_kind'] = args.change_kind;
784
+ if (args.change !== undefined)
785
+ body['change'] = args.change;
786
+ if (args.preview_token !== undefined)
787
+ body['preview_token'] = args.preview_token;
788
+ return await client.postJson('/api/structured/change-plan', body, exec.signal);
789
+ },
790
+ }));
791
+ ctx.tools.register(defineTool({
792
+ name: 'novel_focus',
793
+ 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.',
794
+ parameters: {
795
+ goal: { type: 'string', required: true, description: 'The current writing goal.' },
796
+ must_keep: { type: 'array', items: { type: 'string' }, description: 'Constraints that must be kept.' },
797
+ must_avoid: { type: 'array', items: { type: 'string' }, description: 'Things that must be avoided.' },
798
+ notes: { type: 'array', items: { type: 'string' }, description: 'Free-form focus notes.' },
799
+ },
800
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
801
+ timeoutMs,
802
+ async execute(args, exec) {
803
+ const client = clientFor(exec);
804
+ const body = { goal: args.goal };
805
+ if (args.must_keep !== undefined)
806
+ body['must_keep'] = args.must_keep;
807
+ if (args.must_avoid !== undefined)
808
+ body['must_avoid'] = args.must_avoid;
809
+ if (args.notes !== undefined)
810
+ body['notes'] = args.notes;
811
+ return await client.postJson('/api/focus', body, exec.signal);
812
+ },
813
+ }));
814
+ ctx.tools.register(defineTool({
815
+ name: 'novel_export_preflight',
816
+ description: 'Inspect the current manuscript export order, structural defects, writing targets, metadata, review freshness, ' +
817
+ 'manuscript acceptance, blockers, and warnings. Pass the returned preflight_revision unchanged to novel_export ' +
818
+ 'so the downloaded file is bound to the manuscript state that was inspected.',
819
+ parameters: {
820
+ format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
821
+ purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery"). Delivery applies publication gates; backup preserves the current work.' },
822
+ },
823
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
824
+ timeoutMs,
825
+ isConcurrencySafe: () => true,
826
+ async execute(args, exec) {
827
+ const client = clientFor(exec);
828
+ return await client.getJson('/api/export/preflight', {
829
+ format: args.format ?? 'md',
830
+ purpose: args.purpose ?? 'delivery',
831
+ }, exec.signal);
832
+ },
833
+ }));
834
+ ctx.tools.register(defineTool({
835
+ name: 'novel_export',
836
+ description: 'Export the manuscript as a file download and save it locally. Inspect novel_export_preflight first and pass its ' +
837
+ 'preflight_revision unchanged to bind the download to that inspected manuscript state. Returns the saved file path.',
838
+ parameters: {
839
+ format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
840
+ purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery").' },
841
+ preflight_revision: { type: 'string', description: 'Revision returned by novel_export_preflight for the same format and purpose.' },
842
+ },
843
+ output: {
844
+ schema: {
845
+ type: 'object',
846
+ additionalProperties: false,
847
+ properties: {
848
+ path: { type: 'string', required: true, description: 'Absolute path of the saved export file.' },
849
+ filename: { type: 'string', required: true },
850
+ format: { type: 'string', required: true },
851
+ bytes: { type: 'integer', required: true },
852
+ },
853
+ },
854
+ render: (_args, value) => [{
855
+ type: 'text',
856
+ text: `Exported ${value.filename} (${value.bytes} bytes) to ${value.path}`,
857
+ }],
858
+ },
859
+ timeoutMs,
860
+ async execute(args, exec) {
861
+ const client = clientFor(exec);
862
+ const format = args.format ?? 'md';
863
+ const purpose = args.purpose ?? 'delivery';
864
+ const download = await client.download('/api/export', {
865
+ format,
866
+ purpose,
867
+ ...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
868
+ }, exec.signal);
869
+ const filename = exportFilename(download.filename, `novel.${format}`);
870
+ const exportDir = outputDirFor(outputDir, client);
871
+ await mkdir(exportDir, { recursive: true });
872
+ const path = join(exportDir, filename);
873
+ await writeFile(path, download.content);
874
+ return { path, filename, format, bytes: download.content.byteLength };
875
+ },
876
+ }));
877
+ // ── assets: create / read / packages ────────────────────────────────────
878
+ ctx.tools.register(defineTool({
879
+ name: 'novel_asset_read',
880
+ description: 'Read one structured asset by kind and id, including its relation view.',
881
+ parameters: {
882
+ kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
883
+ id: { type: 'string', required: true, description: 'Asset id.' },
884
+ },
885
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
886
+ timeoutMs,
887
+ isConcurrencySafe: () => true,
888
+ async execute(args, exec) {
889
+ const client = clientFor(exec);
890
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
891
+ throw new Error(`invalid asset id "${args.id}"`);
892
+ return await client.getJson(`/api/assets/${args.kind}/${encodeURIComponent(args.id)}`, {}, exec.signal);
893
+ },
894
+ }));
895
+ ctx.tools.register(defineTool({
896
+ name: 'novel_asset_create',
897
+ description: 'Create a structured asset. character/world assets take front-matter fields via `data` plus an optional ' +
898
+ '`body_markdown`; progression assets take their YAML mapping via `data`. Fails with a conflict if the id exists — ' +
899
+ 'use novel_asset_update instead.',
900
+ parameters: {
901
+ kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
902
+ id: { type: 'string', required: true, description: 'Asset id (letters, digits, "_", "-", ".").' },
903
+ data: { type: 'json', description: 'Object of asset fields (e.g. name, summary, aliases).' },
904
+ body_markdown: { type: 'string', description: 'Markdown body (character/world assets).' },
905
+ },
906
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
907
+ timeoutMs,
908
+ async execute(args, exec) {
909
+ const client = clientFor(exec);
910
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
911
+ throw new Error(`invalid asset id "${args.id}"`);
912
+ const body = { kind: args.kind, id: args.id };
913
+ if (args.data !== undefined)
914
+ body['data'] = args.data;
915
+ if (args.body_markdown !== undefined)
916
+ body['body_markdown'] = args.body_markdown;
917
+ return await client.postJson('/api/assets', body, exec.signal);
918
+ },
919
+ }));
920
+ ctx.tools.register(defineTool({
921
+ name: 'novel_assets_package_export',
922
+ description: 'Export assets as an .owasset.zip package and save it locally. Omit selections to export all assets.',
923
+ parameters: {
924
+ selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
925
+ },
926
+ output: {
927
+ schema: {
928
+ type: 'object',
929
+ additionalProperties: false,
930
+ properties: {
931
+ path: { type: 'string', required: true, description: 'Absolute path of the saved package file.' },
932
+ filename: { type: 'string', required: true },
933
+ bytes: { type: 'integer', required: true },
934
+ },
935
+ },
936
+ render: (_args, value) => [{
937
+ type: 'text',
938
+ text: `Exported asset package ${value.filename} (${value.bytes} bytes) to ${value.path}`,
939
+ }],
940
+ },
941
+ timeoutMs,
942
+ isConcurrencySafe: () => true,
943
+ async execute(args, exec) {
944
+ const client = clientFor(exec);
945
+ const selections = args.selections ?? [];
946
+ for (const item of selections) {
947
+ if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
948
+ throw new Error(`invalid asset selection "${item}"; expected "kind:id"`);
949
+ }
950
+ }
951
+ const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
952
+ const filename = exportFilename(download.filename, 'assets.owasset.zip');
953
+ const exportDir = outputDirFor(outputDir, client);
954
+ await mkdir(exportDir, { recursive: true });
955
+ const path = join(exportDir, filename);
956
+ await writeFile(path, download.content);
957
+ return { path, filename, bytes: download.content.byteLength };
958
+ },
959
+ }));
960
+ ctx.tools.register(defineTool({
961
+ name: 'novel_assets_package_preview',
962
+ description: 'Stage an .owasset.zip asset package (base64) and preview its contents and conflicts without importing. ' +
963
+ 'Returns an upload_id to pass to novel_assets_package_import.',
964
+ parameters: {
965
+ package_base64: { type: 'string', required: true, description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
966
+ },
967
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
968
+ timeoutMs,
969
+ async execute(args, exec) {
970
+ const client = clientFor(exec);
971
+ if (!args.package_base64.trim())
972
+ throw new Error('package_base64 must be a non-empty string');
973
+ return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
974
+ },
975
+ }));
976
+ ctx.tools.register(defineTool({
977
+ name: 'novel_assets_package_import',
978
+ description: 'Import a previously staged asset package (see novel_assets_package_preview). Resolves per-asset conflicts ' +
979
+ 'via `resolutions`; set allow_missing_dependencies to skip missing dependency checks.',
980
+ parameters: {
981
+ upload_id: { type: 'string', required: true, description: 'The upload_id returned by novel_assets_package_preview.' },
982
+ package_sha256: { type: 'string', description: 'Optional expected package digest guard.' },
983
+ resolutions: { type: 'json', description: 'Object mapping conflicting asset keys to resolutions.' },
984
+ allow_missing_dependencies: { type: 'boolean', description: 'Allow import despite missing asset dependencies (default false).' },
985
+ },
986
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
987
+ timeoutMs,
988
+ async execute(args, exec) {
989
+ const client = clientFor(exec);
990
+ const body = { upload_id: args.upload_id };
991
+ if (args.package_sha256 !== undefined)
992
+ body['package_sha256'] = args.package_sha256;
993
+ if (args.resolutions !== undefined)
994
+ body['resolutions'] = args.resolutions;
995
+ if (args.allow_missing_dependencies !== undefined)
996
+ body['allow_missing_dependencies'] = args.allow_missing_dependencies;
997
+ return await client.postJson('/api/assets/package/import', body, exec.signal);
998
+ },
999
+ }));
1000
+ // ── revisions ────────────────────────────────────────────────────────────
1001
+ ctx.tools.register(defineTool({
1002
+ name: 'novel_revisions_list',
1003
+ description: 'List revision proposals, optionally filtered by chapter and/or status.',
1004
+ parameters: {
1005
+ chapter: { type: 'string', description: 'Chapter id filter (e.g. "ch_0007").' },
1006
+ status: { type: 'string', description: 'Status filter (e.g. "proposed", "applied", "rejected").' },
1007
+ },
1008
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1009
+ timeoutMs,
1010
+ isConcurrencySafe: () => true,
1011
+ async execute(args, exec) {
1012
+ const client = clientFor(exec);
1013
+ const params = {};
1014
+ if (args.chapter !== undefined)
1015
+ params['chapter'] = args.chapter;
1016
+ if (args.status !== undefined)
1017
+ params['status'] = args.status;
1018
+ return await client.getJson('/api/revisions', params, exec.signal);
1019
+ },
1020
+ }));
1021
+ ctx.tools.register(defineTool({
1022
+ name: 'novel_revision_get',
1023
+ description: 'Get one revision proposal with its diff hunks by proposal id (rev_*).',
1024
+ parameters: {
1025
+ proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1026
+ },
1027
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1028
+ timeoutMs,
1029
+ isConcurrencySafe: () => true,
1030
+ async execute(args, exec) {
1031
+ const client = clientFor(exec);
1032
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1033
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1034
+ return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
1035
+ },
1036
+ }));
1037
+ ctx.tools.register(defineTool({
1038
+ name: 'novel_revision_create_selection',
1039
+ description: 'Create a revision proposal for a text selection in a chapter (LONG-RUNNING, model-generated). ' +
1040
+ 'start/end are character offsets; pass the selected text as original_text. The proposal is only staged — ' +
1041
+ 'apply it with novel_revision_apply.',
1042
+ parameters: {
1043
+ chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1044
+ start: { type: 'integer', required: true, description: 'Selection start offset.' },
1045
+ end: { type: 'integer', required: true, description: 'Selection end offset.' },
1046
+ original_text: { type: 'string', description: 'The selected original text.' },
1047
+ instruction: { type: 'string', description: 'Revision instruction (max 4000 characters).' },
1048
+ action: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
1049
+ target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1050
+ full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
1051
+ },
1052
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1053
+ timeoutMs,
1054
+ async execute(args, exec) {
1055
+ const client = clientFor(exec);
1056
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1057
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1058
+ const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
1059
+ if (args.original_text !== undefined)
1060
+ body['original_text'] = args.original_text;
1061
+ if (args.instruction !== undefined)
1062
+ body['instruction'] = args.instruction;
1063
+ if (args.action !== undefined)
1064
+ body['action'] = args.action;
1065
+ if (args.target_units !== undefined)
1066
+ body['target_units'] = args.target_units;
1067
+ if (args.full_chapter !== undefined)
1068
+ body['full_chapter'] = args.full_chapter;
1069
+ return await client.postJson('/api/revisions/selection', body, exec.signal);
1070
+ },
1071
+ }));
1072
+ ctx.tools.register(defineTool({
1073
+ name: 'novel_revision_create_from_review',
1074
+ description: 'Create a revision proposal that addresses selected issues from a chapter review (LONG-RUNNING, model-generated). ' +
1075
+ 'Issue ids come from novel_review_chapter\'s issue_details. The proposal is only staged — apply it with novel_revision_apply.',
1076
+ parameters: {
1077
+ chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1078
+ issue_ids: { type: 'array', required: true, items: { type: 'string' }, description: 'Review issue ids to fix.' },
1079
+ instruction: { type: 'string', description: 'Additional revision instruction.' },
1080
+ target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
1081
+ expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
1082
+ expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
1083
+ },
1084
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1085
+ timeoutMs,
1086
+ async execute(args, exec) {
1087
+ const client = clientFor(exec);
1088
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1089
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1090
+ if (args.issue_ids.length === 0)
1091
+ throw new Error('issue_ids must be a non-empty array');
1092
+ const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
1093
+ if (args.instruction !== undefined)
1094
+ body['instruction'] = args.instruction;
1095
+ if (args.target_units !== undefined)
1096
+ body['target_units'] = args.target_units;
1097
+ if (args.expected_review_revision !== undefined)
1098
+ body['expected_review_revision'] = args.expected_review_revision;
1099
+ if (args.expected_document_revision !== undefined)
1100
+ body['expected_document_revision'] = args.expected_document_revision;
1101
+ const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
1102
+ try {
1103
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
1104
+ }
1105
+ catch (error) {
1106
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1107
+ }
1108
+ },
1109
+ }));
1110
+ ctx.tools.register(defineTool({
1111
+ name: 'novel_revision_apply',
1112
+ description: 'Apply a staged revision proposal to the manuscript. Optionally override the generated text or apply only ' +
1113
+ 'selected diff hunks. Fails with a conflict if the document changed since the proposal was created.',
1114
+ parameters: {
1115
+ proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1116
+ replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
1117
+ selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
1118
+ },
1119
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1120
+ timeoutMs,
1121
+ async execute(args, exec) {
1122
+ const client = clientFor(exec);
1123
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1124
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1125
+ const body = {};
1126
+ if (args.replacement_text !== undefined)
1127
+ body['replacement_text'] = args.replacement_text;
1128
+ if (args.selected_hunk_ids !== undefined)
1129
+ body['selected_hunk_ids'] = args.selected_hunk_ids;
1130
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
1131
+ const responseValue = objectValue(response);
1132
+ const proposal = objectValue(responseValue['proposal'] ?? response);
1133
+ const chapterId = String(proposal['chapter_id'] ?? '');
1134
+ try {
1135
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1136
+ }
1137
+ catch (error) {
1138
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1139
+ }
1140
+ },
1141
+ }));
1142
+ ctx.tools.register(defineTool({
1143
+ name: 'novel_revision_reject',
1144
+ description: 'Reject and discard a staged revision proposal. This permanently drops the generated text — it cannot be ' +
1145
+ 'recovered except by regenerating.',
1146
+ parameters: {
1147
+ proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
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 (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1154
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1155
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/reject`, {}, exec.signal);
1156
+ const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1157
+ try {
1158
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1159
+ }
1160
+ catch (error) {
1161
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1162
+ }
1163
+ },
1164
+ }));
1165
+ ctx.tools.register(defineTool({
1166
+ name: 'novel_revision_regenerate',
1167
+ description: 'Regenerate the text of a staged revision proposal (LONG-RUNNING, model-generated).',
1168
+ parameters: {
1169
+ proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
1170
+ },
1171
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1172
+ timeoutMs,
1173
+ async execute(args, exec) {
1174
+ const client = clientFor(exec);
1175
+ if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
1176
+ throw new Error(`invalid proposal_id "${args.proposal_id}"`);
1177
+ const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
1178
+ const chapterId = String(objectValue(response)['chapter_id'] ?? '');
1179
+ try {
1180
+ return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
1181
+ }
1182
+ catch (error) {
1183
+ return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
1184
+ }
1185
+ },
1186
+ }));
1187
+ // ── background tasks ─────────────────────────────────────────────────────
1188
+ ctx.tools.register(defineTool({
1189
+ name: 'novel_tasks_list',
1190
+ description: 'List background tasks (queued/running/finished AI jobs) with per-status counts.',
1191
+ parameters: {
1192
+ limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
1193
+ },
1194
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1195
+ timeoutMs,
1196
+ isConcurrencySafe: () => true,
1197
+ async execute(args, exec) {
1198
+ const client = clientFor(exec);
1199
+ const params = {};
1200
+ if (args.limit !== undefined)
1201
+ params['limit'] = String(args.limit);
1202
+ return await client.getJson('/api/tasks', params, exec.signal);
1203
+ },
1204
+ }));
1205
+ ctx.tools.register(defineTool({
1206
+ name: 'novel_task_get',
1207
+ 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.',
1208
+ parameters: {
1209
+ task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1210
+ },
1211
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1212
+ timeoutMs,
1213
+ async execute(args, exec) {
1214
+ const client = clientFor(exec);
1215
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1216
+ throw new Error(`invalid task_id "${args.task_id}"`);
1217
+ const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
1218
+ const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
1219
+ ? response
1220
+ : {};
1221
+ const taskValue = responseRecord['task'] ?? response;
1222
+ const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
1223
+ ? taskValue
1224
+ : {};
1225
+ if (taskRecord['status'] !== 'completed')
1226
+ return response;
1227
+ const taskType = String(taskRecord['type'] ?? '');
1228
+ if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
1229
+ return response;
1230
+ const taskResult = objectValue(taskRecord['result']);
1231
+ const taskInput = objectValue(taskRecord['input']);
1232
+ const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
1233
+ || String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
1234
+ || '';
1235
+ const value = response !== null && typeof response === 'object' && !Array.isArray(response)
1236
+ ? response
1237
+ : { task: response };
1238
+ if (!/^ch_\d+$/.test(chapterId)) {
1239
+ return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
1240
+ }
1241
+ let workspace;
1242
+ try {
1243
+ workspace = await client.getJson('/api/workspace', {}, exec.signal);
1244
+ }
1245
+ catch (error) {
1246
+ const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1247
+ return taskType === 'chapter_review'
1248
+ ? { ...value, dog_review: unavailable, dog_delivery: unavailable }
1249
+ : { ...value, dog_delivery: unavailable };
1250
+ }
1251
+ let dogDelivery;
1252
+ try {
1253
+ dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
1254
+ }
1255
+ catch (error) {
1256
+ dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1257
+ }
1258
+ if (taskType !== 'chapter_review')
1259
+ return { ...value, dog_delivery: dogDelivery };
1260
+ let dogReview;
1261
+ try {
1262
+ const framework = await client.getJson('/api/review/framework', {}, exec.signal);
1263
+ dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, 70, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
1264
+ }
1265
+ catch (error) {
1266
+ dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
1267
+ }
1268
+ return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
1269
+ },
1270
+ }));
1271
+ ctx.tools.register(defineTool({
1272
+ name: 'novel_task_create',
1273
+ description: 'Queue a background AI task and return immediately; poll with novel_task_get. Task types and their `input` keys: ' +
1274
+ 'chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
1275
+ 'chapter_review {chapter_id | path, strict?, dimensions?}; ' +
1276
+ 'revision_selection / revision_from_review (same inputs as the novel_revision_create_* tools); ' +
1277
+ 'source_operation / reference_operation (same inputs as novel_source_action / novel_reference_library_action); ' +
1278
+ 'manuscript_import (same input as novel_import); research {prompt, search?, language?, quality?, llm?}; ' +
1279
+ 'manuscript_reconcile (durable acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); ' +
1280
+ 'settle_backfill {chapter_ids?, only_missing?} — prefer the novel_settle_backfill convenience tool; ' +
1281
+ 'continuous_write — prefer the novel_multi_write convenience tool.',
1282
+ parameters: {
1283
+ type: {
1284
+ type: 'string',
1285
+ required: true,
1286
+ enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
1287
+ description: 'Task type.',
1288
+ },
1289
+ input: { type: 'json', required: true, description: 'Task input object; shape depends on the task type (see description).' },
1290
+ },
1291
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1292
+ timeoutMs,
1293
+ async execute(args, exec) {
1294
+ const client = clientFor(exec);
1295
+ if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
1296
+ throw new Error('input must be a JSON object');
1297
+ }
1298
+ return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
1299
+ },
1300
+ }));
1301
+ ctx.tools.register(defineTool({
1302
+ name: 'novel_task_cancel',
1303
+ description: 'Cancel a queued or running background task. Cancellation is permanent for that task; retry with novel_task_retry if the type supports it.',
1304
+ parameters: {
1305
+ task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1306
+ },
1307
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1308
+ timeoutMs,
1309
+ async execute(args, exec) {
1310
+ const client = clientFor(exec);
1311
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1312
+ throw new Error(`invalid task_id "${args.task_id}"`);
1313
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, {}, exec.signal);
1314
+ },
1315
+ }));
1316
+ ctx.tools.register(defineTool({
1317
+ name: 'novel_task_retry',
1318
+ description: 'Retry a failed or cancelled background task.',
1319
+ parameters: {
1320
+ task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1321
+ },
1322
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1323
+ timeoutMs,
1324
+ async execute(args, exec) {
1325
+ const client = clientFor(exec);
1326
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1327
+ throw new Error(`invalid task_id "${args.task_id}"`);
1328
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/retry`, {}, exec.signal);
1329
+ },
1330
+ }));
1331
+ ctx.tools.register(defineTool({
1332
+ name: 'novel_task_confirm',
1333
+ description: 'Confirm a background task that is awaiting confirmation (e.g. a continuous_write run paused between chapters), letting it resume.',
1334
+ parameters: {
1335
+ task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
1336
+ },
1337
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1338
+ timeoutMs,
1339
+ async execute(args, exec) {
1340
+ const client = clientFor(exec);
1341
+ if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
1342
+ throw new Error(`invalid task_id "${args.task_id}"`);
1343
+ return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/confirm`, {}, exec.signal);
1344
+ },
1345
+ }));
1346
+ ctx.tools.register(defineTool({
1347
+ name: 'novel_multi_write',
1348
+ description: 'Queue a BACKGROUND continuous-writing run: writes up to max_chapters chapters following the outline, ' +
1349
+ 'reviewing each; stops early on review blockers, continuity errors, low scores, or budget limits. ' +
1350
+ 'Returns immediately with a task id — poll with novel_task_get and resume paused runs with novel_task_confirm.',
1351
+ parameters: {
1352
+ max_chapters: { type: 'integer', description: 'Chapters to write, 1-10 (default 1).' },
1353
+ minimum_review_score: { type: 'integer', description: 'Stop when a chapter scores below this, 0-100 (default 82).' },
1354
+ max_tokens: { type: 'integer', description: 'Token budget, 0 = unlimited (default 0).' },
1355
+ max_failures: { type: 'integer', description: 'Stop after this many consecutive failures, 1-10 (default 2).' },
1356
+ max_cost_usd: { type: 'number', description: 'Cost budget in USD, 0 = unlimited (default 0).' },
1357
+ guidance: { type: 'string', description: 'Writing guidance applied to every chapter.' },
1358
+ target_words: { type: 'integer', description: 'Target words per chapter (falls back to the outline recommendation).' },
1359
+ temperature: { type: 'number', description: 'Sampling temperature (default 0.7).' },
1360
+ stop_on_blocker: { type: 'boolean', description: 'Stop when a review reports a blocker issue (default true).' },
1361
+ stop_on_continuity_error: { type: 'boolean', description: 'Stop on high-severity continuity issues (default true).' },
1362
+ require_confirmation_after_each_chapter: { type: 'boolean', description: 'Pause for novel_task_confirm after each chapter (default false).' },
1363
+ },
1364
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1365
+ timeoutMs,
1366
+ async execute(args, exec) {
1367
+ const client = clientFor(exec);
1368
+ const input = {};
1369
+ if (args.max_chapters !== undefined)
1370
+ input['max_chapters'] = args.max_chapters;
1371
+ if (args.minimum_review_score !== undefined)
1372
+ input['minimum_review_score'] = args.minimum_review_score;
1373
+ if (args.max_tokens !== undefined)
1374
+ input['max_tokens'] = args.max_tokens;
1375
+ if (args.max_failures !== undefined)
1376
+ input['max_failures'] = args.max_failures;
1377
+ if (args.max_cost_usd !== undefined)
1378
+ input['max_cost_usd'] = args.max_cost_usd;
1379
+ if (args.guidance !== undefined)
1380
+ input['guidance'] = args.guidance;
1381
+ if (args.target_words !== undefined)
1382
+ input['target_words'] = args.target_words;
1383
+ if (args.temperature !== undefined)
1384
+ input['temperature'] = args.temperature;
1385
+ if (args.stop_on_blocker !== undefined)
1386
+ input['stop_on_blocker'] = args.stop_on_blocker;
1387
+ if (args.stop_on_continuity_error !== undefined)
1388
+ input['stop_on_continuity_error'] = args.stop_on_continuity_error;
1389
+ if (args.require_confirmation_after_each_chapter !== undefined)
1390
+ input['require_confirmation_after_each_chapter'] = args.require_confirmation_after_each_chapter;
1391
+ return await client.postJson('/api/tasks', { type: 'continuous_write', input }, exec.signal);
1392
+ },
1393
+ }));
1394
+ // ── project lifecycle ────────────────────────────────────────────────────
1395
+ ctx.tools.register(defineTool({
1396
+ name: 'novel_project_init',
1397
+ description: 'Create a new OpenWrite novel project on the server machine and make it the active project. ' +
1398
+ 'Works without a currently open project. novel_id must be 2-64 chars of letters/digits/"-"/"_".',
1399
+ parameters: {
1400
+ novel_id: { type: 'string', required: true, description: 'Project id, e.g. "my_novel" (2-64 chars: letters, digits, "-", "_").' },
1401
+ title: { type: 'string', required: true, description: 'Book title (max 120 characters).' },
1402
+ project_path: { type: 'string', description: 'Target directory on the server; omit to use the server default location.' },
1403
+ template: { type: 'string', enum: ['default', 'demo_short'], description: 'Project template (default "default").' },
1404
+ },
1405
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1406
+ timeoutMs,
1407
+ async execute(args, exec) {
1408
+ const client = clientFor(exec);
1409
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(args.novel_id)) {
1410
+ throw new Error('novel_id must be 2-64 chars of letters, digits, "-" or "_"');
1411
+ }
1412
+ if (!args.title.trim() || args.title.length > 120)
1413
+ throw new Error('title must be non-empty and at most 120 characters');
1414
+ const body = { novel_id: args.novel_id, title: args.title };
1415
+ if (args.project_path !== undefined)
1416
+ body['project_path'] = args.project_path;
1417
+ if (args.template !== undefined)
1418
+ body['template'] = args.template;
1419
+ return await client.postJson('/api/project/init', body, exec.signal);
1420
+ },
1421
+ }));
1422
+ ctx.tools.register(defineTool({
1423
+ name: 'novel_project_open',
1424
+ description: 'Switch the Studio server to another existing project directory (must contain novel_config.yaml). ' +
1425
+ 'Works without a currently open project; afterwards every other novel_* tool targets the newly opened project. ' +
1426
+ 'The server rejects the switch while a background task is active.',
1427
+ parameters: {
1428
+ project_path: { type: 'string', required: true, description: 'Absolute project directory path on the server machine.' },
1429
+ },
1430
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1431
+ timeoutMs,
1432
+ async execute(args, exec) {
1433
+ const client = clientFor(exec);
1434
+ if (!args.project_path.trim())
1435
+ throw new Error('project_path must be a non-empty string');
1436
+ return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
1437
+ },
1438
+ }));
1439
+ ctx.tools.register(defineTool({
1440
+ name: 'novel_project_delete',
1441
+ description: 'PERMANENTLY delete a project directory and all its contents from the server machine (recursive delete, ' +
1442
+ 'not recoverable). The server requires confirm to exactly equal the project\'s novel_id as a safeguard.',
1443
+ parameters: {
1444
+ project_path: { type: 'string', required: true, description: 'Project directory to delete.' },
1445
+ confirm: { type: 'string', required: true, description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
1446
+ },
1447
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1448
+ timeoutMs,
1449
+ async execute(args, exec) {
1450
+ const client = clientFor(exec);
1451
+ if (!args.project_path.trim() || !args.confirm.trim())
1452
+ throw new Error('project_path and confirm are required');
1453
+ return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm }, exec.signal);
1454
+ },
1455
+ }));
1456
+ // ── chapters, documents, import, sync ────────────────────────────────────
1457
+ ctx.tools.register(defineTool({
1458
+ name: 'novel_chapter_delete',
1459
+ description: 'Delete the LATEST manuscript chapter. The server enforces three safeguards: the path must be a chapter under ' +
1460
+ 'data/manuscript, confirm must exactly equal the chapter id (e.g. "ch_0007"), and version must equal the ' +
1461
+ 'document version fingerprint from a fresh novel_doc_read. Only the newest chapter can be deleted, and never ' +
1462
+ 'while a background task is active.',
1463
+ parameters: {
1464
+ path: { type: 'string', required: true, description: 'Chapter path, e.g. "data/manuscript/ch_0007.md".' },
1465
+ confirm: { type: 'string', required: true, description: 'Must exactly equal the chapter id, e.g. "ch_0007".' },
1466
+ version: { type: 'string', required: true, description: 'The document version fingerprint from novel_doc_read.' },
1467
+ },
1468
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1469
+ timeoutMs,
1470
+ async execute(args, exec) {
1471
+ const client = clientFor(exec);
1472
+ return await client.postJson('/api/chapter/delete', { path: args.path, confirm: args.confirm, version: args.version }, exec.signal);
1473
+ },
1474
+ }));
1475
+ ctx.tools.register(defineTool({
1476
+ name: 'novel_chapter_delete_batch',
1477
+ description: 'Delete the contiguous latest manuscript tail in one server request, retaining the specified chapter. ' +
1478
+ 'The server still performs the single-chapter backup/projection cleanup for every chapter and refuses ' +
1479
+ 'active tasks, missing chapters, non-tail ranges, or stale latest-version fingerprints. ' +
1480
+ 'confirm must exactly equal "DELETE_AFTER:<keep_through>" (for example "DELETE_AFTER:ch_0030").',
1481
+ parameters: {
1482
+ keep_through: { type: 'string', required: true, description: 'Last chapter to retain, e.g. "ch_0030".' },
1483
+ confirm: { type: 'string', required: true, description: 'Must exactly equal DELETE_AFTER:<keep_through>.' },
1484
+ version: { type: 'string', required: true, description: 'The latest chapter version fingerprint from a fresh novel_doc_read.' },
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
+ return await client.postJson('/api/chapter/delete-batch', { keep_through: args.keep_through, confirm: args.confirm, version: args.version }, exec.signal);
1491
+ },
1492
+ }));
1493
+ ctx.tools.register(defineTool({
1494
+ name: 'novel_doc_create',
1495
+ description: 'Create a new project document by kind and name (e.g. a setting or character note), then returns the created document.',
1496
+ parameters: {
1497
+ kind: { type: 'string', required: true, description: 'Document kind (e.g. "setting", "character").' },
1498
+ name: { type: 'string', required: true, description: 'Document name.' },
1499
+ description: { type: 'string', description: 'Short description of the document.' },
1500
+ },
1501
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1502
+ timeoutMs,
1503
+ async execute(args, exec) {
1504
+ const client = clientFor(exec);
1505
+ if (!args.kind.trim() || !args.name.trim())
1506
+ throw new Error('kind and name must be non-empty strings');
1507
+ const body = { kind: args.kind, name: args.name };
1508
+ if (args.description !== undefined)
1509
+ body['description'] = args.description;
1510
+ return await client.postJson('/api/document/create', body, exec.signal);
1511
+ },
1512
+ }));
1513
+ ctx.tools.register(defineTool({
1514
+ name: 'novel_import_preview',
1515
+ description: 'Preview a TXT/Markdown manuscript import without writing anything: detected chapters, numbering, and ' +
1516
+ 'conflicts with existing chapters. Run this before novel_import.',
1517
+ parameters: {
1518
+ content: { type: 'string', required: true, description: 'The manuscript text to import.' },
1519
+ filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1520
+ arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1521
+ start_number: { type: 'integer', description: 'First chapter number (default: after the last existing chapter).' },
1522
+ force: { type: 'boolean', description: 'Report the import as allowed despite conflicts (default false).' },
1523
+ },
1524
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1525
+ timeoutMs,
1526
+ isConcurrencySafe: () => true,
1527
+ async execute(args, exec) {
1528
+ const client = clientFor(exec);
1529
+ if (!args.content.trim())
1530
+ throw new Error('content must be a non-empty string');
1531
+ const body = { content: args.content };
1532
+ if (args.filename !== undefined)
1533
+ body['filename'] = args.filename;
1534
+ if (args.arc_id !== undefined)
1535
+ body['arc_id'] = args.arc_id;
1536
+ if (args.start_number !== undefined)
1537
+ body['start_number'] = args.start_number;
1538
+ if (args.force !== undefined)
1539
+ body['force'] = args.force;
1540
+ return await client.postJson('/api/import/preview', body, exec.signal);
1541
+ },
1542
+ }));
1543
+ ctx.tools.register(defineTool({
1544
+ name: 'novel_import',
1545
+ description: 'Import a TXT/Markdown manuscript as chapters (split on headings). Preview first with novel_import_preview; ' +
1546
+ 'the server rejects conflicting chapter ids unless force is set.',
1547
+ parameters: {
1548
+ content: { type: 'string', required: true, description: 'The manuscript text to import.' },
1549
+ filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
1550
+ arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
1551
+ start_number: { type: 'integer', description: 'First chapter number (default: continue numbering).' },
1552
+ force: { type: 'boolean', description: 'Overwrite conflicting existing chapters (default false).' },
1553
+ },
1554
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1555
+ timeoutMs,
1556
+ async execute(args, exec) {
1557
+ const client = clientFor(exec);
1558
+ if (!args.content.trim())
1559
+ throw new Error('content must be a non-empty string');
1560
+ const body = { content: args.content };
1561
+ if (args.filename !== undefined)
1562
+ body['filename'] = args.filename;
1563
+ if (args.arc_id !== undefined)
1564
+ body['arc_id'] = args.arc_id;
1565
+ if (args.start_number !== undefined)
1566
+ body['start_number'] = args.start_number;
1567
+ if (args.force !== undefined)
1568
+ body['force'] = args.force;
1569
+ return await client.postJson('/api/import', body, exec.signal);
1570
+ },
1571
+ }));
1572
+ ctx.tools.register(defineTool({
1573
+ name: 'novel_manuscript_import_action',
1574
+ description: 'Operate the durable, resumable import workspace for the author\'s own TXT/Markdown manuscript. ' +
1575
+ 'Use list/get to inspect saved operations, prepare to snapshot and split a manuscript, structure to revise the ' +
1576
+ 'chapter boundaries against the current preview revision, confirm for explicit structure approval, run to start ' +
1577
+ 'or resume the persistent import task, and discard only before publication. This is manuscript migration, not ' +
1578
+ 'reference-library analysis.',
1579
+ parameters: {
1580
+ action: { type: 'string', required: true, enum: ['list', 'get', 'prepare', 'structure', 'confirm', 'run', 'discard'], description: 'Import workspace action.' },
1581
+ import_id: { type: 'string', description: 'Persistent import operation id.' },
1582
+ limit: { type: 'integer', description: 'Maximum operations for list (default 50).' },
1583
+ filename: { type: 'string', description: 'Source filename ending in .txt, .md, or .markdown.' },
1584
+ content: { type: 'string', description: 'The author\'s own manuscript text for prepare.' },
1585
+ arc_id: { type: 'string', description: 'Target arc id, e.g. arc_001.' },
1586
+ start_number: { type: 'integer', description: 'Optional first chapter number.' },
1587
+ expected_preview_revision: { type: 'string', description: 'Current preview revision required by structure/confirm.' },
1588
+ chapters: { type: 'array', items: { type: 'json' }, description: 'Edited chapter objects: chapter_id, title, content.' },
1589
+ confirm: { type: 'boolean', description: 'Must be true for confirm or discard.' },
1590
+ },
1591
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1592
+ timeoutMs,
1593
+ async execute(args, exec) {
1594
+ const client = clientFor(exec);
1595
+ const importId = args.import_id?.trim();
1596
+ if (args.action === 'list') {
1597
+ return await client.getJson('/api/manuscript-imports', args.limit === undefined ? {} : { limit: String(args.limit) }, exec.signal);
1598
+ }
1599
+ if (args.action === 'prepare') {
1600
+ if (!args.content?.trim())
1601
+ throw new Error('prepare requires non-empty content');
1602
+ if (!args.arc_id?.trim())
1603
+ throw new Error('prepare requires arc_id');
1604
+ const body = { filename: args.filename ?? 'import.md', content: args.content, arc_id: args.arc_id };
1605
+ if (args.start_number !== undefined)
1606
+ body['start_number'] = args.start_number;
1607
+ return await client.postJson('/api/manuscript-imports/prepare', body, exec.signal);
1608
+ }
1609
+ if (!importId || !/^import_[A-Za-z0-9_-]{12,80}$/.test(importId))
1610
+ throw new Error(`${args.action} requires a valid import_id`);
1611
+ if (args.action === 'get') {
1612
+ return await client.getJson(`/api/manuscript-imports/${encodeURIComponent(importId)}`, {}, exec.signal);
1613
+ }
1614
+ if (args.action === 'structure') {
1615
+ if (!args.expected_preview_revision?.trim())
1616
+ throw new Error('structure requires expected_preview_revision');
1617
+ if (!Array.isArray(args.chapters) || args.chapters.length === 0)
1618
+ throw new Error('structure requires non-empty chapters');
1619
+ return await client.postJson('/api/manuscript-imports/structure', {
1620
+ import_id: importId,
1621
+ expected_preview_revision: args.expected_preview_revision,
1622
+ chapters: args.chapters,
1623
+ }, exec.signal);
1624
+ }
1625
+ if (args.action === 'confirm') {
1626
+ if (!args.expected_preview_revision?.trim())
1627
+ throw new Error('confirm requires expected_preview_revision');
1628
+ if (args.confirm !== true)
1629
+ throw new Error('confirm requires confirm=true');
1630
+ return await client.postJson('/api/manuscript-imports/confirm', {
1631
+ import_id: importId,
1632
+ expected_preview_revision: args.expected_preview_revision,
1633
+ confirm: true,
1634
+ }, exec.signal);
1635
+ }
1636
+ if (args.action === 'run') {
1637
+ return await client.postJson('/api/manuscript-imports/run', { import_id: importId }, exec.signal);
1638
+ }
1639
+ if (args.confirm !== true)
1640
+ throw new Error('discard requires confirm=true');
1641
+ return await client.postJson('/api/manuscript-imports/discard', { import_id: importId, confirm: true }, exec.signal);
1642
+ },
1643
+ }));
1644
+ ctx.tools.register(defineTool({
1645
+ name: 'novel_project_archive_action',
1646
+ description: 'Inspect, create, and restore complete .owarchive project archives. Use preflight before create; use ' +
1647
+ 'restore_preview before restore to inspect path/reference rewrites, conflicts, warnings, checksum, and the ' +
1648
+ 'archived-without-auto-resume task policy. Restore requires the preview archive_sha256 and explicit confirmation.',
1649
+ parameters: {
1650
+ action: { type: 'string', required: true, enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle action.' },
1651
+ archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
1652
+ expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
1653
+ target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
1654
+ target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
1655
+ reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
1656
+ archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
1657
+ confirm: { type: 'boolean', description: 'Must be true for restore.' },
1658
+ },
1659
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1660
+ timeoutMs,
1661
+ async execute(args, exec) {
1662
+ const client = clientFor(exec);
1663
+ if (args.action === 'preflight')
1664
+ return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
1665
+ if (args.action === 'list')
1666
+ return await client.getJson('/api/project-archives', {}, exec.signal);
1667
+ if (args.action === 'create') {
1668
+ if (!args.expected_preflight_revision?.trim())
1669
+ throw new Error('create requires expected_preflight_revision');
1670
+ return await client.postJson('/api/project-archives/create', {
1671
+ expected_preflight_revision: args.expected_preflight_revision,
1672
+ }, exec.signal);
1673
+ }
1674
+ const archiveId = args.archive_id?.trim();
1675
+ if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
1676
+ throw new Error(`${args.action} requires a valid archive_id`);
1677
+ if (args.action === 'get')
1678
+ return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
1679
+ if (!args.target_root?.trim())
1680
+ throw new Error(`${args.action} requires target_root`);
1681
+ const body = {
1682
+ archive_id: archiveId,
1683
+ target_root: args.target_root,
1684
+ reference_policy: args.reference_policy ?? 'preserve_relative',
1685
+ };
1686
+ if (args.target_novel_id !== undefined)
1687
+ body['target_novel_id'] = args.target_novel_id;
1688
+ if (args.action === 'restore_preview') {
1689
+ return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
1690
+ }
1691
+ if (!args.archive_sha256?.trim())
1692
+ throw new Error('restore requires archive_sha256');
1693
+ if (args.confirm !== true)
1694
+ throw new Error('restore requires confirm=true');
1695
+ body['archive_sha256'] = args.archive_sha256;
1696
+ body['confirm'] = true;
1697
+ return await client.postJson('/api/project-archives/restore', body, exec.signal);
1698
+ },
1699
+ }));
1700
+ ctx.tools.register(defineTool({
1701
+ name: 'novel_project_archive_download',
1702
+ description: 'Download one complete .owarchive project archive and save it in this workspace\'s local export directory.',
1703
+ parameters: {
1704
+ archive_id: { type: 'string', required: true, description: 'Archive id shaped owa_<24 lowercase hex>.' },
1705
+ },
1706
+ output: {
1707
+ schema: {
1708
+ type: 'object',
1709
+ additionalProperties: false,
1710
+ properties: {
1711
+ path: { type: 'string', required: true },
1712
+ filename: { type: 'string', required: true },
1713
+ archive_id: { type: 'string', required: true },
1714
+ bytes: { type: 'integer', required: true },
1715
+ },
1716
+ },
1717
+ render: (_args, value) => [{ type: 'text', text: `Downloaded ${value.filename} (${value.bytes} bytes) to ${value.path}` }],
1718
+ },
1719
+ timeoutMs,
1720
+ async execute(args, exec) {
1721
+ if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
1722
+ throw new Error('archive_id must match owa_<24 lowercase hex>');
1723
+ const client = clientFor(exec);
1724
+ const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
1725
+ const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
1726
+ const exportDir = outputDirFor(outputDir, client);
1727
+ await mkdir(exportDir, { recursive: true });
1728
+ const path = join(exportDir, filename);
1729
+ await writeFile(path, download.content);
1730
+ return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
1731
+ },
1732
+ }));
1733
+ ctx.tools.register(defineTool({
1734
+ name: 'novel_sync',
1735
+ description: 'Synchronize derived caches that are direct src derivatives: outline hierarchy (data/hierarchy.yaml) and ' +
1736
+ 'character cards (data/characters/cards/*.yaml). It does NOT rebuild chapter memory / runtime truth / ' +
1737
+ 'character state: those are produced by the chapter-write settlement, so imported or pre-existing chapters ' +
1738
+ 'need a one-time novel_settle_backfill. The sync response also reports memory_missing / memory_missing_count.',
1739
+ parameters: {},
1740
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1741
+ timeoutMs,
1742
+ async execute(_args, exec) {
1743
+ const client = clientFor(exec);
1744
+ return await client.postJson('/api/sync', {}, exec.signal);
1745
+ },
1746
+ }));
1747
+ ctx.tools.register(defineTool({
1748
+ name: 'novel_settle_backfill',
1749
+ description: 'Settlement backfill for existing/imported chapters: replay the writer pipeline observer+settler over the ' +
1750
+ 'actual chapter prose (never rewriting it) and commit chapter memory, runtime-truth notes and a ' +
1751
+ 'character-state index refresh. Run once after importing an old manuscript so later chapters, reviews and ' +
1752
+ 'canon/continuity checks have real facts from earlier chapters (without it, review canon stays inconclusive ' +
1753
+ 'and context lacks previous-chapter recaps). Returns a background task (tsk_*); poll with novel_task_get.',
1754
+ parameters: {
1755
+ chapter_ids: {
1756
+ type: 'array',
1757
+ description: 'Optional chapter ids to backfill, e.g. ["ch_001","ch_002"]; omit to backfill every chapter missing memory.',
1758
+ items: { type: 'string' },
1759
+ },
1760
+ only_missing: { type: 'boolean', description: 'Skip chapters that already have memory (default true).' },
1761
+ },
1762
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1763
+ timeoutMs,
1764
+ async execute(args, exec) {
1765
+ const client = clientFor(exec);
1766
+ const input = {};
1767
+ if (args.chapter_ids !== undefined)
1768
+ input['chapter_ids'] = args.chapter_ids;
1769
+ if (args.only_missing !== undefined)
1770
+ input['only_missing'] = args.only_missing;
1771
+ return await client.postJson('/api/tasks', { type: 'settle_backfill', input }, exec.signal);
1772
+ },
1773
+ }));
1774
+ ctx.tools.register(defineTool({
1775
+ name: 'novel_writing_targets',
1776
+ description: 'Immediate compatibility write for targets the author has already authorized exactly; otherwise use ' +
1777
+ 'novel_structured_change_plan. Update the project writing targets. Ranges: book_words 10000-5000000, chapter_words 200-12000, ' +
1778
+ 'outline_volume_words 100-5000, outline_act_words 80-3000, outline_section_words 50-2000, ' +
1779
+ 'outline_chapter_words 30-1000. Omitted fields keep their current values.',
1780
+ parameters: {
1781
+ book_words: { type: 'integer', description: 'Whole-book target word count.' },
1782
+ chapter_words: { type: 'integer', description: 'Default words per chapter.' },
1783
+ outline_volume_words: { type: 'integer', description: 'Outline summary words per volume.' },
1784
+ outline_act_words: { type: 'integer', description: 'Outline summary words per act.' },
1785
+ outline_section_words: { type: 'integer', description: 'Outline summary words per section.' },
1786
+ outline_chapter_words: { type: 'integer', description: 'Outline summary words per chapter.' },
1787
+ },
1788
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1789
+ timeoutMs,
1790
+ async execute(args, exec) {
1791
+ const client = clientFor(exec);
1792
+ const body = {};
1793
+ if (args.book_words !== undefined)
1794
+ body['book_words'] = args.book_words;
1795
+ if (args.chapter_words !== undefined)
1796
+ body['chapter_words'] = args.chapter_words;
1797
+ if (args.outline_volume_words !== undefined)
1798
+ body['outline_volume_words'] = args.outline_volume_words;
1799
+ if (args.outline_act_words !== undefined)
1800
+ body['outline_act_words'] = args.outline_act_words;
1801
+ if (args.outline_section_words !== undefined)
1802
+ body['outline_section_words'] = args.outline_section_words;
1803
+ if (args.outline_chapter_words !== undefined)
1804
+ body['outline_chapter_words'] = args.outline_chapter_words;
1805
+ if (Object.keys(body).length === 0)
1806
+ throw new Error('provide at least one writing target field');
1807
+ return await client.postJson('/api/project/writing-targets', body, exec.signal);
1808
+ },
1809
+ }));
1810
+ // ── continuity & diagnostics ─────────────────────────────────────────────
1811
+ ctx.tools.register(defineTool({
1812
+ name: 'novel_continuity',
1813
+ description: 'Get the continuity report: unresolved foreshadowing, character state drift, and timeline consistency of the current project.',
1814
+ parameters: {},
1815
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1816
+ timeoutMs,
1817
+ isConcurrencySafe: () => true,
1818
+ async execute(_args, exec) {
1819
+ const client = clientFor(exec);
1820
+ return await client.getJson('/api/continuity', {}, exec.signal);
1821
+ },
1822
+ }));
1823
+ ctx.tools.register(defineTool({
1824
+ name: 'novel_diagnostics',
1825
+ description: 'Run runtime diagnostics over the project (read-only): stuck runs, stale locks, and other operational issues.',
1826
+ parameters: {
1827
+ stuck_minutes: { type: 'integer', description: 'Minutes after which a run counts as stuck (default 30).' },
1828
+ },
1829
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1830
+ timeoutMs,
1831
+ isConcurrencySafe: () => true,
1832
+ async execute(args, exec) {
1833
+ const client = clientFor(exec);
1834
+ const body = {};
1835
+ if (args.stuck_minutes !== undefined)
1836
+ body['stuck_minutes'] = args.stuck_minutes;
1837
+ return await client.postJson('/api/diagnostics', body, exec.signal);
1838
+ },
1839
+ }));
1840
+ // ── planning: chapter runs, rolling plans, forecasts, manuscript editing ──
1841
+ ctx.tools.register(defineTool({
1842
+ name: 'novel_chapter_run_action',
1843
+ description: 'Manage Chapter Run V2 records (the server\'s per-chapter write/review run ledger). Actions: "list" ' +
1844
+ '(chapter_id?/statuses?/limit?), "get" (run_id), "record_intervention" (run_id + revision + request, ' +
1845
+ 'optional scope/risk/affected_items/rewrite_required), "update_intervention" (run_id + revision + ' +
1846
+ 'intervention_id + state, optional facts_revision/impact/proposal/confirm — state transitions may require ' +
1847
+ 'confirm: true), "cancel" (run_id + revision, optional reason). All mutating actions are revision-gated: ' +
1848
+ 'pass the run\'s current revision from a list/get call.',
1849
+ parameters: {
1850
+ action: { type: 'string', required: true, enum: ['list', 'get', 'record_intervention', 'update_intervention', 'cancel'], description: 'The chapter-run operation.' },
1851
+ run_id: { type: 'string', description: 'Run id (required for get/record_intervention/update_intervention/cancel).' },
1852
+ revision: { type: 'string', description: 'Current run revision (required for mutating actions).' },
1853
+ chapter_id: { type: 'string', description: 'Chapter filter for list.' },
1854
+ statuses: { type: 'array', items: { type: 'string' }, description: 'Status filter for list.' },
1855
+ limit: { type: 'integer', description: 'List limit (default 20).' },
1856
+ scope: { type: 'string', description: 'Intervention scope (default "chapter").' },
1857
+ risk: { type: 'string', description: 'Intervention risk level (default "medium").' },
1858
+ request: { type: 'string', description: 'Intervention request text.' },
1859
+ affected_items: { type: 'json', description: 'Array of affected item ids.' },
1860
+ rewrite_required: { type: 'boolean', description: 'Whether the intervention forces a rewrite.' },
1861
+ intervention_id: { type: 'string', description: 'Intervention id (update_intervention).' },
1862
+ state: { type: 'string', description: 'Target intervention state (update_intervention).' },
1863
+ facts_revision: { type: 'string', description: 'Facts revision for the transition (update_intervention).' },
1864
+ impact: { type: 'json', description: 'Impact array for the transition (update_intervention).' },
1865
+ proposal: { type: 'string', description: 'Proposal text for the transition (update_intervention).' },
1866
+ confirm: { type: 'boolean', description: 'Confirm a gated state transition (default false).' },
1867
+ reason: { type: 'string', description: 'Cancel reason (cancel).' },
1868
+ },
1869
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1870
+ timeoutMs,
1871
+ isConcurrencySafe: (args) => args.action === 'list' || args.action === 'get',
1872
+ async execute(args, exec) {
1873
+ const client = clientFor(exec);
1874
+ const body = { action: args.action };
1875
+ for (const key of ['run_id', 'revision', 'chapter_id', 'statuses', 'limit', 'scope', 'risk', 'request', 'affected_items', 'rewrite_required', 'intervention_id', 'state', 'facts_revision', 'impact', 'proposal', 'confirm', 'reason']) {
1876
+ const value = args[key];
1877
+ if (value !== undefined)
1878
+ body[key] = value;
1879
+ }
1880
+ return await client.postJson('/api/chapter-runs-v2', body, exec.signal);
1881
+ },
1882
+ }));
1883
+ ctx.tools.register(defineTool({
1884
+ name: 'novel_rolling_plan_action',
1885
+ description: 'Manage rolling plan candidates (mid-range plot planning windows). Actions: "list" (limit?), "create" ' +
1886
+ '(current_arc?, window_size? default 50, max 50). current_window/accepted_window is accepted manuscript ' +
1887
+ 'facts only; next_window/planned_window is outline plan, not facts. "get" (candidate_id), "stage" ' +
1888
+ '(candidate_id + proposal + revision — stages a plan proposal against the candidate; revision-gated), ' +
1889
+ '"delete" (candidate_id + revision), "apply" (candidate_id + revision — appends staged draft chapters ' +
1890
+ 'that do not collide with the canonical outline into src/outline.md; never writes accepted facts).',
1891
+ parameters: {
1892
+ action: { type: 'string', required: true, enum: ['list', 'create', 'get', 'stage', 'delete', 'apply'], description: 'The rolling-plan operation.' },
1893
+ limit: { type: 'integer', description: 'List limit (default 20).' },
1894
+ current_arc: { type: 'string', description: 'Current arc id (create).' },
1895
+ window_size: { type: 'integer', description: 'Planning window size in chapters (create, default 50, max 50).' },
1896
+ candidate_id: { type: 'string', description: 'Candidate id (get/stage/delete/apply).' },
1897
+ proposal: { type: 'string', description: 'Plan proposal text (stage).' },
1898
+ revision: { type: 'string', description: 'Candidate revision (stage/delete/apply, revision-gated).' },
1899
+ },
1900
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1901
+ timeoutMs,
1902
+ isConcurrencySafe: (args) => args.action === 'list' || args.action === 'get',
1903
+ async execute(args, exec) {
1904
+ const client = clientFor(exec);
1905
+ const body = { action: args.action };
1906
+ for (const key of ['limit', 'current_arc', 'window_size', 'candidate_id', 'proposal', 'revision']) {
1907
+ const value = args[key];
1908
+ if (value !== undefined)
1909
+ body[key] = value;
1910
+ }
1911
+ return await client.postJson('/api/rolling-plans', body, exec.signal);
1912
+ },
1913
+ }));
1914
+ ctx.tools.register(defineTool({
1915
+ name: 'novel_narrative_forecast_action',
1916
+ description: 'Manage narrative forecasts (branching "what-if" plot projections). Actions: "list" (limit?), "create" ' +
1917
+ '(divergence + anchor_chapter_id?, branch_count default 3, horizon default 5), "get" (forecast_id), ' +
1918
+ '"stage" (forecast_id + branches + revision), "select" (forecast_id + branch_id + revision — commits one ' +
1919
+ 'branch; revision-gated).',
1920
+ parameters: {
1921
+ action: { type: 'string', required: true, enum: ['list', 'create', 'get', 'stage', 'select'], description: 'The forecast operation.' },
1922
+ limit: { type: 'integer', description: 'List limit (default 20).' },
1923
+ divergence: { type: 'string', description: 'The divergence point description (create).' },
1924
+ anchor_chapter_id: { type: 'string', description: 'Chapter to anchor the forecast at (create).' },
1925
+ branch_count: { type: 'integer', description: 'Number of branches (create, default 3).' },
1926
+ horizon: { type: 'integer', description: 'Forecast horizon in chapters (create, default 5).' },
1927
+ forecast_id: { type: 'string', description: 'Forecast id (get/stage/select).' },
1928
+ branches: { type: 'json', description: 'Branch array to stage (stage).' },
1929
+ branch_id: { type: 'string', description: 'Branch id to commit (select).' },
1930
+ revision: { type: 'string', description: 'Forecast revision (stage/select, revision-gated).' },
1931
+ },
1932
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1933
+ timeoutMs,
1934
+ isConcurrencySafe: (args) => args.action === 'list' || args.action === 'get',
1935
+ async execute(args, exec) {
1936
+ const client = clientFor(exec);
1937
+ const body = { action: args.action };
1938
+ for (const key of ['limit', 'divergence', 'anchor_chapter_id', 'branch_count', 'horizon', 'forecast_id', 'branches', 'branch_id', 'revision']) {
1939
+ const value = args[key];
1940
+ if (value !== undefined)
1941
+ body[key] = value;
1942
+ }
1943
+ return await client.postJson('/api/narrative-forecasts', body, exec.signal);
1944
+ },
1945
+ }));
1946
+ ctx.tools.register(defineTool({
1947
+ name: 'novel_manuscript_edit_action',
1948
+ description: 'Chapter version snapshots and annotations. Actions: "versions" (chapter_id), "version" (chapter_id + ' +
1949
+ 'version_id — returns the snapshot content), "checkpoint" (chapter_id, optional label — save a snapshot), ' +
1950
+ '"compare" (chapter_id + version_id — preview current-to-snapshot differences without writing), ' +
1951
+ '"restore" (chapter_id + version_id + revision — REVERTS the chapter to a snapshot; revision-gated and ' +
1952
+ 'confirmation-gated: the server demands confirm: true), "annotations" (chapter_id), "annotate" (chapter_id + ' +
1953
+ 'revision + quote + note, optional start_hint/end_hint and color amber|rose|sky|lime|violet), ' +
1954
+ '"resolve_annotation" (chapter_id + annotation_id).',
1955
+ parameters: {
1956
+ action: { type: 'string', required: true, enum: ['versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
1957
+ chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
1958
+ version_id: { type: 'string', description: 'Snapshot id (version/restore).' },
1959
+ label: { type: 'string', description: 'Snapshot label (checkpoint).' },
1960
+ revision: { type: 'string', description: 'Current chapter revision (restore/annotate).' },
1961
+ confirm: { type: 'boolean', description: 'Confirm a restore (required by the server).' },
1962
+ quote: { type: 'string', description: 'Annotated passage (annotate).' },
1963
+ note: { type: 'string', description: 'Annotation note (annotate).' },
1964
+ start_hint: { type: 'integer', description: 'Approximate start offset (annotate).' },
1965
+ end_hint: { type: 'integer', description: 'Approximate end offset (annotate).' },
1966
+ color: { type: 'string', enum: ['amber', 'rose', 'sky', 'lime', 'violet'], description: 'Display color for a selection note (annotate).' },
1967
+ annotation_id: { type: 'string', description: 'Annotation id (resolve_annotation).' },
1968
+ },
1969
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
1970
+ timeoutMs,
1971
+ isConcurrencySafe: (args) => args.action === 'versions' || args.action === 'version' || args.action === 'compare' || args.action === 'annotations',
1972
+ async execute(args, exec) {
1973
+ const client = clientFor(exec);
1974
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
1975
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
1976
+ const body = { action: args.action, chapter_id: args.chapter_id };
1977
+ for (const key of ['version_id', 'label', 'revision', 'confirm', 'quote', 'note', 'start_hint', 'end_hint', 'color', 'annotation_id']) {
1978
+ const value = args[key];
1979
+ if (value !== undefined)
1980
+ body[key] = value;
1981
+ }
1982
+ return await client.postJson('/api/manuscript-editing', body, exec.signal);
1983
+ },
1984
+ }));
1985
+ ctx.tools.register(defineTool({
1986
+ name: 'novel_manuscript_acceptance_reconcile',
1987
+ description: 'Reconcile the canonical manuscript acceptance ledger. Actions: "baseline" (accept current text as the ' +
1988
+ 'initial baseline), "external" (accept a detected external edit), "resume" (continue a durable incomplete ' +
1989
+ 'operation), and "acknowledge" (record that the author reviewed the completed acceptance and impacts). ' +
1990
+ 'Each action is mapped to the corresponding OpenWrite acceptance endpoint. Baseline, external, and ' +
1991
+ 'acknowledge are confirmation-gated by the server.',
1992
+ parameters: {
1993
+ action: { type: 'string', required: true, enum: ['baseline', 'external', 'resume', 'acknowledge'], description: 'The reconciliation action.' },
1994
+ chapter_id: { type: 'string', description: 'Optional chapter id (ch_<digits>); omit for the project-level operation.' },
1995
+ operation_id: { type: 'string', description: 'Durable operation id to resume or acknowledge.' },
1996
+ domains: { type: 'array', items: { type: 'string' }, description: 'Affected domains to acknowledge or retry; acknowledge defaults to outline and foreshadowing.' },
1997
+ confirm: { type: 'boolean', description: 'Explicit confirmation for a gated baseline, external acceptance, or acknowledgement.' },
1998
+ },
1999
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2000
+ timeoutMs,
2001
+ async execute(args, exec) {
2002
+ if (args.chapter_id !== undefined && !CHAPTER_ID_PATTERN.test(args.chapter_id)) {
2003
+ throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2004
+ }
2005
+ const body = {};
2006
+ for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm']) {
2007
+ const value = args[key];
2008
+ if (value !== undefined)
2009
+ body[key] = value;
2010
+ }
2011
+ if (args.action === 'acknowledge' && args.domains === undefined)
2012
+ body['domains'] = ['outline', 'foreshadowing'];
2013
+ const route = args.action === 'acknowledge' ? 'ack' : args.action === 'resume' ? 'reconcile' : args.action;
2014
+ return await clientFor(exec).postJson(`/api/manuscript/acceptance/${route}`, body, exec.signal);
2015
+ },
2016
+ }));
2017
+ // ── style sources, reference library, runtime skills, rules ──────────────
2018
+ ctx.tools.register(defineTool({
2019
+ name: 'novel_source_action',
2020
+ description: 'Style/setting source pipeline (learning from reference texts). Actions: "extract" (source_id + content, ' +
2021
+ 'focus "style"|"setting"), "analyze_v2" (source_id + content, focus as array, optional relative_name/' +
2022
+ 'input_budget_tokens), "status_v2" (source_id), "retry_v2" (source_id + chunk_id?), "synthesize_v2" ' +
2023
+ '(source_ids array), "profile_v2" (profile_id), "promotion_preview_v2" (profile_id + target), "promote_v2" ' +
2024
+ '(preview_id, confirm: true to commit), "review" (source_id), "promote" (source_id, target? default "all"), ' +
2025
+ '"synthesize" (source_id). Model-backed actions are LONG-RUNNING.',
2026
+ parameters: {
2027
+ action: { type: 'string', required: true, enum: ['extract', 'analyze_v2', 'status_v2', 'retry_v2', 'synthesize_v2', 'profile_v2', 'promotion_preview_v2', 'promote_v2', 'review', 'promote', 'synthesize'], description: 'The source operation.' },
2028
+ source_id: { type: 'string', description: 'Source pack id.' },
2029
+ content: { type: 'string', description: 'Source text (extract/analyze_v2).' },
2030
+ focus: { type: 'json', description: '"style"/"setting" string for extract; array of focus names for analyze_v2.' },
2031
+ relative_name: { type: 'string', description: 'Display filename (analyze_v2, default "source.txt").' },
2032
+ input_budget_tokens: { type: 'integer', description: 'Token budget per analysis chunk (default 12000).' },
2033
+ chunk_id: { type: 'string', description: 'Chunk to retry (retry_v2).' },
2034
+ source_ids: { type: 'array', items: { type: 'string' }, description: 'Sources to synthesize (synthesize_v2).' },
2035
+ profile_id: { type: 'string', description: 'Analysis profile id (profile_v2/promotion_preview_v2).' },
2036
+ target: { type: 'string', description: 'Promotion target (promotion_preview_v2/promote, default "all").' },
2037
+ preview_id: { type: 'string', description: 'Promotion preview id (promote_v2).' },
2038
+ confirm: { type: 'boolean', description: 'Confirm a gated promotion (default false).' },
2039
+ },
2040
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2041
+ timeoutMs,
2042
+ isConcurrencySafe: (args) => args.action === 'status_v2' || args.action === 'profile_v2' || args.action === 'promotion_preview_v2',
2043
+ async execute(args, exec) {
2044
+ const client = clientFor(exec);
2045
+ const body = { action: args.action };
2046
+ for (const key of ['source_id', 'content', 'focus', 'relative_name', 'input_budget_tokens', 'chunk_id', 'source_ids', 'profile_id', 'target', 'preview_id', 'confirm']) {
2047
+ const value = args[key];
2048
+ if (value !== undefined)
2049
+ body[key] = value;
2050
+ }
2051
+ return await client.postJson('/api/source', body, exec.signal);
2052
+ },
2053
+ }));
2054
+ ctx.tools.register(defineTool({
2055
+ name: 'novel_reference_library_action',
2056
+ description: 'Reference-library pipeline (decomposing reference works into structured evidence). Actions: "list", ' +
2057
+ '"prepare" (source_id + content, optional title/relative_name/intent/focus array/input_budget_tokens), ' +
2058
+ '"confirm_structure" (source_id, units? array), "analyze" (source_id, LONG-RUNNING), "status" (source_id), ' +
2059
+ '"retry" (source_id + chunk_id?), "synthesize" (source_ids array), "profile" (profile_id), ' +
2060
+ '"adoption_preview" (profile_id + selections array, optional rejected_item_ids), "adopt" (preview_id, ' +
2061
+ 'confirm: true to commit).',
2062
+ parameters: {
2063
+ action: { type: 'string', required: true, enum: ['list', 'prepare', 'confirm_structure', 'analyze', 'status', 'retry', 'synthesize', 'profile', 'adoption_preview', 'adopt'], description: 'The reference-library operation.' },
2064
+ source_id: { type: 'string', description: 'Reference source id.' },
2065
+ content: { type: 'string', description: 'Reference text (prepare).' },
2066
+ title: { type: 'string', description: 'Reference title (prepare).' },
2067
+ relative_name: { type: 'string', description: 'Display filename (prepare, default "source.txt").' },
2068
+ intent: { type: 'string', description: 'Analysis intent (prepare, default "reference").' },
2069
+ focus: { type: 'json', description: 'Array of focus names (prepare).' },
2070
+ input_budget_tokens: { type: 'integer', description: 'Token budget per chunk (prepare, default 12000).' },
2071
+ units: { type: 'json', description: 'Confirmed structure units (confirm_structure).' },
2072
+ chunk_id: { type: 'string', description: 'Chunk to retry (retry).' },
2073
+ source_ids: { type: 'array', items: { type: 'string' }, description: 'References to synthesize (synthesize).' },
2074
+ profile_id: { type: 'string', description: 'Analysis profile id (profile/adoption_preview).' },
2075
+ selections: { type: 'json', description: 'Array of adoption selections (adoption_preview).' },
2076
+ rejected_item_ids: { type: 'array', items: { type: 'string' }, description: 'Items to reject (adoption_preview).' },
2077
+ preview_id: { type: 'string', description: 'Adoption preview id (adopt).' },
2078
+ confirm: { type: 'boolean', description: 'Confirm a gated adoption (default false).' },
2079
+ },
2080
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2081
+ timeoutMs,
2082
+ isConcurrencySafe: (args) => args.action === 'list' || args.action === 'status' || args.action === 'profile' || args.action === 'adoption_preview',
2083
+ async execute(args, exec) {
2084
+ const client = clientFor(exec);
2085
+ const body = { action: args.action };
2086
+ for (const key of ['source_id', 'content', 'title', 'relative_name', 'intent', 'focus', 'input_budget_tokens', 'units', 'chunk_id', 'source_ids', 'profile_id', 'selections', 'rejected_item_ids', 'preview_id', 'confirm']) {
2087
+ const value = args[key];
2088
+ if (value !== undefined)
2089
+ body[key] = value;
2090
+ }
2091
+ return await client.postJson('/api/reference-library', body, exec.signal);
2092
+ },
2093
+ }));
2094
+ ctx.tools.register(defineTool({
2095
+ name: 'novel_runtime_skill_action',
2096
+ description: 'Inspect OpenWrite runtime skills. Actions: "list" (all discovered skills), "diagnose" (skill loading ' +
2097
+ 'problems), "resolve" (which skills apply for an agent and task — optional agent one of dante/goethe/writer/' +
2098
+ 'reviewer/studio, skills, task, intent, document_type). Read-only.',
2099
+ parameters: {
2100
+ action: { type: 'string', required: true, enum: ['list', 'diagnose', 'resolve'], description: 'The runtime-skill operation.' },
2101
+ agent: { type: 'string', description: 'Agent baseline for resolve: "dante", "goethe", "writer", "reviewer", or "studio" (default).' },
2102
+ skills: { type: 'array', items: { type: 'string' }, description: 'Explicit skill ids to resolve.' },
2103
+ task: { type: 'string', description: 'Task description for resolve.' },
2104
+ intent: { type: 'string', description: 'Intent hint for resolve.' },
2105
+ document_type: { type: 'string', description: 'Document type hint for resolve.' },
2106
+ },
2107
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2108
+ timeoutMs,
2109
+ isConcurrencySafe: () => true,
2110
+ async execute(args, exec) {
2111
+ const client = clientFor(exec);
2112
+ const body = { action: args.action };
2113
+ for (const key of ['agent', 'skills', 'task', 'intent', 'document_type']) {
2114
+ const value = args[key];
2115
+ if (value !== undefined)
2116
+ body[key] = value;
2117
+ }
2118
+ return await client.postJson('/api/runtime-skills', body, exec.signal);
2119
+ },
2120
+ }));
2121
+ ctx.tools.register(defineTool({
2122
+ name: 'novel_rule_action',
2123
+ description: 'Compiled writing rules. Actions: "status" (active rule set), "preview" (compile a candidate preview), ' +
2124
+ '"apply" (preview_id — confirmation-gated: the server answers CONFIRMATION_REQUIRED until called again ' +
2125
+ 'with confirm: true).',
2126
+ parameters: {
2127
+ action: { type: 'string', required: true, enum: ['status', 'preview', 'apply'], description: 'The rule operation.' },
2128
+ preview_id: { type: 'string', description: 'Preview id to apply (apply).' },
2129
+ confirm: { type: 'boolean', description: 'Confirm applying the preview (apply).' },
2130
+ },
2131
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2132
+ timeoutMs,
2133
+ isConcurrencySafe: (args) => args.action === 'status',
2134
+ async execute(args, exec) {
2135
+ const client = clientFor(exec);
2136
+ const body = { action: args.action };
2137
+ if (args.preview_id !== undefined)
2138
+ body['preview_id'] = args.preview_id;
2139
+ if (args.confirm !== undefined)
2140
+ body['confirm'] = args.confirm;
2141
+ return await client.postJson('/api/rules', body, exec.signal);
2142
+ },
2143
+ }));
2144
+ // ── deep research ────────────────────────────────────────────────────────
2145
+ ctx.tools.register(defineTool({
2146
+ name: 'novel_research_status',
2147
+ 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".',
2148
+ parameters: {},
2149
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2150
+ timeoutMs,
2151
+ isConcurrencySafe: () => true,
2152
+ async execute(_args, exec) {
2153
+ const client = clientFor(exec);
2154
+ return await client.getJson('/api/research', {}, exec.signal);
2155
+ },
2156
+ }));
2157
+ ctx.tools.register(defineTool({
2158
+ name: 'novel_research_report',
2159
+ description: 'Read one deep-research report by id (ids come from novel_research_status).',
2160
+ parameters: {
2161
+ report_id: { type: 'string', required: true, description: 'Report id.' },
2162
+ },
2163
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2164
+ timeoutMs,
2165
+ isConcurrencySafe: () => true,
2166
+ async execute(args, exec) {
2167
+ const client = clientFor(exec);
2168
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,100}$/.test(args.report_id))
2169
+ throw new Error(`invalid report_id "${args.report_id}"`);
2170
+ return await client.getJson(`/api/research/reports/${encodeURIComponent(args.report_id)}`, {}, exec.signal);
2171
+ },
2172
+ }));
2173
+ ctx.tools.register(defineTool({
2174
+ name: 'novel_research_settings_save',
2175
+ description: 'Save deep-research API settings (search/LLM providers, API keys, defaults). Pass the whole settings object; returns the refreshed research surface.',
2176
+ parameters: {
2177
+ settings: { type: 'json', required: true, description: 'Settings object, e.g. {"search": "bocha", "llm": "deepseek", ...}.' },
2178
+ },
2179
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2180
+ timeoutMs,
2181
+ async execute(args, exec) {
2182
+ const client = clientFor(exec);
2183
+ if (typeof args.settings !== 'object' || args.settings === null || Array.isArray(args.settings)) {
2184
+ throw new Error('settings must be a JSON object');
2185
+ }
2186
+ return await client.postJson('/api/research/settings', args.settings, exec.signal);
2187
+ },
2188
+ }));
2189
+ // ── model configuration ──────────────────────────────────────────────────
2190
+ ctx.tools.register(defineTool({
2191
+ name: 'novel_model_profiles',
2192
+ description: 'List the server\'s model profiles and per-task routing (which provider/model OpenWrite uses for writing, review, etc.).',
2193
+ parameters: {},
2194
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2195
+ timeoutMs,
2196
+ isConcurrencySafe: () => true,
2197
+ async execute(_args, exec) {
2198
+ const client = clientFor(exec);
2199
+ return await client.getJson('/api/model/profiles', {}, exec.signal);
2200
+ },
2201
+ }));
2202
+ ctx.tools.register(defineTool({
2203
+ name: 'novel_model_benchmark',
2204
+ description: 'Run or inspect isolated chapter or outline model benchmarks through OpenWrite profiles and LiteLLM. ' +
2205
+ 'Use options first to read chapter choices, task limits, supported execution modes and the authoritative ' +
2206
+ 'pipeline DAG/review domains without invoking a model. The run action creates a background task and never ' +
2207
+ 'changes model routes, canonical manuscript or canonical outline files.',
2208
+ parameters: {
2209
+ action: { type: 'string', required: true, enum: ['list', 'get', 'options', 'run'], description: 'Benchmark operation; options previews task choices and pipeline DAGs without starting a run.' },
2210
+ run_id: { type: 'string', description: 'Benchmark run id for get.' },
2211
+ task_type: { type: 'string', enum: ['chapter', 'outline'], description: 'Run target: chapter (default) or outline. Outline supports framework mode only.' },
2212
+ chapter_id: { type: 'string', description: 'Chapter task target: next (default) or an available canonical chapter_id from options, e.g. ch_003. Unavailable targets are rejected without fallback; inspect availability/reason first.' },
2213
+ outline_start_chapter: { type: 'integer', description: 'Outline task first chapter number, inclusive, 1-100000. Omit to continue after the last outline chapter.' },
2214
+ outline_chapter_count: { type: 'integer', description: 'Outline task range length including its starting chapter, 1-20 (default 3).' },
2215
+ writer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Writing profiles to compare (run).' },
2216
+ reviewer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Independent blind-review profiles (run).' },
2217
+ execution_mode: { type: 'string', enum: ['framework', 'creative'], description: 'Execution path: full framework (default); creative diagnostic is available only for chapter tasks. Read task pipelines with options.' },
2218
+ repeats: { type: 'integer', description: 'Repeats per writing profile, 1-5.' },
2219
+ target_words: { type: 'integer', description: 'Chapter candidate target length, 200-12000; use outline_chapter_count for outline range.' },
2220
+ concurrency: { type: 'integer', description: 'Concurrent model jobs, 1-4.' },
2221
+ limit: { type: 'integer', description: 'List limit, default 20.' },
2222
+ },
2223
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2224
+ timeoutMs,
2225
+ isConcurrencySafe: args => args.action !== 'run',
2226
+ async execute(args, exec) {
2227
+ const client = clientFor(exec);
2228
+ if (args.action === 'list') {
2229
+ return await client.getJson('/api/benchmarks', args.limit === undefined ? {} : { limit: String(args.limit) }, exec.signal);
2230
+ }
2231
+ if (args.action === 'options') {
2232
+ return await client.getJson('/api/benchmarks/options', {}, exec.signal);
2233
+ }
2234
+ if (args.action === 'get') {
2235
+ if (!args.run_id?.trim())
2236
+ throw new Error('run_id is required for get');
2237
+ return await client.getJson(`/api/benchmarks/${encodeURIComponent(args.run_id.trim())}`, {}, exec.signal);
2238
+ }
2239
+ if (!Array.isArray(args.writer_profile_ids) || args.writer_profile_ids.length === 0) {
2240
+ throw new Error('writer_profile_ids is required for run');
2241
+ }
2242
+ if (!Array.isArray(args.reviewer_profile_ids) || args.reviewer_profile_ids.length === 0) {
2243
+ throw new Error('reviewer_profile_ids is required for run');
2244
+ }
2245
+ const body = {
2246
+ writer_profile_ids: args.writer_profile_ids,
2247
+ reviewer_profile_ids: args.reviewer_profile_ids,
2248
+ };
2249
+ for (const key of ['task_type', 'chapter_id', 'outline_start_chapter', 'outline_chapter_count', 'execution_mode', 'repeats', 'target_words', 'concurrency']) {
2250
+ const value = args[key];
2251
+ if (value !== undefined)
2252
+ body[key] = value;
2253
+ }
2254
+ return await client.postJson('/api/benchmarks', body, exec.signal);
2255
+ },
2256
+ }));
2257
+ ctx.tools.register(defineTool({
2258
+ name: 'novel_model_configure',
2259
+ description: 'Apply and persist the server\'s active model connection (OpenWrite\'s own LLM for writing/review — ' +
2260
+ 'independent of the dsh model). max_tokens must stay below context_tokens.',
2261
+ parameters: {
2262
+ provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2263
+ model: { type: 'string', required: true, description: 'Model name (max 120 characters).' },
2264
+ api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2265
+ base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2266
+ api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2267
+ context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
2268
+ max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
2269
+ remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2270
+ },
2271
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2272
+ timeoutMs,
2273
+ async execute(args, exec) {
2274
+ const client = clientFor(exec);
2275
+ if (!args.model.trim() || args.model.length > 120)
2276
+ throw new Error('model must be non-empty and at most 120 characters');
2277
+ const body = { model: args.model };
2278
+ for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
2279
+ const value = args[key];
2280
+ if (value !== undefined)
2281
+ body[key] = value;
2282
+ }
2283
+ return await client.postJson('/api/model', body, exec.signal);
2284
+ },
2285
+ }));
2286
+ ctx.tools.register(defineTool({
2287
+ name: 'novel_model_test',
2288
+ description: 'Test a model connection without changing active settings. Pass id to test a stored profile, or a full ' +
2289
+ 'candidate (provider/model/base_url/api_key...).',
2290
+ parameters: {
2291
+ id: { type: 'string', description: 'Stored profile id to test.' },
2292
+ provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
2293
+ model: { type: 'string', description: 'Model name (required when id is omitted).' },
2294
+ base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
2295
+ api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
2296
+ api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
2297
+ context_tokens: { type: 'integer', description: 'Context budget.' },
2298
+ max_tokens: { type: 'integer', description: 'Max output tokens.' },
2299
+ },
2300
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2301
+ timeoutMs,
2302
+ isConcurrencySafe: () => true,
2303
+ async execute(args, exec) {
2304
+ const client = clientFor(exec);
2305
+ if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
2306
+ throw new Error('either id (stored profile) or model (candidate) is required');
2307
+ }
2308
+ const body = {};
2309
+ for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
2310
+ const value = args[key];
2311
+ if (value !== undefined)
2312
+ body[key] = value;
2313
+ }
2314
+ return await client.postJson('/api/model/test', body, exec.signal);
2315
+ },
2316
+ }));
2317
+ ctx.tools.register(defineTool({
2318
+ name: 'novel_model_embedding_test',
2319
+ description: 'Probe an independent Embedding profile without changing Chat profiles.',
2320
+ parameters: {
2321
+ id: { type: 'string', description: 'Stored profile id to probe.' },
2322
+ candidate: { type: 'json', description: 'Embedding candidate settings object (merged into the request).' },
2323
+ api_key: { type: 'string', description: 'Embedding API key override.' },
2324
+ },
2325
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2326
+ timeoutMs,
2327
+ isConcurrencySafe: () => true,
2328
+ async execute(args, exec) {
2329
+ const client = clientFor(exec);
2330
+ const body = {};
2331
+ if (args.candidate !== undefined) {
2332
+ if (typeof args.candidate !== 'object' || args.candidate === null || Array.isArray(args.candidate)) {
2333
+ throw new Error('candidate must be a JSON object');
2334
+ }
2335
+ Object.assign(body, args.candidate);
2336
+ }
2337
+ for (const key of ['id', 'api_key']) {
2338
+ const value = args[key];
2339
+ if (value !== undefined)
2340
+ body[key] = value;
2341
+ }
2342
+ return await client.postJson('/api/model/embedding/test', body, exec.signal);
2343
+ },
2344
+ }));
2345
+ ctx.tools.register(defineTool({
2346
+ name: 'novel_model_profile_save',
2347
+ description: 'Save (create or update) a named model profile on the server, with an optional API key.',
2348
+ parameters: {
2349
+ profile: { type: 'json', required: true, description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
2350
+ api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
2351
+ remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
2352
+ },
2353
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2354
+ timeoutMs,
2355
+ async execute(args, exec) {
2356
+ const client = clientFor(exec);
2357
+ if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
2358
+ throw new Error('profile must be a JSON object');
2359
+ }
2360
+ const body = { ...args.profile };
2361
+ for (const key of ['api_key', 'remember_api_key']) {
2362
+ const value = args[key];
2363
+ if (value !== undefined)
2364
+ body[key] = value;
2365
+ }
2366
+ return await client.postJson('/api/model/profiles', body, exec.signal);
2367
+ },
2368
+ }));
2369
+ ctx.tools.register(defineTool({
2370
+ name: 'novel_model_profile_delete',
2371
+ description: 'Delete a stored model profile. Routes pointing at it fall back to fallback_id.',
2372
+ parameters: {
2373
+ profile_id: { type: 'string', required: true, description: 'Profile id to delete.' },
2374
+ fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
2375
+ },
2376
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2377
+ timeoutMs,
2378
+ async execute(args, exec) {
2379
+ const client = clientFor(exec);
2380
+ if (!args.profile_id.trim())
2381
+ throw new Error('profile_id must be a non-empty string');
2382
+ const body = { profile_id: args.profile_id };
2383
+ if (args.fallback_id !== undefined)
2384
+ body['fallback_id'] = args.fallback_id;
2385
+ return await client.postJson('/api/model/profiles/delete', body, exec.signal);
2386
+ },
2387
+ }));
2388
+ ctx.tools.register(defineTool({
2389
+ name: 'novel_model_routes_save',
2390
+ description: 'Save per-task model routing: map operation names (e.g. "chapter_write", "review", "research") to profile ids.',
2391
+ parameters: {
2392
+ routes: { type: 'json', required: true, description: 'Object mapping operation names to profile ids.' },
2393
+ },
2394
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2395
+ timeoutMs,
2396
+ async execute(args, exec) {
2397
+ const client = clientFor(exec);
2398
+ if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
2399
+ throw new Error('routes must be a JSON object');
2400
+ }
2401
+ return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
2402
+ },
2403
+ }));
2404
+ }