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