dsh-openwrite 0.2.28 → 0.2.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +49 -8
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/companions.mjs +508 -0
- package/cordis.patch.yml +0 -2
- package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
- package/docs/COMPANION_PLUGINS.md +142 -0
- package/docs/CONTEXT_SLOTS.md +1 -1
- 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 +2 -2
- package/docs/INSTALL.md +112 -4
- package/docs/ISSUES_121_123.md +4 -4
- package/docs/ISSUES_140_142.md +58 -0
- package/docs/ISSUES_149_150_151.md +77 -0
- package/docs/ISSUE_123_FOLLOWUP.md +3 -2
- 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 +20 -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 +2 -2
- package/docs/TOOLS.md +181 -604
- package/docs/WORKFLOWS.md +5 -5
- package/package.json +17 -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 +3 -1
- 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 +176 -34
- package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
- package/packages/openwrite-bridge/lib/tools.js +1223 -1227
- package/packages/openwrite-bridge/package.json +7 -5
- package/packages/studio-panel/lib/client.js +123207 -121534
- 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 +1 -1
- 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/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.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +4 -13
- 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 +26 -4
- package/scripts/safe-upgrade.mjs +38 -5
- package/dog-tools.mjs +0 -13
- 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--byUPJL8.js +0 -2191
- 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 -1230
- 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,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
573
919
|
return await client.postJson('/api/outline/edit', body, exec.signal);
|
|
574
920
|
},
|
|
575
921
|
}));
|
|
576
|
-
ctx.tools.register(defineTool({
|
|
577
|
-
name: 'novel_reading_order_move',
|
|
578
|
-
description: 'Move one manuscript document within or across volumes. Requires the fresh reading-order revision and rejects ambiguous, missing, or concurrently changed order.',
|
|
579
|
-
parameters: {
|
|
580
|
-
document_id: { type: 'string', required: true, description: 'Stable document_id or unambiguous occurrence_id.' },
|
|
581
|
-
target_volume_id: { type: 'string', required: true, description: 'Target volume_id from novel_reading_order.' },
|
|
582
|
-
target_index: { type: 'integer', required: true, description: 'Zero-based position inside the target volume.' },
|
|
583
|
-
expected_revision: { type: 'string', required: true, description: 'Fresh reading-order revision used for CAS.' },
|
|
584
|
-
},
|
|
585
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
586
|
-
timeoutMs,
|
|
587
|
-
async execute(args, exec) {
|
|
588
|
-
if (!args.document_id.trim())
|
|
589
|
-
throw new Error('document_id must be non-empty');
|
|
590
|
-
if (!args.target_volume_id.trim())
|
|
591
|
-
throw new Error('target_volume_id must be non-empty');
|
|
592
|
-
if (!args.expected_revision.trim())
|
|
593
|
-
throw new Error('expected_revision must be non-empty');
|
|
594
|
-
return await clientFor(exec).postJson('/api/reading-order/move', {
|
|
595
|
-
document_id: args.document_id,
|
|
596
|
-
target_volume_id: args.target_volume_id,
|
|
597
|
-
target_index: args.target_index,
|
|
598
|
-
expected_revision: args.expected_revision,
|
|
599
|
-
}, exec.signal);
|
|
600
|
-
},
|
|
601
|
-
}));
|
|
602
|
-
ctx.tools.register(defineTool({
|
|
603
|
-
name: 'novel_scene_migration_apply',
|
|
604
|
-
description: 'Apply one freshly read scene migration preview. Requires the exact preview revision and explicit confirmation.',
|
|
605
|
-
parameters: {
|
|
606
|
-
expected_preview_revision: { type: 'string', required: true, description: 'Exact preview_revision from novel_scene_migration_preview.' },
|
|
607
|
-
confirm: { type: 'boolean', required: true, description: 'Must be true after the author approves the preview.' },
|
|
608
|
-
},
|
|
609
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
610
|
-
timeoutMs,
|
|
611
|
-
async execute(args, exec) {
|
|
612
|
-
if (!args.expected_preview_revision.trim())
|
|
613
|
-
throw new Error('expected_preview_revision must be non-empty');
|
|
614
|
-
if (args.confirm !== true)
|
|
615
|
-
throw new Error('confirm must be true');
|
|
616
|
-
return await clientFor(exec).postJson('/api/scenes/migration/apply', {
|
|
617
|
-
expected_preview_revision: args.expected_preview_revision,
|
|
618
|
-
confirm: true,
|
|
619
|
-
}, exec.signal);
|
|
620
|
-
},
|
|
621
|
-
}));
|
|
622
|
-
ctx.tools.register(defineTool({
|
|
623
|
-
name: 'novel_scene_migration_rollback',
|
|
624
|
-
description: 'Restore the byte-exact scene sidecar that preceded a migration, guarded by the current scene-structure revision.',
|
|
625
|
-
parameters: {
|
|
626
|
-
migration_id: { type: 'string', required: true, description: 'Migration id returned by novel_scene_migration_apply.' },
|
|
627
|
-
expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
|
|
628
|
-
},
|
|
629
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
630
|
-
timeoutMs,
|
|
631
|
-
async execute(args, exec) {
|
|
632
|
-
if (!args.migration_id.trim() || !args.expected_revision.trim())
|
|
633
|
-
throw new Error('migration_id and expected_revision must be non-empty');
|
|
634
|
-
return await clientFor(exec).postJson('/api/scenes/migration/rollback', {
|
|
635
|
-
migration_id: args.migration_id,
|
|
636
|
-
expected_revision: args.expected_revision,
|
|
637
|
-
}, exec.signal);
|
|
638
|
-
},
|
|
639
|
-
}));
|
|
640
|
-
ctx.tools.register(defineTool({
|
|
641
|
-
name: 'novel_scene_update',
|
|
642
|
-
description: 'CAS-update scene title, story time, and character/location/event references without changing manuscript prose.',
|
|
643
|
-
parameters: {
|
|
644
|
-
scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
|
|
645
|
-
expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision used for CAS.' },
|
|
646
|
-
title: { type: 'string', description: 'Optional scene title.' },
|
|
647
|
-
story_time_sort_key: { type: 'string', description: 'Optional lexical story-time sort key.' },
|
|
648
|
-
story_time_label: { type: 'string', description: 'Optional author-facing story-time label.' },
|
|
649
|
-
characters: { type: 'array', items: { type: 'string' }, description: 'Character references; replaces the current list when supplied.' },
|
|
650
|
-
locations: { type: 'array', items: { type: 'string' }, description: 'Location references; replaces the current list when supplied.' },
|
|
651
|
-
events: { type: 'array', items: { type: 'string' }, description: 'Event references; replaces the current list when supplied.' },
|
|
652
|
-
},
|
|
653
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
654
|
-
timeoutMs,
|
|
655
|
-
async execute(args, exec) {
|
|
656
|
-
if (!args.scene_id.trim() || !args.expected_revision.trim())
|
|
657
|
-
throw new Error('scene_id and expected_revision must be non-empty');
|
|
658
|
-
const body = { scene_id: args.scene_id, expected_revision: args.expected_revision };
|
|
659
|
-
if (args.title !== undefined)
|
|
660
|
-
body['title'] = args.title;
|
|
661
|
-
if (args.story_time_sort_key !== undefined)
|
|
662
|
-
body['story_time_sort_key'] = args.story_time_sort_key;
|
|
663
|
-
if (args.story_time_label !== undefined)
|
|
664
|
-
body['story_time_label'] = args.story_time_label;
|
|
665
|
-
if (args.characters !== undefined)
|
|
666
|
-
body['characters'] = args.characters;
|
|
667
|
-
if (args.locations !== undefined)
|
|
668
|
-
body['locations'] = args.locations;
|
|
669
|
-
if (args.events !== undefined)
|
|
670
|
-
body['events'] = args.events;
|
|
671
|
-
return await clientFor(exec).postJson('/api/scenes/metadata', body, exec.signal);
|
|
672
|
-
},
|
|
673
|
-
}));
|
|
674
|
-
ctx.tools.register(defineTool({
|
|
675
|
-
name: 'novel_scene_move',
|
|
676
|
-
description: 'Move one complete scene within or across chapters with scene/source/target revision guards. Cross-chapter moves start the existing manuscript acceptance flow.',
|
|
677
|
-
parameters: {
|
|
678
|
-
scene_id: { type: 'string', required: true, description: 'Stable scene id from novel_scene_structure.' },
|
|
679
|
-
target_chapter_id: { type: 'string', required: true, description: 'Target chapter id (ch_<digits>).' },
|
|
680
|
-
target_index: { type: 'integer', required: true, description: 'Zero-based scene position inside the target chapter.' },
|
|
681
|
-
expected_revision: { type: 'string', required: true, description: 'Fresh scene-structure revision.' },
|
|
682
|
-
expected_source_revision: { type: 'string', required: true, description: 'Fresh source chapter revision.' },
|
|
683
|
-
expected_target_revision: { type: 'string', required: true, description: 'Fresh target chapter revision.' },
|
|
684
|
-
},
|
|
685
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
686
|
-
timeoutMs,
|
|
687
|
-
async execute(args, exec) {
|
|
688
|
-
if (!args.scene_id.trim() || !CHAPTER_ID_PATTERN.test(args.target_chapter_id))
|
|
689
|
-
throw new Error('scene_id and a valid target_chapter_id are required');
|
|
690
|
-
if (!args.expected_revision.trim() || !args.expected_source_revision.trim() || !args.expected_target_revision.trim())
|
|
691
|
-
throw new Error('all scene and chapter revisions are required');
|
|
692
|
-
return await clientFor(exec).postJson('/api/scenes/move', {
|
|
693
|
-
scene_id: args.scene_id,
|
|
694
|
-
target_chapter_id: args.target_chapter_id,
|
|
695
|
-
target_index: args.target_index,
|
|
696
|
-
expected_revision: args.expected_revision,
|
|
697
|
-
expected_source_revision: args.expected_source_revision,
|
|
698
|
-
expected_target_revision: args.expected_target_revision,
|
|
699
|
-
}, exec.signal);
|
|
700
|
-
},
|
|
701
|
-
}));
|
|
702
922
|
ctx.tools.register(defineTool({
|
|
703
923
|
name: 'novel_write_chapter',
|
|
704
|
-
description: 'Queue a chapter write and immediately return a durable task_id. Poll
|
|
924
|
+
description: 'Queue a chapter write and immediately return a durable task_id. Poll novel_task(action="get") for progress/result. Omit chapter_id to write ' +
|
|
705
925
|
'the outline-recommended next chapter; a given chapter_id must match the current recommendation. ' +
|
|
706
926
|
'Preview context first with novel_context_preview and consult novel_outline_read for the recommendation. ' +
|
|
707
|
-
'Cancel with
|
|
927
|
+
'Cancel with novel_task(action="cancel"). Generated partial prose is retained; resume_from_draft continues it in a new run when the source/context revisions still match.',
|
|
708
928
|
parameters: {
|
|
709
929
|
dry_run: { type: 'boolean', description: 'Only assemble the current writer input and per-document sizes; no model call and no writing task. Guidance and target_words are included in this preview.' },
|
|
710
930
|
run_id_v2: { type: 'string', description: 'Resume this completed draft without generating it again.' },
|
|
@@ -761,8 +981,20 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
761
981
|
timeoutMs,
|
|
762
982
|
async execute(args, exec) {
|
|
763
983
|
const client = clientFor(exec);
|
|
764
|
-
if (args.action === 'versions' || args.action === 'compare')
|
|
765
|
-
|
|
984
|
+
if (args.action === 'versions' || args.action === 'compare') {
|
|
985
|
+
// "versions" lists saved reviews and needs no revision; only
|
|
986
|
+
// "compare" needs one. An empty revision for compare is not "unset":
|
|
987
|
+
// the store answers REVIEW_HISTORY_NOT_FOUND, so report the real cause
|
|
988
|
+
// before sending the request.
|
|
989
|
+
if (args.action === 'compare' && !args.revision?.trim()) {
|
|
990
|
+
throw new Error('revision is required for novel_review_chapter(action="compare")');
|
|
991
|
+
}
|
|
992
|
+
return await client.postJson('/api/review/history', {
|
|
993
|
+
action: args.action,
|
|
994
|
+
chapter_id: reviewChapterId(args),
|
|
995
|
+
revision: args.revision ?? '',
|
|
996
|
+
}, exec.signal);
|
|
997
|
+
}
|
|
766
998
|
if (args.dimensions !== undefined && args.dimensions.some(value => !Number.isInteger(value) || value < 1 || value > 37)) {
|
|
767
999
|
throw new Error('dimensions must contain only integers from 1 to 37');
|
|
768
1000
|
}
|
|
@@ -800,42 +1032,10 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
800
1032
|
return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
|
|
801
1033
|
},
|
|
802
1034
|
}));
|
|
803
|
-
ctx.tools.register(defineTool({
|
|
804
|
-
name: 'novel_asset_update',
|
|
805
|
-
description: 'Immediate compatibility write for an asset change the author has already authorized exactly; otherwise use ' +
|
|
806
|
-
'novel_structured_change_plan. Update a structured asset (character / world / progression). Revision-gated: pass the `revision` ' +
|
|
807
|
-
'fingerprint from novel_assets_list; the server rejects the update with a conflict if the asset changed. ' +
|
|
808
|
-
'Pass field changes via `data` (merged into the asset front matter), `body_markdown` to replace the ' +
|
|
809
|
-
'markdown body, or `raw_text` to replace the whole document.',
|
|
810
|
-
parameters: {
|
|
811
|
-
kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
|
|
812
|
-
id: { type: 'string', required: true, description: 'Asset id.' },
|
|
813
|
-
revision: { type: 'string', required: true, description: 'The asset revision fingerprint from a fresh novel_assets_list call.' },
|
|
814
|
-
data: { type: 'json', description: 'Object of asset fields to merge (e.g. name, summary, aliases).' },
|
|
815
|
-
body_markdown: { type: 'string', description: 'Replacement markdown body (character/world assets).' },
|
|
816
|
-
raw_text: { type: 'string', description: 'Full replacement document text, validated by the server.' },
|
|
817
|
-
related_mode: { type: 'string', enum: ['merge', 'replace'], description: 'Preserve and merge existing related entries by default; replace explicitly clears or replaces relations.' },
|
|
818
|
-
},
|
|
819
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
820
|
-
timeoutMs,
|
|
821
|
-
async execute(args, exec) {
|
|
822
|
-
const client = clientFor(exec);
|
|
823
|
-
const body = { kind: args.kind, id: args.id, revision: args.revision };
|
|
824
|
-
if (args.data !== undefined)
|
|
825
|
-
body['data'] = args.data;
|
|
826
|
-
if (args.body_markdown !== undefined)
|
|
827
|
-
body['body_markdown'] = args.body_markdown;
|
|
828
|
-
if (args.raw_text !== undefined)
|
|
829
|
-
body['raw_text'] = args.raw_text;
|
|
830
|
-
if (args.related_mode !== undefined)
|
|
831
|
-
body['related_mode'] = args.related_mode;
|
|
832
|
-
return await client.postJson('/api/assets/update', body, exec.signal);
|
|
833
|
-
},
|
|
834
|
-
}));
|
|
835
1035
|
ctx.tools.register(defineTool({
|
|
836
1036
|
name: 'novel_foreshadowing',
|
|
837
1037
|
description: 'Immediate compatibility write for a foreshadowing change the author has already authorized exactly; otherwise use ' +
|
|
838
|
-
'
|
|
1038
|
+
'novel_change_plan(action="structured"). Manage the foreshadowing DAG. action "create": node_id + content, optional weight (1-10, default 5), ' +
|
|
839
1039
|
'layer (default "支线"), target_chapter. action "update": node_id + status (e.g. planted/paid-off).',
|
|
840
1040
|
parameters: {
|
|
841
1041
|
action: { type: 'string', required: true, enum: ['create', 'update'], description: 'Create a new foreshadowing node or update an existing one.' },
|
|
@@ -866,113 +1066,81 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
866
1066
|
return await client.postJson('/api/foreshadowing', body, exec.signal);
|
|
867
1067
|
},
|
|
868
1068
|
}));
|
|
1069
|
+
// ── change plans ──────────────────────────────────────────────────────────────
|
|
869
1070
|
ctx.tools.register(defineTool({
|
|
870
|
-
name: '
|
|
871
|
-
description: '
|
|
872
|
-
'
|
|
873
|
-
'
|
|
874
|
-
parameters: {
|
|
875
|
-
path: { type: 'string', required: true, description: 'Document path relative to the novel data root.' },
|
|
876
|
-
content: { type: 'string', required: true, description: 'Full new document content (max 2 MB).' },
|
|
877
|
-
version: { type: 'string', description: 'The version fingerprint from novel_doc_read (optimistic lock).' },
|
|
878
|
-
force: { type: 'boolean', description: 'Bypass the version check (default false).' },
|
|
879
|
-
},
|
|
880
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
881
|
-
timeoutMs,
|
|
882
|
-
async execute(args, exec) {
|
|
883
|
-
const client = clientFor(exec);
|
|
884
|
-
if (!args.path.trim())
|
|
885
|
-
throw new Error('path must be a non-empty string');
|
|
886
|
-
const body = { path: args.path, content: args.content };
|
|
887
|
-
if (args.version !== undefined)
|
|
888
|
-
body['version'] = args.version;
|
|
889
|
-
if (args.force !== undefined)
|
|
890
|
-
body['force'] = args.force;
|
|
891
|
-
return await client.putJson('/api/document', body, exec.signal);
|
|
892
|
-
},
|
|
893
|
-
}));
|
|
894
|
-
ctx.tools.register(defineTool({
|
|
895
|
-
name: 'novel_document_change_plan',
|
|
896
|
-
description: 'Stage and review revision-gated changes to an existing novel document. Use action "preview" with path and edits ' +
|
|
897
|
-
'to receive an immutable preview_token and real before/after summary. Use "apply" only after author acceptance, ' +
|
|
898
|
-
'passing that token and confirm: true; "reject" discards it, "retry" rebuilds it against the current source, and "undo" safely ' +
|
|
899
|
-
'applies an undo_preview_token returned by a prior apply. Each edit uses old_text/new_text or unique start_text/' +
|
|
900
|
-
'end_text/new_text anchors. Preview returns applied:false; changed/would_change describe only the proposed difference. ' +
|
|
901
|
-
'OLD_TEXT_NOT_FOUND is an anchor mismatch (400), while revision conflicts are 409; use the exact suggested bytes and reread the source before retrying. ' +
|
|
902
|
-
'The server rechecks the source revision and writes exactly the stored preview.',
|
|
1071
|
+
name: 'novel_change_plan',
|
|
1072
|
+
description: 'Preview, apply, reject, retry and undo a change plan. document plans edit one project document ' +
|
|
1073
|
+
'as text; structured plans edit the manuscript model (outline, scenes, canon) as operations. ' +
|
|
1074
|
+
'Both are two-step: preview returns a token the follow-up step must present.',
|
|
903
1075
|
parameters: {
|
|
904
|
-
action: { type: 'string', required: true, enum: ['
|
|
1076
|
+
action: { type: 'string', required: true, enum: ['document', 'structured'], description: 'Plan kind: document edits a project document, structured edits the manuscript model.' },
|
|
1077
|
+
step: { type: 'string', enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle step.' },
|
|
905
1078
|
confirm: { type: 'boolean', description: 'Required true for apply and undo after author acceptance.' },
|
|
906
1079
|
path: { type: 'string', description: 'Existing document path for preview, relative to the novel root.' },
|
|
907
1080
|
edits: { type: 'json', description: 'Preview edits: [{old_text,new_text}] or [{start_text,end_text,new_text,end_inclusive}]. Range end defaults to inclusive for compatibility; end_inclusive:false preserves the end anchor (e.g. the next heading).' },
|
|
908
1081
|
preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
|
|
909
|
-
},
|
|
910
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
911
|
-
timeoutMs,
|
|
912
|
-
async execute(args, exec) {
|
|
913
|
-
if (args.action === 'preview' && (!Array.isArray(args.edits) || (args.edits.length < 1 || args.edits.length > 50))) {
|
|
914
|
-
throw new Error('preview requires between 1 and 50 edits ' +
|
|
915
|
-
`(received ${describeValue(args.edits)})`);
|
|
916
|
-
}
|
|
917
|
-
if (args.action !== 'preview' && !args.preview_token?.trim()) {
|
|
918
|
-
throw new Error(`${args.action} requires preview_token`);
|
|
919
|
-
}
|
|
920
|
-
if (['apply', 'undo'].includes(args.action) && args.confirm !== true)
|
|
921
|
-
throw new Error('apply/undo requires confirm: true');
|
|
922
|
-
const client = clientFor(exec);
|
|
923
|
-
const body = { action: args.action };
|
|
924
|
-
if (args.confirm !== undefined)
|
|
925
|
-
body['confirm'] = args.confirm;
|
|
926
|
-
if (args.path !== undefined)
|
|
927
|
-
body['path'] = args.path;
|
|
928
|
-
if (args.edits !== undefined)
|
|
929
|
-
body['edits'] = args.edits;
|
|
930
|
-
if (args.preview_token !== undefined)
|
|
931
|
-
body['preview_token'] = args.preview_token;
|
|
932
|
-
return await client.postJson('/api/document/change-plan', body, exec.signal);
|
|
933
|
-
},
|
|
934
|
-
}));
|
|
935
|
-
ctx.tools.register(defineTool({
|
|
936
|
-
name: 'novel_structured_change_plan',
|
|
937
|
-
description: 'Stage one structured OpenWrite domain change without writing it. Supported change_kind values are "outline", ' +
|
|
938
|
-
'"asset", "focus", "foreshadowing", and "writing_targets"; `change` uses the corresponding direct tool payload. ' +
|
|
939
|
-
'Use action "preview" to receive an immutable token and entity-level before/after summary. Only after author ' +
|
|
940
|
-
'acceptance pass that token unchanged to "apply". "reject" consumes it, "retry" rebuilds the same semantic ' +
|
|
941
|
-
'change against current source, and "undo" accepts only the revision-bound undo token returned by apply. The ' +
|
|
942
|
-
'server writes the exact validated preview and rejects source conflicts.',
|
|
943
|
-
parameters: {
|
|
944
|
-
action: { type: 'string', required: true, enum: ['preview', 'apply', 'reject', 'retry', 'undo'], description: 'Plan lifecycle action.' },
|
|
945
1082
|
change_kind: { type: 'string', enum: ['outline', 'asset', 'focus', 'foreshadowing', 'writing_targets'], description: 'Structured domain for a preview.' },
|
|
946
1083
|
change: { type: 'json', description: 'One direct-tool-shaped change object, required for preview.' },
|
|
947
|
-
preview_token: { type: 'string', description: 'Immutable preview or undo token for apply/reject/retry/undo.' },
|
|
948
1084
|
},
|
|
949
1085
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
950
1086
|
timeoutMs,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1087
|
+
isConcurrencySafe: () => false,
|
|
1088
|
+
async execute(args, exec) {
|
|
1089
|
+
switch (args.action) {
|
|
1090
|
+
case 'document': {
|
|
1091
|
+
if (args.step === undefined)
|
|
1092
|
+
throw new Error('step is required for novel_change_plan(action="document")');
|
|
1093
|
+
if (args.step === 'preview' && (!Array.isArray(args.edits) || (args.edits.length < 1 || args.edits.length > 50))) {
|
|
1094
|
+
throw new Error('preview requires between 1 and 50 edits ' +
|
|
1095
|
+
`(received ${describeValue(args.edits)})`);
|
|
1096
|
+
}
|
|
1097
|
+
if (args.step !== 'preview' && !args.preview_token?.trim()) {
|
|
1098
|
+
throw new Error(`${args.step} requires preview_token`);
|
|
1099
|
+
}
|
|
1100
|
+
if (['apply', 'undo'].includes(args.step) && args.confirm !== true)
|
|
1101
|
+
throw new Error('apply/undo requires confirm: true');
|
|
1102
|
+
const client = clientFor(exec);
|
|
1103
|
+
const body = { action: args.step };
|
|
1104
|
+
if (args.confirm !== undefined)
|
|
1105
|
+
body['confirm'] = args.confirm;
|
|
1106
|
+
if (args.path !== undefined)
|
|
1107
|
+
body['path'] = args.path;
|
|
1108
|
+
if (args.edits !== undefined)
|
|
1109
|
+
body['edits'] = args.edits;
|
|
1110
|
+
if (args.preview_token !== undefined)
|
|
1111
|
+
body['preview_token'] = args.preview_token;
|
|
1112
|
+
return await client.postJson('/api/document/change-plan', body, exec.signal);
|
|
957
1113
|
}
|
|
1114
|
+
case 'structured': {
|
|
1115
|
+
if (args.step === undefined)
|
|
1116
|
+
throw new Error('step is required for novel_change_plan(action="structured")');
|
|
1117
|
+
if (args.step === 'preview') {
|
|
1118
|
+
if (!args.change_kind)
|
|
1119
|
+
throw new Error('preview requires change_kind');
|
|
1120
|
+
if (args.change === null || typeof args.change !== 'object' || Array.isArray(args.change)) {
|
|
1121
|
+
throw new Error(`preview requires one change object (received ${describeValue(args.change)})`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
else if (!args.preview_token?.trim()) {
|
|
1125
|
+
throw new Error(`${args.step} requires preview_token`);
|
|
1126
|
+
}
|
|
1127
|
+
const client = clientFor(exec);
|
|
1128
|
+
const body = { action: args.step };
|
|
1129
|
+
if (args.change_kind !== undefined)
|
|
1130
|
+
body['change_kind'] = args.change_kind;
|
|
1131
|
+
if (args.change !== undefined)
|
|
1132
|
+
body['change'] = args.change;
|
|
1133
|
+
if (args.preview_token !== undefined)
|
|
1134
|
+
body['preview_token'] = args.preview_token;
|
|
1135
|
+
return await client.postJson('/api/structured/change-plan', body, exec.signal);
|
|
1136
|
+
}
|
|
1137
|
+
default: throw new Error(`novel_change_plan: unknown action ${String(args.action)}`);
|
|
958
1138
|
}
|
|
959
|
-
else if (!args.preview_token?.trim()) {
|
|
960
|
-
throw new Error(`${args.action} requires preview_token`);
|
|
961
|
-
}
|
|
962
|
-
const client = clientFor(exec);
|
|
963
|
-
const body = { action: args.action };
|
|
964
|
-
if (args.change_kind !== undefined)
|
|
965
|
-
body['change_kind'] = args.change_kind;
|
|
966
|
-
if (args.change !== undefined)
|
|
967
|
-
body['change'] = args.change;
|
|
968
|
-
if (args.preview_token !== undefined)
|
|
969
|
-
body['preview_token'] = args.preview_token;
|
|
970
|
-
return await client.postJson('/api/structured/change-plan', body, exec.signal);
|
|
971
1139
|
},
|
|
972
1140
|
}));
|
|
973
1141
|
ctx.tools.register(defineTool({
|
|
974
1142
|
name: 'novel_focus',
|
|
975
|
-
description: 'Immediate compatibility write for a focus change the author has already authorized exactly; otherwise use
|
|
1143
|
+
description: 'Immediate compatibility write for a focus change the author has already authorized exactly; otherwise use novel_change_plan(action="structured"). Set the writing focus compass for the project.',
|
|
976
1144
|
parameters: {
|
|
977
1145
|
goal: { type: 'string', required: true, description: 'The current writing goal.' },
|
|
978
1146
|
must_keep: { type: 'array', items: { type: 'string' }, description: 'Constraints that must be kept.' },
|
|
@@ -993,557 +1161,435 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
993
1161
|
return await client.postJson('/api/focus', body, exec.signal);
|
|
994
1162
|
},
|
|
995
1163
|
}));
|
|
996
|
-
|
|
997
|
-
name: 'novel_export_preflight',
|
|
998
|
-
description: 'Inspect the current manuscript export order, structural defects, writing targets, metadata, review freshness, ' +
|
|
999
|
-
'manuscript acceptance, blockers, and warnings. Pass the returned preflight_revision unchanged to novel_export ' +
|
|
1000
|
-
'so the downloaded file is bound to the manuscript state that was inspected.',
|
|
1001
|
-
parameters: {
|
|
1002
|
-
format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
|
|
1003
|
-
purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
|
|
1004
|
-
},
|
|
1005
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1006
|
-
timeoutMs,
|
|
1007
|
-
isConcurrencySafe: () => true,
|
|
1008
|
-
async execute(args, exec) {
|
|
1009
|
-
const client = clientFor(exec);
|
|
1010
|
-
return await client.getJson('/api/export/preflight', {
|
|
1011
|
-
format: args.format ?? 'md',
|
|
1012
|
-
purpose: args.purpose ?? 'delivery',
|
|
1013
|
-
}, exec.signal);
|
|
1014
|
-
},
|
|
1015
|
-
}));
|
|
1164
|
+
// ── export ────────────────────────────────────────────────────────────────────
|
|
1016
1165
|
ctx.tools.register(defineTool({
|
|
1017
1166
|
name: 'novel_export',
|
|
1018
|
-
description: '
|
|
1019
|
-
'
|
|
1167
|
+
description: 'Preflight and run a manuscript export. Preflight validates the selection, purpose and format ' +
|
|
1168
|
+
'and returns a revision the export must present.',
|
|
1020
1169
|
parameters: {
|
|
1170
|
+
action: { type: 'string', required: true, enum: ['preflight', 'export'], description: 'Export step; preflight reports what would be written without writing it.' },
|
|
1021
1171
|
format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
|
|
1022
|
-
purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery").' },
|
|
1023
|
-
preflight_revision: { type: 'string', description: 'Revision returned by
|
|
1024
|
-
},
|
|
1025
|
-
output: {
|
|
1026
|
-
schema: {
|
|
1027
|
-
type: 'object',
|
|
1028
|
-
additionalProperties: false,
|
|
1029
|
-
properties: {
|
|
1030
|
-
path: { type: 'string', required: true, description: 'Absolute path of the saved export file.' },
|
|
1031
|
-
filename: { type: 'string', required: true },
|
|
1032
|
-
format: { type: 'string', required: true },
|
|
1033
|
-
bytes: { type: 'integer', required: true },
|
|
1034
|
-
},
|
|
1035
|
-
},
|
|
1036
|
-
render: (_args, value) => [{
|
|
1037
|
-
type: 'text',
|
|
1038
|
-
text: `Exported ${value.filename} (${value.bytes} bytes) to ${value.path}`,
|
|
1039
|
-
}],
|
|
1040
|
-
},
|
|
1041
|
-
timeoutMs,
|
|
1042
|
-
async execute(args, exec) {
|
|
1043
|
-
const client = clientFor(exec);
|
|
1044
|
-
const format = args.format ?? 'md';
|
|
1045
|
-
const purpose = args.purpose ?? 'delivery';
|
|
1046
|
-
const download = await client.download('/api/export', {
|
|
1047
|
-
format,
|
|
1048
|
-
purpose,
|
|
1049
|
-
...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
|
|
1050
|
-
}, exec.signal);
|
|
1051
|
-
const filename = exportFilename(download.filename, `novel.${format}`);
|
|
1052
|
-
const exportDir = outputDirFor(outputDir, client);
|
|
1053
|
-
await mkdir(exportDir, { recursive: true });
|
|
1054
|
-
const path = join(exportDir, filename);
|
|
1055
|
-
await writeFile(path, download.content);
|
|
1056
|
-
return { path, filename, format, bytes: download.content.byteLength };
|
|
1057
|
-
},
|
|
1058
|
-
}));
|
|
1059
|
-
// ── assets: create / read / packages ────────────────────────────────────
|
|
1060
|
-
ctx.tools.register(defineTool({
|
|
1061
|
-
name: 'novel_asset_read',
|
|
1062
|
-
description: 'Read one structured asset by kind and id, including its relation view.',
|
|
1063
|
-
parameters: {
|
|
1064
|
-
kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
|
|
1065
|
-
id: { type: 'string', required: true, description: 'Asset id.' },
|
|
1066
|
-
},
|
|
1067
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1068
|
-
timeoutMs,
|
|
1069
|
-
isConcurrencySafe: () => true,
|
|
1070
|
-
async execute(args, exec) {
|
|
1071
|
-
const client = clientFor(exec);
|
|
1072
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(args.id))
|
|
1073
|
-
throw new Error(`invalid asset id "${args.id}"`);
|
|
1074
|
-
return await client.getJson(`/api/assets/${args.kind}/${encodeURIComponent(args.id)}`, {}, exec.signal);
|
|
1075
|
-
},
|
|
1076
|
-
}));
|
|
1077
|
-
ctx.tools.register(defineTool({
|
|
1078
|
-
name: 'novel_asset_create',
|
|
1079
|
-
description: 'Create a structured asset. character/world assets take front-matter fields via `data` plus an optional ' +
|
|
1080
|
-
'`body_markdown`; progression assets take their YAML mapping via `data`. Fails with a conflict if the id exists — ' +
|
|
1081
|
-
'use novel_asset_update instead.',
|
|
1082
|
-
parameters: {
|
|
1083
|
-
kind: { type: 'string', required: true, enum: ['character', 'world', 'progression'], description: 'Asset kind.' },
|
|
1084
|
-
id: { type: 'string', required: true, description: 'Asset id (letters, digits, "_", "-", ".").' },
|
|
1085
|
-
data: { type: 'json', description: 'Object of asset fields (e.g. name, summary, aliases).' },
|
|
1086
|
-
body_markdown: { type: 'string', description: 'Markdown body (character/world assets).' },
|
|
1172
|
+
purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
|
|
1173
|
+
preflight_revision: { type: 'string', description: 'Revision returned by novel_export(action="preflight") for the same format and purpose.' },
|
|
1087
1174
|
},
|
|
1088
1175
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1089
1176
|
timeoutMs,
|
|
1177
|
+
isConcurrencySafe: args => ['preflight'].includes(args.action),
|
|
1090
1178
|
async execute(args, exec) {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
body['body_markdown'] = args.body_markdown;
|
|
1099
|
-
return await client.postJson('/api/assets', body, exec.signal);
|
|
1100
|
-
},
|
|
1101
|
-
}));
|
|
1102
|
-
ctx.tools.register(defineTool({
|
|
1103
|
-
name: 'novel_assets_package_export',
|
|
1104
|
-
description: 'Export assets as an .owasset.zip package and save it locally. Omit selections to export all assets.',
|
|
1105
|
-
parameters: {
|
|
1106
|
-
selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
|
|
1107
|
-
},
|
|
1108
|
-
output: {
|
|
1109
|
-
schema: {
|
|
1110
|
-
type: 'object',
|
|
1111
|
-
additionalProperties: false,
|
|
1112
|
-
properties: {
|
|
1113
|
-
path: { type: 'string', required: true, description: 'Absolute path of the saved package file.' },
|
|
1114
|
-
filename: { type: 'string', required: true },
|
|
1115
|
-
bytes: { type: 'integer', required: true },
|
|
1116
|
-
},
|
|
1117
|
-
},
|
|
1118
|
-
render: (_args, value) => [{
|
|
1119
|
-
type: 'text',
|
|
1120
|
-
text: `Exported asset package ${value.filename} (${value.bytes} bytes) to ${value.path}`,
|
|
1121
|
-
}],
|
|
1122
|
-
},
|
|
1123
|
-
timeoutMs,
|
|
1124
|
-
isConcurrencySafe: () => true,
|
|
1125
|
-
async execute(args, exec) {
|
|
1126
|
-
const client = clientFor(exec);
|
|
1127
|
-
const selections = args.selections ?? [];
|
|
1128
|
-
for (const item of selections) {
|
|
1129
|
-
if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
|
|
1130
|
-
throw new Error(`invalid asset selection "${item}"; expected "kind:id"`);
|
|
1179
|
+
switch (args.action) {
|
|
1180
|
+
case 'preflight': {
|
|
1181
|
+
const client = clientFor(exec);
|
|
1182
|
+
return await client.getJson('/api/export/preflight', {
|
|
1183
|
+
format: args.format ?? 'md',
|
|
1184
|
+
purpose: args.purpose ?? 'delivery',
|
|
1185
|
+
}, exec.signal);
|
|
1131
1186
|
}
|
|
1187
|
+
case 'export': {
|
|
1188
|
+
const client = clientFor(exec);
|
|
1189
|
+
const format = args.format ?? 'md';
|
|
1190
|
+
const purpose = args.purpose ?? 'delivery';
|
|
1191
|
+
const download = await client.download('/api/export', {
|
|
1192
|
+
format,
|
|
1193
|
+
purpose,
|
|
1194
|
+
...(args.preflight_revision === undefined ? {} : { preflight_revision: args.preflight_revision }),
|
|
1195
|
+
}, exec.signal);
|
|
1196
|
+
const filename = exportFilename(download.filename, `novel.${format}`);
|
|
1197
|
+
const exportDir = outputDirFor(outputDir, client);
|
|
1198
|
+
await mkdir(exportDir, { recursive: true });
|
|
1199
|
+
const path = join(exportDir, filename);
|
|
1200
|
+
await writeFile(path, download.content);
|
|
1201
|
+
return { path, filename, format, bytes: download.content.byteLength };
|
|
1202
|
+
}
|
|
1203
|
+
default: throw new Error(`novel_export: unknown action ${String(args.action)}`);
|
|
1132
1204
|
}
|
|
1133
|
-
const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
|
|
1134
|
-
const filename = exportFilename(download.filename, 'assets.owasset.zip');
|
|
1135
|
-
const exportDir = outputDirFor(outputDir, client);
|
|
1136
|
-
await mkdir(exportDir, { recursive: true });
|
|
1137
|
-
const path = join(exportDir, filename);
|
|
1138
|
-
await writeFile(path, download.content);
|
|
1139
|
-
return { path, filename, bytes: download.content.byteLength };
|
|
1140
|
-
},
|
|
1141
|
-
}));
|
|
1142
|
-
ctx.tools.register(defineTool({
|
|
1143
|
-
name: 'novel_assets_package_preview',
|
|
1144
|
-
description: 'Stage an .owasset.zip asset package (base64) and preview its contents and conflicts without importing. ' +
|
|
1145
|
-
'Returns an upload_id to pass to novel_assets_package_import.',
|
|
1146
|
-
parameters: {
|
|
1147
|
-
package_base64: { type: 'string', required: true, description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
|
|
1148
|
-
},
|
|
1149
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1150
|
-
timeoutMs,
|
|
1151
|
-
async execute(args, exec) {
|
|
1152
|
-
const client = clientFor(exec);
|
|
1153
|
-
if (!args.package_base64.trim())
|
|
1154
|
-
throw new Error('package_base64 must be a non-empty string');
|
|
1155
|
-
return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
|
|
1156
1205
|
},
|
|
1157
1206
|
}));
|
|
1207
|
+
// ── assets: create / read / packages ────────────────────────────────────
|
|
1208
|
+
// ── asset packages ────────────────────────────────────────────────────────────
|
|
1158
1209
|
ctx.tools.register(defineTool({
|
|
1159
|
-
name: '
|
|
1160
|
-
description: '
|
|
1161
|
-
'
|
|
1210
|
+
name: 'novel_assets_package',
|
|
1211
|
+
description: 'Export the project assets into a portable package, preview a package before importing it, and ' +
|
|
1212
|
+
'import a reviewed package with explicit dependency resolutions.',
|
|
1162
1213
|
parameters: {
|
|
1163
|
-
|
|
1164
|
-
|
|
1214
|
+
action: { type: 'string', required: true, enum: ['export', 'preview', 'import'], description: 'Asset package operation.' },
|
|
1215
|
+
selections: { type: 'array', items: { type: 'string' }, description: 'Asset selectors in "kind:id" form, e.g. ["character:hero", "world:capital"].' },
|
|
1216
|
+
package_base64: { type: 'string', description: 'Base64-encoded .owasset.zip package (max 25 MB decoded).' },
|
|
1217
|
+
upload_id: { type: 'string', description: 'The upload_id returned by novel_assets_package(action="preview").' },
|
|
1218
|
+
package_sha256: { type: 'string', description: 'Package digest. Required for import: pass the package_sha256 returned by action="preview"; the server rejects an import without it as a stale package.' },
|
|
1165
1219
|
resolutions: { type: 'json', description: 'Object mapping conflicting asset keys to resolutions.' },
|
|
1166
1220
|
allow_missing_dependencies: { type: 'boolean', description: 'Allow import despite missing asset dependencies (default false).' },
|
|
1167
1221
|
},
|
|
1168
1222
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1169
1223
|
timeoutMs,
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1224
|
+
// `export` writes a local archive (non-atomic `writeFile` to a fixed name),
|
|
1225
|
+
// so it must be serialized; `preview` is the server-side read-only action.
|
|
1226
|
+
isConcurrencySafe: args => args.action === 'preview',
|
|
1227
|
+
async execute(args, exec) {
|
|
1228
|
+
switch (args.action) {
|
|
1229
|
+
case 'export': {
|
|
1230
|
+
const client = clientFor(exec);
|
|
1231
|
+
const selections = args.selections ?? [];
|
|
1232
|
+
for (const item of selections) {
|
|
1233
|
+
if (!/^(character|world|progression):[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(item)) {
|
|
1234
|
+
throw new Error(`invalid asset selection "${item}"; expected "kind:id"`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const download = await client.download('/api/assets/package/export', { select: selections }, exec.signal);
|
|
1238
|
+
const filename = exportFilename(download.filename, 'assets.owasset.zip');
|
|
1239
|
+
const exportDir = outputDirFor(outputDir, client);
|
|
1240
|
+
await mkdir(exportDir, { recursive: true });
|
|
1241
|
+
const path = join(exportDir, filename);
|
|
1242
|
+
await writeFile(path, download.content);
|
|
1243
|
+
return { path, filename, bytes: download.content.byteLength };
|
|
1244
|
+
}
|
|
1245
|
+
case 'preview': {
|
|
1246
|
+
if (args.package_base64 === undefined)
|
|
1247
|
+
throw new Error('package_base64 is required for novel_assets_package(action="preview")');
|
|
1248
|
+
const client = clientFor(exec);
|
|
1249
|
+
if (!args.package_base64.trim())
|
|
1250
|
+
throw new Error('package_base64 must be a non-empty string');
|
|
1251
|
+
return await client.postJson('/api/assets/package/preview', { package_base64: args.package_base64 }, exec.signal);
|
|
1252
|
+
}
|
|
1253
|
+
case 'import': {
|
|
1254
|
+
if (args.upload_id === undefined)
|
|
1255
|
+
throw new Error('upload_id is required for novel_assets_package(action="import")');
|
|
1256
|
+
const client = clientFor(exec);
|
|
1257
|
+
// The server treats a missing digest as a stale package, so an
|
|
1258
|
+
// import without the preview digest always fails with
|
|
1259
|
+
// ASSET_PACKAGE_CONFLICT. Require it and name the fix here.
|
|
1260
|
+
if (!args.package_sha256?.trim()) {
|
|
1261
|
+
throw new Error('package_sha256 is required for novel_assets_package(action="import"); pass the package_sha256 returned by action="preview"');
|
|
1262
|
+
}
|
|
1263
|
+
const body = { upload_id: args.upload_id };
|
|
1264
|
+
if (args.package_sha256 !== undefined)
|
|
1265
|
+
body['package_sha256'] = args.package_sha256;
|
|
1266
|
+
if (args.resolutions !== undefined)
|
|
1267
|
+
body['resolutions'] = args.resolutions;
|
|
1268
|
+
if (args.allow_missing_dependencies !== undefined)
|
|
1269
|
+
body['allow_missing_dependencies'] = args.allow_missing_dependencies;
|
|
1270
|
+
return await client.postJson('/api/assets/package/import', body, exec.signal);
|
|
1271
|
+
}
|
|
1272
|
+
default: throw new Error(`novel_assets_package: unknown action ${String(args.action)}`);
|
|
1273
|
+
}
|
|
1180
1274
|
},
|
|
1181
1275
|
}));
|
|
1182
1276
|
// ── revisions ────────────────────────────────────────────────────────────
|
|
1277
|
+
// ── revisions ─────────────────────────────────────────────────────────────────
|
|
1183
1278
|
ctx.tools.register(defineTool({
|
|
1184
|
-
name: '
|
|
1185
|
-
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.',
|
|
1186
1283
|
parameters: {
|
|
1284
|
+
action: { type: 'string', required: true, enum: ['list', 'get', 'create', 'regenerate', 'apply', 'reject'], description: 'Revision operation; create reads its source from the source parameter.' },
|
|
1187
1285
|
chapter: { type: 'string', description: 'Chapter id filter (e.g. "ch_0007").' },
|
|
1188
1286
|
status: { type: 'string', description: 'Status filter (e.g. "proposed", "applied", "rejected").' },
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
async execute(args, exec) {
|
|
1194
|
-
const client = clientFor(exec);
|
|
1195
|
-
const params = {};
|
|
1196
|
-
if (args.chapter !== undefined)
|
|
1197
|
-
params['chapter'] = args.chapter;
|
|
1198
|
-
if (args.status !== undefined)
|
|
1199
|
-
params['status'] = args.status;
|
|
1200
|
-
return await client.getJson('/api/revisions', params, exec.signal);
|
|
1201
|
-
},
|
|
1202
|
-
}));
|
|
1203
|
-
ctx.tools.register(defineTool({
|
|
1204
|
-
name: 'novel_revision_get',
|
|
1205
|
-
description: 'Get one revision proposal with its diff hunks by proposal id (rev_*).',
|
|
1206
|
-
parameters: {
|
|
1207
|
-
proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
|
|
1208
|
-
},
|
|
1209
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1210
|
-
timeoutMs,
|
|
1211
|
-
isConcurrencySafe: () => true,
|
|
1212
|
-
async execute(args, exec) {
|
|
1213
|
-
const client = clientFor(exec);
|
|
1214
|
-
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1215
|
-
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1216
|
-
return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
|
|
1217
|
-
},
|
|
1218
|
-
}));
|
|
1219
|
-
ctx.tools.register(defineTool({
|
|
1220
|
-
name: 'novel_revision_create_selection',
|
|
1221
|
-
description: 'Create a revision proposal for a text selection in a chapter (LONG-RUNNING, model-generated). ' +
|
|
1222
|
-
'start/end are Unicode code-point offsets in the exact saved text, including original CR/LF characters; pass the selected text as original_text and the current expected_document_revision. The proposal is only staged — ' +
|
|
1223
|
-
'apply it with novel_revision_apply.',
|
|
1224
|
-
parameters: {
|
|
1225
|
-
chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
|
|
1226
|
-
start: { type: 'integer', required: true, description: 'Selection start offset.' },
|
|
1227
|
-
end: { type: 'integer', required: true, description: 'Selection end offset.' },
|
|
1228
|
-
original_text: { type: 'string', description: 'The selected original text.' },
|
|
1229
|
-
expected_document_revision: { type: 'string', description: 'Current exact manuscript revision; reject before generation if the source changed.' },
|
|
1230
|
-
instruction: { type: 'string', description: 'Revision instruction (max 4000 characters).' },
|
|
1231
|
-
action: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
|
|
1232
|
-
target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
|
|
1233
|
-
full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
|
|
1234
|
-
},
|
|
1235
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1236
|
-
timeoutMs,
|
|
1237
|
-
async execute(args, exec) {
|
|
1238
|
-
const client = clientFor(exec);
|
|
1239
|
-
if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
|
|
1240
|
-
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
1241
|
-
const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
|
|
1242
|
-
if (args.original_text !== undefined)
|
|
1243
|
-
body['original_text'] = args.original_text;
|
|
1244
|
-
if (args.expected_document_revision !== undefined)
|
|
1245
|
-
body['expected_document_revision'] = args.expected_document_revision;
|
|
1246
|
-
if (args.instruction !== undefined)
|
|
1247
|
-
body['instruction'] = args.instruction;
|
|
1248
|
-
if (args.action !== undefined)
|
|
1249
|
-
body['action'] = args.action;
|
|
1250
|
-
if (args.target_units !== undefined)
|
|
1251
|
-
body['target_units'] = args.target_units;
|
|
1252
|
-
if (args.full_chapter !== undefined)
|
|
1253
|
-
body['full_chapter'] = args.full_chapter;
|
|
1254
|
-
return await client.postJson('/api/revisions/selection', body, exec.signal);
|
|
1255
|
-
},
|
|
1256
|
-
}));
|
|
1257
|
-
ctx.tools.register(defineTool({
|
|
1258
|
-
name: 'novel_revision_create_from_review',
|
|
1259
|
-
description: 'Create a revision proposal that addresses selected issues from a chapter review (LONG-RUNNING, model-generated). ' +
|
|
1260
|
-
'Issue ids come from novel_review_chapter\'s issue_details. Pass selection to constrain rewriting to an exact manuscript range; overlapping issue evidence never expands that range. The proposal is only staged — apply it with novel_revision_apply.',
|
|
1261
|
-
parameters: {
|
|
1262
|
-
chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
|
|
1263
|
-
issue_ids: { type: 'array', required: true, items: { type: 'string' }, description: 'Review issue ids to fix.' },
|
|
1287
|
+
proposal_id: { type: 'string', description: 'Revision proposal id (rev_*).' },
|
|
1288
|
+
source: { type: 'string', enum: ['review', 'selection'], description: 'Create source: review (default) fixes review issues, selection revises an explicit text range.' },
|
|
1289
|
+
chapter_id: { type: 'string', description: 'Chapter id (ch_<digits>).' },
|
|
1290
|
+
issue_ids: { type: 'array', items: { type: 'string' }, description: 'Review issue ids to fix; required for create with source="review".' },
|
|
1264
1291
|
selection: { type: 'object', additionalProperties: false, description: 'Optional exact saved-text range in Unicode code points; preserve raw CR/LF characters.', properties: {
|
|
1265
|
-
start: { type: 'integer',
|
|
1266
|
-
end: { type: 'integer',
|
|
1292
|
+
start: { type: 'integer', description: 'Inclusive source code-point offset.' },
|
|
1293
|
+
end: { type: 'integer', description: 'Exclusive source code-point offset.' },
|
|
1267
1294
|
original_text: { type: 'string', required: true, description: 'Exact saved text inside this range.' },
|
|
1268
1295
|
} },
|
|
1269
1296
|
instruction: { type: 'string', description: 'Additional revision instruction.' },
|
|
1270
1297
|
target_units: { type: 'integer', description: 'Target length in writing units (0 = keep similar).' },
|
|
1271
|
-
expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
|
|
1272
|
-
expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
if (args.issue_ids.length === 0)
|
|
1281
|
-
throw new Error('issue_ids must be a non-empty array');
|
|
1282
|
-
const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
|
|
1283
|
-
if (args.selection !== undefined)
|
|
1284
|
-
body['selection'] = args.selection;
|
|
1285
|
-
if (args.instruction !== undefined)
|
|
1286
|
-
body['instruction'] = args.instruction;
|
|
1287
|
-
if (args.target_units !== undefined)
|
|
1288
|
-
body['target_units'] = args.target_units;
|
|
1289
|
-
if (args.expected_review_revision !== undefined)
|
|
1290
|
-
body['expected_review_revision'] = args.expected_review_revision;
|
|
1291
|
-
if (args.expected_document_revision !== undefined)
|
|
1292
|
-
body['expected_document_revision'] = args.expected_document_revision;
|
|
1293
|
-
const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
|
|
1294
|
-
try {
|
|
1295
|
-
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
|
|
1296
|
-
}
|
|
1297
|
-
catch (error) {
|
|
1298
|
-
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1299
|
-
}
|
|
1300
|
-
},
|
|
1301
|
-
}));
|
|
1302
|
-
ctx.tools.register(defineTool({
|
|
1303
|
-
name: 'novel_revision_apply',
|
|
1304
|
-
description: 'Apply a staged revision proposal to the manuscript. Optionally override the generated text or apply only ' +
|
|
1305
|
-
'selected diff hunks. Fails with a conflict if the document changed since the proposal was created.',
|
|
1306
|
-
parameters: {
|
|
1307
|
-
proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
|
|
1308
|
-
replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
|
|
1309
|
-
selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
|
|
1310
|
-
},
|
|
1311
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1312
|
-
timeoutMs,
|
|
1313
|
-
async execute(args, exec) {
|
|
1314
|
-
const client = clientFor(exec);
|
|
1315
|
-
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1316
|
-
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1317
|
-
const body = {};
|
|
1318
|
-
if (args.replacement_text !== undefined)
|
|
1319
|
-
body['replacement_text'] = args.replacement_text;
|
|
1320
|
-
if (args.selected_hunk_ids !== undefined)
|
|
1321
|
-
body['selected_hunk_ids'] = args.selected_hunk_ids;
|
|
1322
|
-
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
|
|
1323
|
-
const responseValue = objectValue(response);
|
|
1324
|
-
const proposal = objectValue(responseValue['proposal'] ?? response);
|
|
1325
|
-
const chapterId = String(proposal['chapter_id'] ?? '');
|
|
1326
|
-
try {
|
|
1327
|
-
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1328
|
-
}
|
|
1329
|
-
catch (error) {
|
|
1330
|
-
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1331
|
-
}
|
|
1332
|
-
},
|
|
1333
|
-
}));
|
|
1334
|
-
ctx.tools.register(defineTool({
|
|
1335
|
-
name: 'novel_revision_reject',
|
|
1336
|
-
description: 'Reject and discard a staged revision proposal. This permanently drops the generated text — it cannot be ' +
|
|
1337
|
-
'recovered except by regenerating.',
|
|
1338
|
-
parameters: {
|
|
1339
|
-
proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
|
|
1340
|
-
},
|
|
1341
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1342
|
-
timeoutMs,
|
|
1343
|
-
async execute(args, exec) {
|
|
1344
|
-
const client = clientFor(exec);
|
|
1345
|
-
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1346
|
-
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1347
|
-
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/reject`, {}, exec.signal);
|
|
1348
|
-
const chapterId = String(objectValue(response)['chapter_id'] ?? '');
|
|
1349
|
-
try {
|
|
1350
|
-
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1351
|
-
}
|
|
1352
|
-
catch (error) {
|
|
1353
|
-
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1354
|
-
}
|
|
1355
|
-
},
|
|
1356
|
-
}));
|
|
1357
|
-
ctx.tools.register(defineTool({
|
|
1358
|
-
name: 'novel_revision_regenerate',
|
|
1359
|
-
description: 'Regenerate the text of a staged revision proposal (LONG-RUNNING, model-generated).',
|
|
1360
|
-
parameters: {
|
|
1361
|
-
proposal_id: { type: 'string', required: true, description: 'Revision proposal id (rev_*).' },
|
|
1362
|
-
},
|
|
1363
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1364
|
-
timeoutMs,
|
|
1365
|
-
async execute(args, exec) {
|
|
1366
|
-
const client = clientFor(exec);
|
|
1367
|
-
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1368
|
-
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1369
|
-
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
|
|
1370
|
-
const chapterId = String(objectValue(response)['chapter_id'] ?? '');
|
|
1371
|
-
try {
|
|
1372
|
-
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1373
|
-
}
|
|
1374
|
-
catch (error) {
|
|
1375
|
-
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1376
|
-
}
|
|
1377
|
-
},
|
|
1378
|
-
}));
|
|
1379
|
-
// ── background tasks ─────────────────────────────────────────────────────
|
|
1380
|
-
ctx.tools.register(defineTool({
|
|
1381
|
-
name: 'novel_tasks_list',
|
|
1382
|
-
description: 'List background tasks (queued/running/finished AI jobs) with per-status counts.',
|
|
1383
|
-
parameters: {
|
|
1384
|
-
since: { type: 'string', description: 'ISO timestamp filter for requests whose response was lost.' },
|
|
1385
|
-
limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
|
|
1386
|
-
},
|
|
1387
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1388
|
-
timeoutMs,
|
|
1389
|
-
isConcurrencySafe: () => true,
|
|
1390
|
-
async execute(args, exec) {
|
|
1391
|
-
const client = clientFor(exec);
|
|
1392
|
-
const params = {};
|
|
1393
|
-
if (args.since !== undefined)
|
|
1394
|
-
params['since'] = args.since;
|
|
1395
|
-
if (args.limit !== undefined)
|
|
1396
|
-
params['limit'] = String(args.limit);
|
|
1397
|
-
return await client.getJson('/api/tasks', params, exec.signal);
|
|
1398
|
-
},
|
|
1399
|
-
}));
|
|
1400
|
-
ctx.tools.register(defineTool({
|
|
1401
|
-
name: 'novel_task_get',
|
|
1402
|
-
description: 'Get one background task with its event log by task id (tsk_*). Completed chapter write/review/revision tasks refresh the chapter-delivery DoG graph; chapter reviews also materialize the 37-dimension graph.',
|
|
1403
|
-
parameters: {
|
|
1404
|
-
task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
|
|
1405
|
-
},
|
|
1406
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1407
|
-
timeoutMs,
|
|
1408
|
-
async execute(args, exec) {
|
|
1409
|
-
const client = clientFor(exec);
|
|
1410
|
-
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1411
|
-
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1412
|
-
const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
|
|
1413
|
-
const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
|
|
1414
|
-
? response
|
|
1415
|
-
: {};
|
|
1416
|
-
const taskValue = responseRecord['task'] ?? response;
|
|
1417
|
-
const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
|
|
1418
|
-
? taskValue
|
|
1419
|
-
: {};
|
|
1420
|
-
if (taskRecord['status'] !== 'completed')
|
|
1421
|
-
return response;
|
|
1422
|
-
const taskType = String(taskRecord['type'] ?? '');
|
|
1423
|
-
if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
|
|
1424
|
-
return response;
|
|
1425
|
-
const taskResult = objectValue(taskRecord['result']);
|
|
1426
|
-
const taskInput = objectValue(taskRecord['input']);
|
|
1427
|
-
const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
|
|
1428
|
-
|| String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
|
|
1429
|
-
|| '';
|
|
1430
|
-
const value = response !== null && typeof response === 'object' && !Array.isArray(response)
|
|
1431
|
-
? response
|
|
1432
|
-
: { task: response };
|
|
1433
|
-
if (!/^ch_\d+$/.test(chapterId)) {
|
|
1434
|
-
return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
|
|
1435
|
-
}
|
|
1436
|
-
let workspace;
|
|
1437
|
-
try {
|
|
1438
|
-
workspace = await client.getJson('/api/workspace', {}, exec.signal);
|
|
1439
|
-
}
|
|
1440
|
-
catch (error) {
|
|
1441
|
-
const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1442
|
-
return taskType === 'chapter_review'
|
|
1443
|
-
? { ...value, dog_review: unavailable, dog_delivery: unavailable }
|
|
1444
|
-
: { ...value, dog_delivery: unavailable };
|
|
1445
|
-
}
|
|
1446
|
-
let dogDelivery;
|
|
1447
|
-
try {
|
|
1448
|
-
dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
|
|
1449
|
-
}
|
|
1450
|
-
catch (error) {
|
|
1451
|
-
dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1452
|
-
}
|
|
1453
|
-
if (taskType !== 'chapter_review')
|
|
1454
|
-
return { ...value, dog_delivery: dogDelivery };
|
|
1455
|
-
let dogReview;
|
|
1456
|
-
try {
|
|
1457
|
-
const framework = await client.getJson('/api/review/framework', {}, exec.signal);
|
|
1458
|
-
dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, 70, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
|
|
1459
|
-
}
|
|
1460
|
-
catch (error) {
|
|
1461
|
-
dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1462
|
-
}
|
|
1463
|
-
return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
|
|
1464
|
-
},
|
|
1465
|
-
}));
|
|
1466
|
-
ctx.tools.register(defineTool({
|
|
1467
|
-
name: 'novel_task_create',
|
|
1468
|
-
description: 'Queue a background AI task and return immediately; poll with novel_task_get. Task types and their `input` keys: ' +
|
|
1469
|
-
'chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
|
|
1470
|
-
'chapter_review {chapter_id | path, strict?, dimensions?}; ' +
|
|
1471
|
-
'revision_selection / revision_from_review (same inputs as the novel_revision_create_* tools); ' +
|
|
1472
|
-
'source_operation / reference_operation (same inputs as novel_source_action / novel_reference_library_action); ' +
|
|
1473
|
-
'manuscript_import (same input as novel_import); research {prompt, search?, language?, quality?, llm?}; ' +
|
|
1474
|
-
'manuscript_reconcile (durable acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); ' +
|
|
1475
|
-
'settle_backfill {chapter_ids?, only_missing?} — prefer the novel_settle_backfill convenience tool; ' +
|
|
1476
|
-
'continuous_write — prefer the novel_multi_write convenience tool.',
|
|
1477
|
-
parameters: {
|
|
1478
|
-
type: {
|
|
1479
|
-
type: 'string',
|
|
1480
|
-
required: true,
|
|
1481
|
-
enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
|
|
1482
|
-
description: 'Task type.',
|
|
1483
|
-
},
|
|
1484
|
-
input: { type: 'json', required: true, description: 'Task input object; shape depends on the task type (see description).' },
|
|
1485
|
-
},
|
|
1486
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1487
|
-
timeoutMs,
|
|
1488
|
-
async execute(args, exec) {
|
|
1489
|
-
const client = clientFor(exec);
|
|
1490
|
-
if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
|
|
1491
|
-
throw new Error('input must be a JSON object');
|
|
1492
|
-
}
|
|
1493
|
-
return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
|
|
1494
|
-
},
|
|
1495
|
-
}));
|
|
1496
|
-
ctx.tools.register(defineTool({
|
|
1497
|
-
name: 'novel_task_cancel',
|
|
1498
|
-
description: 'Request cancellation of a queued or running task. Check cancelled and cancellation_status for the actual outcome; poll novel_task_get while cancellation is requested.',
|
|
1499
|
-
parameters: {
|
|
1500
|
-
wait_seconds: { type: 'number', description: 'Wait up to 30 seconds for actual cancellation; running model calls may finish later.' },
|
|
1501
|
-
task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
|
|
1502
|
-
},
|
|
1503
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1504
|
-
timeoutMs,
|
|
1505
|
-
async execute(args, exec) {
|
|
1506
|
-
const client = clientFor(exec);
|
|
1507
|
-
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1508
|
-
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1509
|
-
return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, { wait_seconds: args.wait_seconds ?? 0 }, exec.signal);
|
|
1510
|
-
},
|
|
1511
|
-
}));
|
|
1512
|
-
ctx.tools.register(defineTool({
|
|
1513
|
-
name: 'novel_task_retry',
|
|
1514
|
-
description: 'Retry a failed or cancelled background task.',
|
|
1515
|
-
parameters: {
|
|
1516
|
-
task_id: { type: 'string', required: true, description: 'Task id (tsk_*).' },
|
|
1298
|
+
expected_review_revision: { type: 'string', description: 'Exact review file revision from the chapter work brief.' },
|
|
1299
|
+
expected_document_revision: { type: 'string', description: 'Exact manuscript revision from the chapter work brief.' },
|
|
1300
|
+
start: { type: 'integer', description: 'Selection start offset.' },
|
|
1301
|
+
end: { type: 'integer', description: 'Selection end offset.' },
|
|
1302
|
+
original_text: { type: 'string', description: 'The selected original text.' },
|
|
1303
|
+
mode: { type: 'string', enum: ['rewrite', 'expand', 'compress', 'pace', 'dialogue', 'naturalize', 'custom'], description: 'Revision mode (default "rewrite").' },
|
|
1304
|
+
full_chapter: { type: 'boolean', description: 'Treat the whole chapter as the selection (default false).' },
|
|
1305
|
+
replacement_text: { type: 'string', description: 'Replacement text overriding the proposal.' },
|
|
1306
|
+
selected_hunk_ids: { type: 'array', items: { type: 'string' }, description: 'Apply only these diff hunk ids.' },
|
|
1517
1307
|
},
|
|
1518
1308
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1519
1309
|
timeoutMs,
|
|
1310
|
+
isConcurrencySafe: args => ['list', 'get'].includes(args.action),
|
|
1520
1311
|
async execute(args, exec) {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1312
|
+
switch (args.action) {
|
|
1313
|
+
case 'list': {
|
|
1314
|
+
const client = clientFor(exec);
|
|
1315
|
+
const params = {};
|
|
1316
|
+
if (args.chapter !== undefined)
|
|
1317
|
+
params['chapter'] = args.chapter;
|
|
1318
|
+
if (args.status !== undefined)
|
|
1319
|
+
params['status'] = args.status;
|
|
1320
|
+
return await client.getJson('/api/revisions', params, exec.signal);
|
|
1321
|
+
}
|
|
1322
|
+
case 'get': {
|
|
1323
|
+
if (args.proposal_id === undefined)
|
|
1324
|
+
throw new Error('proposal_id is required for novel_revision(action="get")');
|
|
1325
|
+
const client = clientFor(exec);
|
|
1326
|
+
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1327
|
+
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1328
|
+
return await client.getJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}`, {}, exec.signal);
|
|
1329
|
+
}
|
|
1330
|
+
case 'create': {
|
|
1331
|
+
if (args.chapter_id === undefined)
|
|
1332
|
+
throw new Error('chapter_id is required for novel_revision(action="create")');
|
|
1333
|
+
// source=selection keeps the pre-merge create-from-selection contract;
|
|
1334
|
+
// anything else (including an omitted source) stays on from-review.
|
|
1335
|
+
if (args.source === 'selection') {
|
|
1336
|
+
if (args.chapter_id === undefined)
|
|
1337
|
+
throw new Error('chapter_id is required for novel_revision(action="create", source="selection")');
|
|
1338
|
+
if (args.start === undefined)
|
|
1339
|
+
throw new Error('start is required for novel_revision(action="create", source="selection")');
|
|
1340
|
+
if (args.end === undefined)
|
|
1341
|
+
throw new Error('end is required for novel_revision(action="create", source="selection")');
|
|
1342
|
+
const client = clientFor(exec);
|
|
1343
|
+
if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
|
|
1344
|
+
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
1345
|
+
const body = { chapter_id: args.chapter_id, start: args.start, end: args.end };
|
|
1346
|
+
if (args.original_text !== undefined)
|
|
1347
|
+
body['original_text'] = args.original_text;
|
|
1348
|
+
if (args.expected_document_revision !== undefined)
|
|
1349
|
+
body['expected_document_revision'] = args.expected_document_revision;
|
|
1350
|
+
if (args.instruction !== undefined)
|
|
1351
|
+
body['instruction'] = args.instruction;
|
|
1352
|
+
if (args.mode !== undefined)
|
|
1353
|
+
body['action'] = args.mode;
|
|
1354
|
+
if (args.target_units !== undefined)
|
|
1355
|
+
body['target_units'] = args.target_units;
|
|
1356
|
+
if (args.full_chapter !== undefined)
|
|
1357
|
+
body['full_chapter'] = args.full_chapter;
|
|
1358
|
+
return await client.postJson('/api/revisions/selection', body, exec.signal);
|
|
1359
|
+
}
|
|
1360
|
+
if (args.source !== undefined && args.source !== 'review' && args.source !== 'selection') {
|
|
1361
|
+
throw new Error(`source must be "review" or "selection", got "${args.source}"`);
|
|
1362
|
+
}
|
|
1363
|
+
if (args.issue_ids === undefined)
|
|
1364
|
+
throw new Error('issue_ids is required for novel_revision(action="create", source="review")');
|
|
1365
|
+
const client = clientFor(exec);
|
|
1366
|
+
if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
|
|
1367
|
+
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
1368
|
+
if (args.issue_ids.length === 0)
|
|
1369
|
+
throw new Error('issue_ids must be a non-empty array');
|
|
1370
|
+
const body = { chapter_id: args.chapter_id, issue_ids: args.issue_ids };
|
|
1371
|
+
if (args.selection !== undefined)
|
|
1372
|
+
body['selection'] = args.selection;
|
|
1373
|
+
if (args.instruction !== undefined)
|
|
1374
|
+
body['instruction'] = args.instruction;
|
|
1375
|
+
if (args.target_units !== undefined)
|
|
1376
|
+
body['target_units'] = args.target_units;
|
|
1377
|
+
if (args.expected_review_revision !== undefined)
|
|
1378
|
+
body['expected_review_revision'] = args.expected_review_revision;
|
|
1379
|
+
if (args.expected_document_revision !== undefined)
|
|
1380
|
+
body['expected_document_revision'] = args.expected_document_revision;
|
|
1381
|
+
const response = await client.postJson('/api/revisions/from-review', body, exec.signal);
|
|
1382
|
+
try {
|
|
1383
|
+
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, args.chapter_id, exec.signal) };
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
case 'regenerate': {
|
|
1390
|
+
if (args.proposal_id === undefined)
|
|
1391
|
+
throw new Error('proposal_id is required for novel_revision(action="regenerate")');
|
|
1392
|
+
const client = clientFor(exec);
|
|
1393
|
+
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1394
|
+
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1395
|
+
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/regenerate`, {}, exec.signal);
|
|
1396
|
+
const chapterId = String(objectValue(response)['chapter_id'] ?? '');
|
|
1397
|
+
try {
|
|
1398
|
+
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
case 'apply': {
|
|
1405
|
+
if (args.proposal_id === undefined)
|
|
1406
|
+
throw new Error('proposal_id is required for novel_revision(action="apply")');
|
|
1407
|
+
const client = clientFor(exec);
|
|
1408
|
+
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1409
|
+
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1410
|
+
const body = {};
|
|
1411
|
+
if (args.replacement_text !== undefined)
|
|
1412
|
+
body['replacement_text'] = args.replacement_text;
|
|
1413
|
+
if (args.selected_hunk_ids !== undefined)
|
|
1414
|
+
body['selected_hunk_ids'] = args.selected_hunk_ids;
|
|
1415
|
+
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/apply`, body, exec.signal);
|
|
1416
|
+
const responseValue = objectValue(response);
|
|
1417
|
+
const proposal = objectValue(responseValue['proposal'] ?? response);
|
|
1418
|
+
const chapterId = String(proposal['chapter_id'] ?? '');
|
|
1419
|
+
try {
|
|
1420
|
+
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1421
|
+
}
|
|
1422
|
+
catch (error) {
|
|
1423
|
+
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
case 'reject': {
|
|
1427
|
+
if (args.proposal_id === undefined)
|
|
1428
|
+
throw new Error('proposal_id is required for novel_revision(action="reject")');
|
|
1429
|
+
const client = clientFor(exec);
|
|
1430
|
+
if (!/^rev_[A-Za-z0-9_-]+$/.test(args.proposal_id))
|
|
1431
|
+
throw new Error(`invalid proposal_id "${args.proposal_id}"`);
|
|
1432
|
+
const response = await client.postJson(`/api/revisions/${encodeURIComponent(args.proposal_id)}/reject`, {}, exec.signal);
|
|
1433
|
+
const chapterId = String(objectValue(response)['chapter_id'] ?? '');
|
|
1434
|
+
try {
|
|
1435
|
+
return { ...objectValue(response), dog_delivery: await deliveryForResponse(client, response, chapterId, exec.signal) };
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
return { ...objectValue(response), dog_delivery: { status: 'unavailable', error: error instanceof Error ? error.message : String(error) } };
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
default: throw new Error(`novel_revision: unknown action ${String(args.action)}`);
|
|
1442
|
+
}
|
|
1525
1443
|
},
|
|
1526
1444
|
}));
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1445
|
+
// ── background tasks ─────────────────────────────────────────────────────
|
|
1446
|
+
// ── background tasks ──────────────────────────────────────────────────────────
|
|
1447
|
+
ctx.tools.register(defineTool({
|
|
1448
|
+
name: 'novel_task',
|
|
1449
|
+
description: 'Queue a background AI task and return immediately; poll with get or list. Task types and their ' +
|
|
1450
|
+
'`input` keys: chapter_write {chapter_id?, guidance?, target_words?, temperature?, outline_revision?}; ' +
|
|
1451
|
+
'chapter_review {chapter_id | path, strict?, dimensions?}; revision_selection / revision_from_review ' +
|
|
1452
|
+
'(same inputs as the novel_revision create action); source_operation / reference_operation (same ' +
|
|
1453
|
+
'inputs as novel_source_action / novel_reference_library_action); manuscript_import (same input as ' +
|
|
1454
|
+
'novel_import); research {prompt, search?, language?, quality? (advisory|balanced|strict), llm?}; manuscript_reconcile (durable ' +
|
|
1455
|
+
'acceptance analysis; usually started through novel_manuscript_acceptance_reconcile); settle_backfill ' +
|
|
1456
|
+
'{chapter_ids?, only_missing?} - prefer the novel_settle_backfill convenience tool; continuous_write ' +
|
|
1457
|
+
'- prefer the novel_multi_write convenience tool.',
|
|
1458
|
+
parameters: {
|
|
1459
|
+
action: { type: 'string', required: true, enum: ['create', 'get', 'list', 'cancel', 'confirm', 'retry'], description: 'Task operation; long-running work returns a task id immediately. confirm resumes a paused run and returns a continuation task: poll the returned task_id; re-confirming the paused task is idempotent.' },
|
|
1460
|
+
type: {
|
|
1461
|
+
type: 'string',
|
|
1462
|
+
enum: ['chapter_write', 'chapter_review', 'revision_selection', 'revision_from_review', 'source_operation', 'reference_operation', 'manuscript_import', 'manuscript_reconcile', 'continuous_write', 'research', 'settle_backfill'],
|
|
1463
|
+
description: 'Task type; required for create.',
|
|
1464
|
+
},
|
|
1465
|
+
input: { type: 'json', description: 'Task input object; required for create. Shape depends on the task type (see description).' },
|
|
1466
|
+
task_id: { type: 'string', description: 'Task id (tsk_*).' },
|
|
1467
|
+
since: { type: 'string', description: 'ISO timestamp filter for requests whose response was lost.' },
|
|
1468
|
+
limit: { type: 'integer', description: 'Maximum number of tasks (server default 100).' },
|
|
1469
|
+
wait_seconds: { type: 'number', description: 'Wait up to 30 seconds for actual cancellation; running model calls may finish later.' },
|
|
1532
1470
|
},
|
|
1533
1471
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1534
1472
|
timeoutMs,
|
|
1473
|
+
isConcurrencySafe: args => ['list'].includes(args.action),
|
|
1535
1474
|
async execute(args, exec) {
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1475
|
+
switch (args.action) {
|
|
1476
|
+
case 'create': {
|
|
1477
|
+
if (args.type === undefined)
|
|
1478
|
+
throw new Error('type is required for novel_task(action="create")');
|
|
1479
|
+
if (args.input === undefined)
|
|
1480
|
+
throw new Error('input is required for novel_task(action="create")');
|
|
1481
|
+
const client = clientFor(exec);
|
|
1482
|
+
if (typeof args.input !== 'object' || args.input === null || Array.isArray(args.input)) {
|
|
1483
|
+
throw new Error('input must be a JSON object');
|
|
1484
|
+
}
|
|
1485
|
+
return await client.postJson('/api/tasks', { type: args.type, input: args.input }, exec.signal);
|
|
1486
|
+
}
|
|
1487
|
+
case 'get': {
|
|
1488
|
+
if (args.task_id === undefined)
|
|
1489
|
+
throw new Error('task_id is required for novel_task(action="get")');
|
|
1490
|
+
const client = clientFor(exec);
|
|
1491
|
+
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1492
|
+
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1493
|
+
const response = await client.getJson(`/api/tasks/${encodeURIComponent(args.task_id)}`, {}, exec.signal);
|
|
1494
|
+
const responseRecord = response !== null && typeof response === 'object' && !Array.isArray(response)
|
|
1495
|
+
? response
|
|
1496
|
+
: {};
|
|
1497
|
+
const taskValue = responseRecord['task'] ?? response;
|
|
1498
|
+
const taskRecord = taskValue !== null && typeof taskValue === 'object' && !Array.isArray(taskValue)
|
|
1499
|
+
? taskValue
|
|
1500
|
+
: {};
|
|
1501
|
+
if (taskRecord['status'] !== 'completed')
|
|
1502
|
+
return response;
|
|
1503
|
+
const taskType = String(taskRecord['type'] ?? '');
|
|
1504
|
+
if (!['chapter_write', 'chapter_review', 'revision_from_review'].includes(taskType))
|
|
1505
|
+
return response;
|
|
1506
|
+
const taskResult = objectValue(taskRecord['result']);
|
|
1507
|
+
const taskInput = objectValue(taskRecord['input']);
|
|
1508
|
+
const chapterId = String(taskRecord['chapter_id'] ?? taskResult['chapter_id'] ?? '').trim()
|
|
1509
|
+
|| String(taskInput['path'] ?? '').match(/(?:^|\/)(ch_\d+)\.md$/)?.[1]
|
|
1510
|
+
|| '';
|
|
1511
|
+
const value = response !== null && typeof response === 'object' && !Array.isArray(response)
|
|
1512
|
+
? response
|
|
1513
|
+
: { task: response };
|
|
1514
|
+
if (!/^ch_\d+$/.test(chapterId)) {
|
|
1515
|
+
return { ...value, dog_delivery: { status: 'unavailable', error: 'completed chapter task lacks a resolvable chapter id' } };
|
|
1516
|
+
}
|
|
1517
|
+
let workspace;
|
|
1518
|
+
try {
|
|
1519
|
+
workspace = await client.getJson('/api/workspace', {}, exec.signal);
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
const unavailable = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1523
|
+
return taskType === 'chapter_review'
|
|
1524
|
+
? { ...value, dog_review: unavailable, dog_delivery: unavailable }
|
|
1525
|
+
: { ...value, dog_delivery: unavailable };
|
|
1526
|
+
}
|
|
1527
|
+
let dogDelivery;
|
|
1528
|
+
try {
|
|
1529
|
+
dogDelivery = await materializeChapterDelivery(workspace, chapterId, undefined, client.context?.workspaceRoot);
|
|
1530
|
+
}
|
|
1531
|
+
catch (error) {
|
|
1532
|
+
dogDelivery = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1533
|
+
}
|
|
1534
|
+
if (taskType !== 'chapter_review')
|
|
1535
|
+
return { ...value, dog_delivery: dogDelivery };
|
|
1536
|
+
let dogReview;
|
|
1537
|
+
try {
|
|
1538
|
+
const framework = await client.getJson('/api/review/framework', {}, exec.signal);
|
|
1539
|
+
// Pass the threshold through rather than hardcoding 70: the
|
|
1540
|
+
// manifest's threshold feeds deliveryStatus, so a caller who
|
|
1541
|
+
// asked for another value must not silently get the default.
|
|
1542
|
+
// `undefined` makes the materializer read it back from the
|
|
1543
|
+
// persisted delivery.json, exactly as the direct path does.
|
|
1544
|
+
dogReview = await materializeCompletedDogTaskReview(response, workspace, framework, undefined, client.context?.workspaceRoot) ?? { status: 'unavailable', error: 'review task was not materialized' };
|
|
1545
|
+
}
|
|
1546
|
+
catch (error) {
|
|
1547
|
+
dogReview = { status: 'unavailable', error: error instanceof Error ? error.message : String(error) };
|
|
1548
|
+
}
|
|
1549
|
+
return { ...value, dog_review: dogReview, dog_delivery: dogDelivery };
|
|
1550
|
+
}
|
|
1551
|
+
case 'list': {
|
|
1552
|
+
const client = clientFor(exec);
|
|
1553
|
+
const params = {};
|
|
1554
|
+
if (args.since !== undefined)
|
|
1555
|
+
params['since'] = args.since;
|
|
1556
|
+
if (args.limit !== undefined)
|
|
1557
|
+
params['limit'] = String(args.limit);
|
|
1558
|
+
return await client.getJson('/api/tasks', params, exec.signal);
|
|
1559
|
+
}
|
|
1560
|
+
case 'cancel': {
|
|
1561
|
+
if (args.task_id === undefined)
|
|
1562
|
+
throw new Error('task_id is required for novel_task(action="cancel")');
|
|
1563
|
+
const client = clientFor(exec);
|
|
1564
|
+
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1565
|
+
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1566
|
+
return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/cancel`, { wait_seconds: args.wait_seconds ?? 0 }, exec.signal);
|
|
1567
|
+
}
|
|
1568
|
+
case 'confirm': {
|
|
1569
|
+
if (args.task_id === undefined)
|
|
1570
|
+
throw new Error('task_id is required for novel_task(action="confirm")');
|
|
1571
|
+
const client = clientFor(exec);
|
|
1572
|
+
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1573
|
+
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1574
|
+
return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/confirm`, {}, exec.signal);
|
|
1575
|
+
}
|
|
1576
|
+
case 'retry': {
|
|
1577
|
+
if (args.task_id === undefined)
|
|
1578
|
+
throw new Error('task_id is required for novel_task(action="retry")');
|
|
1579
|
+
const client = clientFor(exec);
|
|
1580
|
+
if (!/^tsk_[A-Za-z0-9_-]+$/.test(args.task_id))
|
|
1581
|
+
throw new Error(`invalid task_id "${args.task_id}"`);
|
|
1582
|
+
return await client.postJson(`/api/tasks/${encodeURIComponent(args.task_id)}/retry`, {}, exec.signal);
|
|
1583
|
+
}
|
|
1584
|
+
default: throw new Error(`novel_task: unknown action ${String(args.action)}`);
|
|
1585
|
+
}
|
|
1540
1586
|
},
|
|
1541
1587
|
}));
|
|
1542
1588
|
ctx.tools.register(defineTool({
|
|
1543
1589
|
name: 'novel_multi_write',
|
|
1544
1590
|
description: 'Queue a BACKGROUND continuous-writing run: writes up to max_chapters chapters following the outline, ' +
|
|
1545
1591
|
'reviewing each; stops early on review blockers, continuity errors, low scores, or budget limits. ' +
|
|
1546
|
-
'Returns immediately with a task id — poll with
|
|
1592
|
+
'Returns immediately with a task id — poll with novel_task(action="get"). A paused run is resumed with novel_task(action="confirm"), which returns a continuation task: poll that returned task_id. Confirming the same paused task again is idempotent.',
|
|
1547
1593
|
parameters: {
|
|
1548
1594
|
max_chapters: { type: 'integer', description: 'Chapters to write, 1-10 (default 1).' },
|
|
1549
1595
|
minimum_review_score: { type: 'integer', description: 'Stop when a chapter scores below this, 0-100 (default 82).' },
|
|
@@ -1555,7 +1601,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1555
1601
|
temperature: { type: 'number', description: 'Sampling temperature (default 0.7).' },
|
|
1556
1602
|
stop_on_blocker: { type: 'boolean', description: 'Stop when a review reports a blocker issue (default true).' },
|
|
1557
1603
|
stop_on_continuity_error: { type: 'boolean', description: 'Stop on high-severity continuity issues (default true).' },
|
|
1558
|
-
require_confirmation_after_each_chapter: { type: 'boolean', description: 'Pause for
|
|
1604
|
+
require_confirmation_after_each_chapter: { type: 'boolean', description: 'Pause for novel_task(action="confirm") after each chapter (default false).' },
|
|
1559
1605
|
},
|
|
1560
1606
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1561
1607
|
timeoutMs,
|
|
@@ -1588,71 +1634,130 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1588
1634
|
},
|
|
1589
1635
|
}));
|
|
1590
1636
|
// ── project lifecycle ────────────────────────────────────────────────────
|
|
1637
|
+
// ── projects ──────────────────────────────────────────────────────────────────
|
|
1591
1638
|
ctx.tools.register(defineTool({
|
|
1592
|
-
name: '
|
|
1593
|
-
description: '
|
|
1594
|
-
'
|
|
1639
|
+
name: 'novel_project',
|
|
1640
|
+
description: 'Initialize, open and delete a novel project, and manage its archive packages (list, get, ' +
|
|
1641
|
+
'create, restore preview, restore, download). Archiving never touches the live project.',
|
|
1595
1642
|
parameters: {
|
|
1596
|
-
|
|
1597
|
-
|
|
1643
|
+
action: { type: 'string', required: true, enum: ['init', 'open', 'delete', 'archive', 'download'], description: 'Project operation.' },
|
|
1644
|
+
novel_id: { type: 'string', description: 'Project id, e.g. "my_novel" (2-64 chars: letters, digits, "-", "_").' },
|
|
1645
|
+
title: { type: 'string', description: 'Book title (max 120 characters).' },
|
|
1598
1646
|
author: { type: 'string', description: 'Author credit for delivery export (max 120 characters).' },
|
|
1599
1647
|
language: { type: 'string', description: 'Book language tag, e.g. zh-CN or en (default zh-CN).' },
|
|
1600
1648
|
project_path: { type: 'string', description: 'Target directory on the server; omit to use the server default location.' },
|
|
1601
1649
|
template: { type: 'string', enum: ['default', 'demo_short'], description: 'Project template (default "default").' },
|
|
1650
|
+
confirm_novel_id: { type: 'string', description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
|
|
1651
|
+
archive_op: { type: 'string', enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle operation.' },
|
|
1652
|
+
archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
|
|
1653
|
+
expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
|
|
1654
|
+
target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
|
|
1655
|
+
target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
|
|
1656
|
+
reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
|
|
1657
|
+
archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
|
|
1658
|
+
confirm: { type: 'boolean', description: 'Must be true for restore.' },
|
|
1602
1659
|
},
|
|
1603
1660
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1604
1661
|
timeoutMs,
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1662
|
+
isConcurrencySafe: () => false,
|
|
1663
|
+
async execute(args, exec) {
|
|
1664
|
+
switch (args.action) {
|
|
1665
|
+
case 'init': {
|
|
1666
|
+
if (args.novel_id === undefined)
|
|
1667
|
+
throw new Error('novel_id is required for novel_project(action="init")');
|
|
1668
|
+
if (args.title === undefined)
|
|
1669
|
+
throw new Error('title is required for novel_project(action="init")');
|
|
1670
|
+
const client = clientFor(exec);
|
|
1671
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(args.novel_id)) {
|
|
1672
|
+
throw new Error('novel_id must be 2-64 chars of letters, digits, "-" or "_"');
|
|
1673
|
+
}
|
|
1674
|
+
if (!args.title.trim() || args.title.length > 120)
|
|
1675
|
+
throw new Error('title must be non-empty and at most 120 characters');
|
|
1676
|
+
const body = { novel_id: args.novel_id, title: args.title };
|
|
1677
|
+
if (args.author !== undefined)
|
|
1678
|
+
body['author'] = args.author;
|
|
1679
|
+
if (args.language !== undefined)
|
|
1680
|
+
body['language'] = args.language;
|
|
1681
|
+
if (args.project_path !== undefined)
|
|
1682
|
+
body['project_path'] = args.project_path;
|
|
1683
|
+
if (args.template !== undefined)
|
|
1684
|
+
body['template'] = args.template;
|
|
1685
|
+
return await client.postJson('/api/project/init', body, exec.signal);
|
|
1686
|
+
}
|
|
1687
|
+
case 'open': {
|
|
1688
|
+
if (args.project_path === undefined)
|
|
1689
|
+
throw new Error('project_path is required for novel_project(action="open")');
|
|
1690
|
+
const client = clientFor(exec);
|
|
1691
|
+
if (!args.project_path.trim())
|
|
1692
|
+
throw new Error('project_path must be a non-empty string');
|
|
1693
|
+
return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
|
|
1694
|
+
}
|
|
1695
|
+
case 'delete': {
|
|
1696
|
+
if (args.project_path === undefined)
|
|
1697
|
+
throw new Error('project_path is required for novel_project(action="delete")');
|
|
1698
|
+
if (args.confirm_novel_id === undefined)
|
|
1699
|
+
throw new Error('confirm_novel_id is required for novel_project(action="delete")');
|
|
1700
|
+
const client = clientFor(exec);
|
|
1701
|
+
if (!args.project_path.trim() || !args.confirm_novel_id.trim())
|
|
1702
|
+
throw new Error('project_path and confirm are required');
|
|
1703
|
+
return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm_novel_id }, exec.signal);
|
|
1704
|
+
}
|
|
1705
|
+
case 'archive': {
|
|
1706
|
+
if (args.archive_op === undefined)
|
|
1707
|
+
throw new Error('archive_op is required for novel_project(action="archive")');
|
|
1708
|
+
const client = clientFor(exec);
|
|
1709
|
+
if (args.archive_op === 'preflight')
|
|
1710
|
+
return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
|
|
1711
|
+
if (args.archive_op === 'list')
|
|
1712
|
+
return await client.getJson('/api/project-archives', {}, exec.signal);
|
|
1713
|
+
if (args.archive_op === 'create') {
|
|
1714
|
+
if (!args.expected_preflight_revision?.trim())
|
|
1715
|
+
throw new Error('create requires expected_preflight_revision');
|
|
1716
|
+
return await client.postJson('/api/project-archives/create', {
|
|
1717
|
+
expected_preflight_revision: args.expected_preflight_revision,
|
|
1718
|
+
}, exec.signal);
|
|
1719
|
+
}
|
|
1720
|
+
const archiveId = args.archive_id?.trim();
|
|
1721
|
+
if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
|
|
1722
|
+
throw new Error(`${args.archive_op} requires a valid archive_id`);
|
|
1723
|
+
if (args.archive_op === 'get')
|
|
1724
|
+
return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
|
|
1725
|
+
if (!args.target_root?.trim())
|
|
1726
|
+
throw new Error(`${args.archive_op} requires target_root`);
|
|
1727
|
+
const body = {
|
|
1728
|
+
archive_id: archiveId,
|
|
1729
|
+
target_root: args.target_root,
|
|
1730
|
+
reference_policy: args.reference_policy ?? 'preserve_relative',
|
|
1731
|
+
};
|
|
1732
|
+
if (args.target_novel_id !== undefined)
|
|
1733
|
+
body['target_novel_id'] = args.target_novel_id;
|
|
1734
|
+
if (args.archive_op === 'restore_preview') {
|
|
1735
|
+
return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
|
|
1736
|
+
}
|
|
1737
|
+
if (!args.archive_sha256?.trim())
|
|
1738
|
+
throw new Error('restore requires archive_sha256');
|
|
1739
|
+
if (args.confirm !== true)
|
|
1740
|
+
throw new Error('restore requires confirm=true');
|
|
1741
|
+
body['archive_sha256'] = args.archive_sha256;
|
|
1742
|
+
body['confirm'] = true;
|
|
1743
|
+
return await client.postJson('/api/project-archives/restore', body, exec.signal);
|
|
1744
|
+
}
|
|
1745
|
+
case 'download': {
|
|
1746
|
+
if (args.archive_id === undefined)
|
|
1747
|
+
throw new Error('archive_id is required for novel_project(action="download")');
|
|
1748
|
+
if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
|
|
1749
|
+
throw new Error('archive_id must match owa_<24 lowercase hex>');
|
|
1750
|
+
const client = clientFor(exec);
|
|
1751
|
+
const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
|
|
1752
|
+
const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
|
|
1753
|
+
const exportDir = outputDirFor(outputDir, client);
|
|
1754
|
+
await mkdir(exportDir, { recursive: true });
|
|
1755
|
+
const path = join(exportDir, filename);
|
|
1756
|
+
await writeFile(path, download.content);
|
|
1757
|
+
return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
|
|
1758
|
+
}
|
|
1759
|
+
default: throw new Error(`novel_project: unknown action ${String(args.action)}`);
|
|
1609
1760
|
}
|
|
1610
|
-
if (!args.title.trim() || args.title.length > 120)
|
|
1611
|
-
throw new Error('title must be non-empty and at most 120 characters');
|
|
1612
|
-
const body = { novel_id: args.novel_id, title: args.title };
|
|
1613
|
-
if (args.author !== undefined)
|
|
1614
|
-
body['author'] = args.author;
|
|
1615
|
-
if (args.language !== undefined)
|
|
1616
|
-
body['language'] = args.language;
|
|
1617
|
-
if (args.project_path !== undefined)
|
|
1618
|
-
body['project_path'] = args.project_path;
|
|
1619
|
-
if (args.template !== undefined)
|
|
1620
|
-
body['template'] = args.template;
|
|
1621
|
-
return await client.postJson('/api/project/init', body, exec.signal);
|
|
1622
|
-
},
|
|
1623
|
-
}));
|
|
1624
|
-
ctx.tools.register(defineTool({
|
|
1625
|
-
name: 'novel_project_open',
|
|
1626
|
-
description: 'Switch the Studio server to another existing project directory (must contain novel_config.yaml). ' +
|
|
1627
|
-
'Works without a currently open project; afterwards every other novel_* tool targets the newly opened project. ' +
|
|
1628
|
-
'The server rejects the switch while a background task is active.',
|
|
1629
|
-
parameters: {
|
|
1630
|
-
project_path: { type: 'string', required: true, description: 'Absolute project directory path on the server machine.' },
|
|
1631
|
-
},
|
|
1632
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1633
|
-
timeoutMs,
|
|
1634
|
-
async execute(args, exec) {
|
|
1635
|
-
const client = clientFor(exec);
|
|
1636
|
-
if (!args.project_path.trim())
|
|
1637
|
-
throw new Error('project_path must be a non-empty string');
|
|
1638
|
-
return await client.postJson('/api/project/open', { project_path: args.project_path }, exec.signal);
|
|
1639
|
-
},
|
|
1640
|
-
}));
|
|
1641
|
-
ctx.tools.register(defineTool({
|
|
1642
|
-
name: 'novel_project_delete',
|
|
1643
|
-
description: 'PERMANENTLY delete a project directory and all its contents from the server machine (recursive delete, ' +
|
|
1644
|
-
'not recoverable). The server requires confirm to exactly equal the project\'s novel_id as a safeguard.',
|
|
1645
|
-
parameters: {
|
|
1646
|
-
project_path: { type: 'string', required: true, description: 'Project directory to delete.' },
|
|
1647
|
-
confirm: { type: 'string', required: true, description: 'Must exactly equal the project\'s novel_id (from its novel_config.yaml).' },
|
|
1648
|
-
},
|
|
1649
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1650
|
-
timeoutMs,
|
|
1651
|
-
async execute(args, exec) {
|
|
1652
|
-
const client = clientFor(exec);
|
|
1653
|
-
if (!args.project_path.trim() || !args.confirm.trim())
|
|
1654
|
-
throw new Error('project_path and confirm are required');
|
|
1655
|
-
return await client.postJson('/api/project/delete', { project_path: args.project_path, confirm: args.confirm }, exec.signal);
|
|
1656
1761
|
},
|
|
1657
1762
|
}));
|
|
1658
1763
|
// ── chapters, documents, import, sync ────────────────────────────────────
|
|
@@ -1660,12 +1765,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1660
1765
|
name: 'novel_chapter_delete',
|
|
1661
1766
|
description: 'Delete the LATEST manuscript chapter. The server enforces three safeguards: the path must be a chapter under ' +
|
|
1662
1767
|
'data/manuscript, confirm must exactly equal the chapter id (e.g. "ch_0007"), and version must equal the ' +
|
|
1663
|
-
'document version fingerprint from a fresh
|
|
1768
|
+
'document version fingerprint from a fresh novel_doc(action="read"). Only the newest chapter can be deleted, and never ' +
|
|
1664
1769
|
'while a background task is active.',
|
|
1665
1770
|
parameters: {
|
|
1666
1771
|
path: { type: 'string', required: true, description: 'Chapter path, e.g. "data/manuscript/ch_0007.md".' },
|
|
1667
1772
|
confirm: { type: 'string', required: true, description: 'Must exactly equal the chapter id, e.g. "ch_0007".' },
|
|
1668
|
-
version: { type: 'string', required: true, description: 'The document version fingerprint from
|
|
1773
|
+
version: { type: 'string', required: true, description: 'The document version fingerprint from novel_doc(action="read").' },
|
|
1669
1774
|
},
|
|
1670
1775
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1671
1776
|
timeoutMs,
|
|
@@ -1683,7 +1788,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1683
1788
|
parameters: {
|
|
1684
1789
|
keep_through: { type: 'string', required: true, description: 'Last chapter to retain, e.g. "ch_0030".' },
|
|
1685
1790
|
confirm: { type: 'string', required: true, description: 'Must exactly equal DELETE_AFTER:<keep_through>.' },
|
|
1686
|
-
version: { type: 'string', required: true, description: 'The latest chapter version fingerprint from a fresh
|
|
1791
|
+
version: { type: 'string', required: true, description: 'The latest chapter version fingerprint from a fresh novel_doc(action="read").' },
|
|
1687
1792
|
},
|
|
1688
1793
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1689
1794
|
timeoutMs,
|
|
@@ -1692,32 +1797,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1692
1797
|
return await client.postJson('/api/chapter/delete-batch', { keep_through: args.keep_through, confirm: args.confirm, version: args.version }, exec.signal);
|
|
1693
1798
|
},
|
|
1694
1799
|
}));
|
|
1800
|
+
// ── manuscript import ─────────────────────────────────────────────────────────
|
|
1695
1801
|
ctx.tools.register(defineTool({
|
|
1696
|
-
name: '
|
|
1697
|
-
description: '
|
|
1698
|
-
|
|
1699
|
-
kind: { type: 'string', required: true, description: 'Document kind (e.g. "setting", "character").' },
|
|
1700
|
-
name: { type: 'string', required: true, description: 'Document name.' },
|
|
1701
|
-
description: { type: 'string', description: 'Short description of the document.' },
|
|
1702
|
-
},
|
|
1703
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1704
|
-
timeoutMs,
|
|
1705
|
-
async execute(args, exec) {
|
|
1706
|
-
const client = clientFor(exec);
|
|
1707
|
-
if (!args.kind.trim() || !args.name.trim())
|
|
1708
|
-
throw new Error('kind and name must be non-empty strings');
|
|
1709
|
-
const body = { kind: args.kind, name: args.name };
|
|
1710
|
-
if (args.description !== undefined)
|
|
1711
|
-
body['description'] = args.description;
|
|
1712
|
-
return await client.postJson('/api/document/create', body, exec.signal);
|
|
1713
|
-
},
|
|
1714
|
-
}));
|
|
1715
|
-
ctx.tools.register(defineTool({
|
|
1716
|
-
name: 'novel_import_preview',
|
|
1717
|
-
description: 'Preview a TXT/Markdown manuscript import without writing anything: detected chapters, numbering, and ' +
|
|
1718
|
-
'conflicts with existing chapters. Run this before novel_import.',
|
|
1802
|
+
name: 'novel_import',
|
|
1803
|
+
description: 'Preview an imported manuscript and then import it. Preview reports the parsed chapters, arc ' +
|
|
1804
|
+
'mapping and conflicts without writing; import applies the reviewed plan.',
|
|
1719
1805
|
parameters: {
|
|
1720
|
-
|
|
1806
|
+
action: { type: 'string', required: true, enum: ['preview', 'import'], description: 'Import step; preview never writes anything.' },
|
|
1807
|
+
content: { type: 'string', description: 'The manuscript text to import.' },
|
|
1721
1808
|
filename: { type: 'string', description: 'Source filename; suffix must be .txt/.md/.markdown (default "import.md").' },
|
|
1722
1809
|
arc_id: { type: 'string', description: 'Arc id like "arc_001" (default: the project\'s current arc).' },
|
|
1723
1810
|
start_number: { type: 'integer', description: 'First chapter number (default: after the last existing chapter).' },
|
|
@@ -1725,50 +1812,45 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1725
1812
|
},
|
|
1726
1813
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1727
1814
|
timeoutMs,
|
|
1728
|
-
isConcurrencySafe:
|
|
1729
|
-
async execute(args, exec) {
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
if (args.start_number !== undefined)
|
|
1768
|
-
body['start_number'] = args.start_number;
|
|
1769
|
-
if (args.force !== undefined)
|
|
1770
|
-
body['force'] = args.force;
|
|
1771
|
-
return await client.postJson('/api/import', body, exec.signal);
|
|
1815
|
+
isConcurrencySafe: args => ['preview'].includes(args.action),
|
|
1816
|
+
async execute(args, exec) {
|
|
1817
|
+
switch (args.action) {
|
|
1818
|
+
case 'preview': {
|
|
1819
|
+
if (args.content === undefined)
|
|
1820
|
+
throw new Error('content is required for novel_import(action="preview")');
|
|
1821
|
+
const client = clientFor(exec);
|
|
1822
|
+
if (!args.content.trim())
|
|
1823
|
+
throw new Error('content must be a non-empty string');
|
|
1824
|
+
const body = { content: args.content };
|
|
1825
|
+
if (args.filename !== undefined)
|
|
1826
|
+
body['filename'] = args.filename;
|
|
1827
|
+
if (args.arc_id !== undefined)
|
|
1828
|
+
body['arc_id'] = args.arc_id;
|
|
1829
|
+
if (args.start_number !== undefined)
|
|
1830
|
+
body['start_number'] = args.start_number;
|
|
1831
|
+
if (args.force !== undefined)
|
|
1832
|
+
body['force'] = args.force;
|
|
1833
|
+
return await client.postJson('/api/import/preview', body, exec.signal);
|
|
1834
|
+
}
|
|
1835
|
+
case 'import': {
|
|
1836
|
+
if (args.content === undefined)
|
|
1837
|
+
throw new Error('content is required for novel_import(action="import")');
|
|
1838
|
+
const client = clientFor(exec);
|
|
1839
|
+
if (!args.content.trim())
|
|
1840
|
+
throw new Error('content must be a non-empty string');
|
|
1841
|
+
const body = { content: args.content };
|
|
1842
|
+
if (args.filename !== undefined)
|
|
1843
|
+
body['filename'] = args.filename;
|
|
1844
|
+
if (args.arc_id !== undefined)
|
|
1845
|
+
body['arc_id'] = args.arc_id;
|
|
1846
|
+
if (args.start_number !== undefined)
|
|
1847
|
+
body['start_number'] = args.start_number;
|
|
1848
|
+
if (args.force !== undefined)
|
|
1849
|
+
body['force'] = args.force;
|
|
1850
|
+
return await client.postJson('/api/import', body, exec.signal);
|
|
1851
|
+
}
|
|
1852
|
+
default: throw new Error(`novel_import: unknown action ${String(args.action)}`);
|
|
1853
|
+
}
|
|
1772
1854
|
},
|
|
1773
1855
|
}));
|
|
1774
1856
|
ctx.tools.register(defineTool({
|
|
@@ -1784,7 +1866,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1784
1866
|
limit: { type: 'integer', description: 'Maximum operations for list (default 50).' },
|
|
1785
1867
|
filename: { type: 'string', description: 'Source filename ending in .txt, .md, or .markdown.' },
|
|
1786
1868
|
content: { type: 'string', description: 'The author\'s own manuscript text for prepare.' },
|
|
1787
|
-
arc_id: { type: 'string', description: 'Target arc id, e.g. arc_001.' },
|
|
1869
|
+
arc_id: { type: 'string', description: 'Target arc id, e.g. arc_001. Optional for prepare; defaults to the project\'s current arc.' },
|
|
1788
1870
|
start_number: { type: 'integer', description: 'Optional first chapter number.' },
|
|
1789
1871
|
expected_preview_revision: { type: 'string', description: 'Current preview revision required by structure/confirm.' },
|
|
1790
1872
|
chapters: { type: 'array', items: { type: 'json' }, description: 'Edited chapter objects: chapter_id, title, content.' },
|
|
@@ -1801,9 +1883,12 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1801
1883
|
if (args.action === 'prepare') {
|
|
1802
1884
|
if (!args.content?.trim())
|
|
1803
1885
|
throw new Error('prepare requires non-empty content');
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1886
|
+
// The server resolves the project's current arc when arc_id is absent,
|
|
1887
|
+
// exactly like the sibling novel_import tool. Demanding it here only
|
|
1888
|
+
// cost the agent a round trip.
|
|
1889
|
+
const body = { filename: args.filename ?? 'import.md', content: args.content };
|
|
1890
|
+
if (args.arc_id !== undefined)
|
|
1891
|
+
body['arc_id'] = args.arc_id;
|
|
1807
1892
|
if (args.start_number !== undefined)
|
|
1808
1893
|
body['start_number'] = args.start_number;
|
|
1809
1894
|
return await client.postJson('/api/manuscript-imports/prepare', body, exec.signal);
|
|
@@ -1843,95 +1928,6 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1843
1928
|
return await client.postJson('/api/manuscript-imports/discard', { import_id: importId, confirm: true }, exec.signal);
|
|
1844
1929
|
},
|
|
1845
1930
|
}));
|
|
1846
|
-
ctx.tools.register(defineTool({
|
|
1847
|
-
name: 'novel_project_archive_action',
|
|
1848
|
-
description: 'Inspect, create, and restore complete .owarchive project archives. Use preflight before create; use ' +
|
|
1849
|
-
'restore_preview before restore to inspect path/reference rewrites, conflicts, warnings, checksum, and the ' +
|
|
1850
|
-
'archived-without-auto-resume task policy. Restore requires the preview archive_sha256 and explicit confirmation.',
|
|
1851
|
-
parameters: {
|
|
1852
|
-
action: { type: 'string', required: true, enum: ['preflight', 'list', 'get', 'create', 'restore_preview', 'restore'], description: 'Archive lifecycle action.' },
|
|
1853
|
-
archive_id: { type: 'string', description: 'Archive id shaped owa_<24 lowercase hex>.' },
|
|
1854
|
-
expected_preflight_revision: { type: 'string', description: 'Current archive preflight revision for create.' },
|
|
1855
|
-
target_root: { type: 'string', description: 'Absolute new or empty project directory for restore.' },
|
|
1856
|
-
target_novel_id: { type: 'string', description: 'Optional restored novel id.' },
|
|
1857
|
-
reference_policy: { type: 'string', enum: ['preserve_relative', 'rewrite_novel_id'], description: 'Reference handling during restore.' },
|
|
1858
|
-
archive_sha256: { type: 'string', description: 'Checksum returned by restore_preview.' },
|
|
1859
|
-
confirm: { type: 'boolean', description: 'Must be true for restore.' },
|
|
1860
|
-
},
|
|
1861
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
1862
|
-
timeoutMs,
|
|
1863
|
-
async execute(args, exec) {
|
|
1864
|
-
const client = clientFor(exec);
|
|
1865
|
-
if (args.action === 'preflight')
|
|
1866
|
-
return await client.getJson('/api/project-archives/preflight', {}, exec.signal);
|
|
1867
|
-
if (args.action === 'list')
|
|
1868
|
-
return await client.getJson('/api/project-archives', {}, exec.signal);
|
|
1869
|
-
if (args.action === 'create') {
|
|
1870
|
-
if (!args.expected_preflight_revision?.trim())
|
|
1871
|
-
throw new Error('create requires expected_preflight_revision');
|
|
1872
|
-
return await client.postJson('/api/project-archives/create', {
|
|
1873
|
-
expected_preflight_revision: args.expected_preflight_revision,
|
|
1874
|
-
}, exec.signal);
|
|
1875
|
-
}
|
|
1876
|
-
const archiveId = args.archive_id?.trim();
|
|
1877
|
-
if (!archiveId || !/^owa_[0-9a-f]{24}$/.test(archiveId))
|
|
1878
|
-
throw new Error(`${args.action} requires a valid archive_id`);
|
|
1879
|
-
if (args.action === 'get')
|
|
1880
|
-
return await client.getJson(`/api/project-archives/${archiveId}`, {}, exec.signal);
|
|
1881
|
-
if (!args.target_root?.trim())
|
|
1882
|
-
throw new Error(`${args.action} requires target_root`);
|
|
1883
|
-
const body = {
|
|
1884
|
-
archive_id: archiveId,
|
|
1885
|
-
target_root: args.target_root,
|
|
1886
|
-
reference_policy: args.reference_policy ?? 'preserve_relative',
|
|
1887
|
-
};
|
|
1888
|
-
if (args.target_novel_id !== undefined)
|
|
1889
|
-
body['target_novel_id'] = args.target_novel_id;
|
|
1890
|
-
if (args.action === 'restore_preview') {
|
|
1891
|
-
return await client.postJson('/api/project-archives/restore/preview', body, exec.signal);
|
|
1892
|
-
}
|
|
1893
|
-
if (!args.archive_sha256?.trim())
|
|
1894
|
-
throw new Error('restore requires archive_sha256');
|
|
1895
|
-
if (args.confirm !== true)
|
|
1896
|
-
throw new Error('restore requires confirm=true');
|
|
1897
|
-
body['archive_sha256'] = args.archive_sha256;
|
|
1898
|
-
body['confirm'] = true;
|
|
1899
|
-
return await client.postJson('/api/project-archives/restore', body, exec.signal);
|
|
1900
|
-
},
|
|
1901
|
-
}));
|
|
1902
|
-
ctx.tools.register(defineTool({
|
|
1903
|
-
name: 'novel_project_archive_download',
|
|
1904
|
-
description: 'Download one complete .owarchive project archive and save it in this workspace\'s local export directory.',
|
|
1905
|
-
parameters: {
|
|
1906
|
-
archive_id: { type: 'string', required: true, description: 'Archive id shaped owa_<24 lowercase hex>.' },
|
|
1907
|
-
},
|
|
1908
|
-
output: {
|
|
1909
|
-
schema: {
|
|
1910
|
-
type: 'object',
|
|
1911
|
-
additionalProperties: false,
|
|
1912
|
-
properties: {
|
|
1913
|
-
path: { type: 'string', required: true },
|
|
1914
|
-
filename: { type: 'string', required: true },
|
|
1915
|
-
archive_id: { type: 'string', required: true },
|
|
1916
|
-
bytes: { type: 'integer', required: true },
|
|
1917
|
-
},
|
|
1918
|
-
},
|
|
1919
|
-
render: (_args, value) => [{ type: 'text', text: `Downloaded ${value.filename} (${value.bytes} bytes) to ${value.path}` }],
|
|
1920
|
-
},
|
|
1921
|
-
timeoutMs,
|
|
1922
|
-
async execute(args, exec) {
|
|
1923
|
-
if (!/^owa_[0-9a-f]{24}$/.test(args.archive_id))
|
|
1924
|
-
throw new Error('archive_id must match owa_<24 lowercase hex>');
|
|
1925
|
-
const client = clientFor(exec);
|
|
1926
|
-
const download = await client.download(`/api/project-archives/${args.archive_id}/download`, {}, exec.signal);
|
|
1927
|
-
const filename = exportFilename(download.filename, `${args.archive_id}.owarchive.zip`);
|
|
1928
|
-
const exportDir = outputDirFor(outputDir, client);
|
|
1929
|
-
await mkdir(exportDir, { recursive: true });
|
|
1930
|
-
const path = join(exportDir, filename);
|
|
1931
|
-
await writeFile(path, download.content);
|
|
1932
|
-
return { path, filename, archive_id: args.archive_id, bytes: download.content.byteLength };
|
|
1933
|
-
},
|
|
1934
|
-
}));
|
|
1935
1931
|
ctx.tools.register(defineTool({
|
|
1936
1932
|
name: 'novel_sync',
|
|
1937
1933
|
description: 'Synchronize derived caches that are direct src derivatives: outline hierarchy (data/hierarchy.yaml) and ' +
|
|
@@ -1952,7 +1948,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1952
1948
|
'actual chapter prose (never rewriting it) and commit chapter memory, runtime-truth notes and a ' +
|
|
1953
1949
|
'character-state index refresh. Run once after importing an old manuscript so later chapters, reviews and ' +
|
|
1954
1950
|
'canon/continuity checks have real facts from earlier chapters (without it, review canon stays inconclusive ' +
|
|
1955
|
-
'and context lacks previous-chapter recaps). Returns a background task (tsk_*); poll with
|
|
1951
|
+
'and context lacks previous-chapter recaps). Returns a background task (tsk_*); poll with novel_task(action="get").',
|
|
1956
1952
|
parameters: {
|
|
1957
1953
|
chapter_ids: {
|
|
1958
1954
|
type: 'array',
|
|
@@ -1976,7 +1972,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
1976
1972
|
ctx.tools.register(defineTool({
|
|
1977
1973
|
name: 'novel_writing_targets',
|
|
1978
1974
|
description: 'Immediate compatibility write for targets the author has already authorized exactly; otherwise use ' +
|
|
1979
|
-
'
|
|
1975
|
+
'novel_change_plan(action="structured"). Update the project writing targets. Ranges: book_words 10000-5000000, chapter_words 200-50000, ' +
|
|
1980
1976
|
'outline_volume_words 100-5000, outline_act_words 80-3000, outline_section_words 50-2000, ' +
|
|
1981
1977
|
'outline_chapter_words 30-1000. Omitted fields keep their current values.',
|
|
1982
1978
|
parameters: {
|
|
@@ -2089,9 +2085,11 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2089
2085
|
description: 'Manage rolling plan candidates (mid-range plot planning windows). Actions: "list" (limit?), "create" ' +
|
|
2090
2086
|
'(current_arc?, window_size? default 50, max 50). current_window/accepted_window is accepted manuscript ' +
|
|
2091
2087
|
'facts only; next_window/planned_window is outline plan, not facts. "get" (candidate_id), "stage" ' +
|
|
2092
|
-
'(candidate_id + proposal + revision — stages a plan
|
|
2088
|
+
'(candidate_id + proposal + revision — stages a Markdown plan; chapter headings should read "第N章:标题" ' +
|
|
2089
|
+
'or "第N章 标题" and Chinese numerals are accepted; revision-gated), ' +
|
|
2093
2090
|
'"delete" (candidate_id + revision), "apply" (candidate_id + revision — appends staged draft chapters ' +
|
|
2094
|
-
'that do not collide with the canonical outline into src/outline.md; never writes accepted facts
|
|
2091
|
+
'that do not collide with the canonical outline into src/outline.md; never writes accepted facts; ' +
|
|
2092
|
+
'a proposal without recognizable chapter headings is reported as such instead of as "already exists").',
|
|
2095
2093
|
parameters: {
|
|
2096
2094
|
action: { type: 'string', required: true, enum: ['list', 'create', 'get', 'stage', 'delete', 'apply'], description: 'The rolling-plan operation.' },
|
|
2097
2095
|
limit: { type: 'integer', description: 'List limit (default 20).' },
|
|
@@ -2155,7 +2153,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2155
2153
|
'"compare" (chapter_id + version_id — preview current-to-snapshot differences without writing), ' +
|
|
2156
2154
|
'"restore" (chapter_id + version_id + revision — REVERTS the chapter to a snapshot; revision-gated and ' +
|
|
2157
2155
|
'confirmation-gated: the server demands confirm: true), "annotations" (chapter_id), "annotate" (chapter_id + ' +
|
|
2158
|
-
'revision + quote + note, optional start_hint/end_hint and color amber|rose|sky|lime|violet), ' +
|
|
2156
|
+
'revision + quote + note, optional start_hint/end_hint (derived from the quote when omitted) and color amber|rose|sky|lime|violet), ' +
|
|
2159
2157
|
'"resolve_annotation" (chapter_id + annotation_id).',
|
|
2160
2158
|
parameters: {
|
|
2161
2159
|
action: { type: 'string', required: true, enum: ['drafts', 'restore_draft', 'versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
|
|
@@ -2182,18 +2180,52 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2182
2180
|
if (args.action === 'restore_draft') {
|
|
2183
2181
|
if (!args.run_id_v2)
|
|
2184
2182
|
throw new Error('run_id_v2 is required; list drafts first');
|
|
2183
|
+
// A blank expected_revision is not "no guard": the server computes
|
|
2184
|
+
// `expected = ""` when the chapter file is absent, so `'' != ''` is
|
|
2185
|
+
// false and the revision check is silently satisfied. Sending
|
|
2186
|
+
// `confirm: false` likewise guarantees CONFIRMATION_REQUIRED.
|
|
2187
|
+
if (args.revision === undefined || !String(args.revision).trim()) {
|
|
2188
|
+
throw new Error('revision is required for novel_manuscript_edit_action(action="restore_draft"); read the current chapter revision first');
|
|
2189
|
+
}
|
|
2190
|
+
if (args.confirm !== true) {
|
|
2191
|
+
throw new Error('confirm: true is required for novel_manuscript_edit_action(action="restore_draft")');
|
|
2192
|
+
}
|
|
2185
2193
|
return await client.postJson('/api/manuscript/drafts/restore', {
|
|
2186
|
-
run_id_v2: args.run_id_v2, expected_revision: args.revision
|
|
2194
|
+
run_id_v2: args.run_id_v2, expected_revision: args.revision, confirm: true,
|
|
2187
2195
|
}, exec.signal);
|
|
2188
2196
|
}
|
|
2189
2197
|
if (!args.chapter_id || !CHAPTER_ID_PATTERN.test(args.chapter_id))
|
|
2190
2198
|
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
2199
|
+
// An empty quote selects `content[0:0]`, which trivially "matches", so the
|
|
2200
|
+
// server would persist a junk annotation and consume the chapter revision.
|
|
2201
|
+
if (args.action === 'annotate') {
|
|
2202
|
+
if (!args.quote?.trim())
|
|
2203
|
+
throw new Error('quote is required for novel_manuscript_edit_action(action="annotate")');
|
|
2204
|
+
if (!args.note?.trim())
|
|
2205
|
+
throw new Error('note is required for novel_manuscript_edit_action(action="annotate")');
|
|
2206
|
+
if (args.revision === undefined || !String(args.revision).trim()) {
|
|
2207
|
+
throw new Error('revision is required for novel_manuscript_edit_action(action="annotate")');
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2191
2210
|
const body = { action: args.action, chapter_id: args.chapter_id };
|
|
2192
|
-
|
|
2211
|
+
let startHint = args.start_hint;
|
|
2212
|
+
let endHint = args.end_hint;
|
|
2213
|
+
if (args.action === 'annotate' && (startHint === undefined || endHint === undefined)) {
|
|
2214
|
+
// The server matches content[start_hint:end_hint] literally against the
|
|
2215
|
+
// quote, so optional hints must still produce exact offsets.
|
|
2216
|
+
const derived = await resolveAnnotationHints(client, args.chapter_id, args.quote, exec.signal);
|
|
2217
|
+
startHint = derived.start;
|
|
2218
|
+
endHint = derived.end;
|
|
2219
|
+
}
|
|
2220
|
+
for (const key of ['version_id', 'label', 'revision', 'confirm', 'quote', 'note', 'color', 'annotation_id']) {
|
|
2193
2221
|
const value = args[key];
|
|
2194
2222
|
if (value !== undefined)
|
|
2195
2223
|
body[key] = value;
|
|
2196
2224
|
}
|
|
2225
|
+
if (startHint !== undefined)
|
|
2226
|
+
body['start_hint'] = startHint;
|
|
2227
|
+
if (endHint !== undefined)
|
|
2228
|
+
body['end_hint'] = endHint;
|
|
2197
2229
|
return await client.postJson('/api/manuscript-editing', body, exec.signal);
|
|
2198
2230
|
},
|
|
2199
2231
|
}));
|
|
@@ -2216,15 +2248,22 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2216
2248
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2217
2249
|
timeoutMs,
|
|
2218
2250
|
async execute(args, exec) {
|
|
2219
|
-
|
|
2251
|
+
// An empty chapter id is the project-level operation, not a malformed
|
|
2252
|
+
// one: the server resolves it to the first chapter and covers the book.
|
|
2253
|
+
// Rejecting "" here made the documented project-level call unreachable
|
|
2254
|
+
// for any caller that passes the field as an empty string.
|
|
2255
|
+
const chapterId = args.chapter_id?.trim();
|
|
2256
|
+
if (chapterId !== undefined && chapterId !== '' && !CHAPTER_ID_PATTERN.test(chapterId)) {
|
|
2220
2257
|
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
2221
2258
|
}
|
|
2222
2259
|
const body = {};
|
|
2223
|
-
for (const key of ['
|
|
2260
|
+
for (const key of ['operation_id', 'domains', 'confirm', 'mode', 'no_model']) {
|
|
2224
2261
|
const value = args[key];
|
|
2225
2262
|
if (value !== undefined)
|
|
2226
2263
|
body[key] = value;
|
|
2227
2264
|
}
|
|
2265
|
+
if (chapterId)
|
|
2266
|
+
body['chapter_id'] = chapterId;
|
|
2228
2267
|
if (args.action === 'acknowledge' && args.domains === undefined)
|
|
2229
2268
|
body['domains'] = ['outline', 'foreshadowing'];
|
|
2230
2269
|
const route = args.action === 'acknowledge' ? 'ack' : args.action === 'resume' ? 'reconcile' : args.action;
|
|
@@ -2250,8 +2289,19 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2250
2289
|
const client = clientFor(exec);
|
|
2251
2290
|
if (args.action === 'get')
|
|
2252
2291
|
return await client.getJson('/api/writing-assets', {}, exec.signal);
|
|
2253
|
-
if (args.action === 'save_anchor')
|
|
2254
|
-
|
|
2292
|
+
if (args.action === 'save_anchor') {
|
|
2293
|
+
// A blank name/content reaches the server as INVALID_WRITING_ASSETS
|
|
2294
|
+
// (400), which is also what a stale expected_revision answers — the two
|
|
2295
|
+
// are indistinguishable by code. Validate the payload locally so the
|
|
2296
|
+
// caller is told which parameter is actually wrong.
|
|
2297
|
+
if (!args.name?.trim())
|
|
2298
|
+
throw new Error('name is required for novel_writing_assets(action="save_anchor")');
|
|
2299
|
+
if (!/^[\w-]{1,80}\.md$/.test(args.name))
|
|
2300
|
+
throw new Error(`save_anchor name must be a simple .md filename, got "${args.name}"`);
|
|
2301
|
+
if (!args.content?.trim())
|
|
2302
|
+
throw new Error('content is required for novel_writing_assets(action="save_anchor")');
|
|
2303
|
+
return await client.postJson('/api/writing-assets', { action: args.action, name: args.name, content: args.content, expected_revision: args.expected_revision ?? '' }, exec.signal);
|
|
2304
|
+
}
|
|
2255
2305
|
if (args.action === 'mark_usage')
|
|
2256
2306
|
return await client.postJson('/api/writing-assets', { action: args.action, chapter_id: args.chapter_id ?? '', groups: requireRecord(args.groups, 'groups') }, exec.signal);
|
|
2257
2307
|
const assets = requireRecord(args.assets, 'assets');
|
|
@@ -2333,7 +2383,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2333
2383
|
chunk_id: { type: 'string', description: 'Chunk to retry (retry_v2).' },
|
|
2334
2384
|
source_ids: { type: 'array', items: { type: 'string' }, description: 'Sources to synthesize (synthesize_v2).' },
|
|
2335
2385
|
profile_id: { type: 'string', description: 'Analysis profile id (profile_v2/promotion_preview_v2).' },
|
|
2336
|
-
target: { type: 'string', description: 'Promotion target (
|
|
2386
|
+
target: { type: 'string', description: 'Promotion target: style/rules/inspiration/setting_candidates for promotion_preview_v2 (required); style/setting/world/all for legacy promote (default "all").' },
|
|
2337
2387
|
preview_id: { type: 'string', description: 'Promotion preview id (promote_v2).' },
|
|
2338
2388
|
confirm: { type: 'boolean', description: 'Confirm a gated promotion (default false).' },
|
|
2339
2389
|
},
|
|
@@ -2342,6 +2392,15 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2342
2392
|
isConcurrencySafe: (args) => args.action === 'status_v2' || args.action === 'profile_v2' || args.action === 'promotion_preview_v2',
|
|
2343
2393
|
async execute(args, exec) {
|
|
2344
2394
|
const client = clientFor(exec);
|
|
2395
|
+
if (args.action === 'promotion_preview_v2') {
|
|
2396
|
+
const target = args.target?.trim();
|
|
2397
|
+
if (!target) {
|
|
2398
|
+
throw new Error('target is required for novel_source_action(action="promotion_preview_v2"); choose style, rules, inspiration, or setting_candidates');
|
|
2399
|
+
}
|
|
2400
|
+
if (!['style', 'rules', 'inspiration', 'setting_candidates'].includes(target)) {
|
|
2401
|
+
throw new Error('target must be style, rules, inspiration, or setting_candidates for promotion_preview_v2');
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2345
2404
|
const body = { action: args.action };
|
|
2346
2405
|
for (const key of ['source_id', 'content', 'focus', 'relative_name', 'input_budget_tokens', 'chunk_id', 'source_ids', 'profile_id', 'target', 'preview_id', 'confirm']) {
|
|
2347
2406
|
const value = args[key];
|
|
@@ -2379,7 +2438,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2379
2438
|
},
|
|
2380
2439
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2381
2440
|
timeoutMs,
|
|
2382
|
-
isConcurrencySafe: (args) => args.action === 'list' || args.action === 'status' || args.action === 'profile'
|
|
2441
|
+
isConcurrencySafe: (args) => args.action === 'list' || args.action === 'status' || args.action === 'profile',
|
|
2383
2442
|
async execute(args, exec) {
|
|
2384
2443
|
const client = clientFor(exec);
|
|
2385
2444
|
const body = { action: args.action };
|
|
@@ -2444,7 +2503,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2444
2503
|
// ── deep research ────────────────────────────────────────────────────────
|
|
2445
2504
|
ctx.tools.register(defineTool({
|
|
2446
2505
|
name: 'novel_research_status',
|
|
2447
|
-
description: 'Get the deep-research surface: settings status, report list, and the configured model route. Run a research job via
|
|
2506
|
+
description: 'Get the deep-research surface: settings status, report list, and the configured model route. Run a research job via novel_task(action="create") with type "research".',
|
|
2448
2507
|
parameters: {},
|
|
2449
2508
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2450
2509
|
timeoutMs,
|
|
@@ -2487,16 +2546,100 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2487
2546
|
},
|
|
2488
2547
|
}));
|
|
2489
2548
|
// ── model configuration ──────────────────────────────────────────────────
|
|
2549
|
+
// ── model configuration ───────────────────────────────────────────────────────
|
|
2490
2550
|
ctx.tools.register(defineTool({
|
|
2491
2551
|
name: 'novel_model_profiles',
|
|
2492
|
-
description: 'List
|
|
2493
|
-
|
|
2552
|
+
description: 'List, save, delete and test OpenWrite model profiles, per-task routing and the active model ' +
|
|
2553
|
+
'settings. Profiles select which provider/model each task uses; routes map tasks to profiles.',
|
|
2554
|
+
parameters: {
|
|
2555
|
+
action: { type: 'string', required: true, enum: ['list', 'save', 'delete', 'save_routes', 'configure', 'test'], description: 'Model configuration operation.' },
|
|
2556
|
+
profile: { type: 'json', description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
|
|
2557
|
+
api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
|
|
2558
|
+
remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
|
|
2559
|
+
profile_id: { type: 'string', description: 'Profile id to delete.' },
|
|
2560
|
+
fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
|
|
2561
|
+
routes: { type: 'json', description: 'Object mapping operation names to profile ids.' },
|
|
2562
|
+
provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
|
|
2563
|
+
model: { type: 'string', description: 'Model name (max 120 characters).' },
|
|
2564
|
+
base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
|
|
2565
|
+
api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
|
|
2566
|
+
context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
|
|
2567
|
+
max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
|
|
2568
|
+
id: { type: 'string', description: 'Stored profile id to test.' },
|
|
2569
|
+
},
|
|
2494
2570
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2495
2571
|
timeoutMs,
|
|
2496
|
-
isConcurrencySafe:
|
|
2497
|
-
async execute(
|
|
2498
|
-
|
|
2499
|
-
|
|
2572
|
+
isConcurrencySafe: args => ['list', 'test'].includes(args.action),
|
|
2573
|
+
async execute(args, exec) {
|
|
2574
|
+
switch (args.action) {
|
|
2575
|
+
case 'list': {
|
|
2576
|
+
const client = clientFor(exec);
|
|
2577
|
+
return await client.getJson('/api/model/profiles', {}, exec.signal);
|
|
2578
|
+
}
|
|
2579
|
+
case 'save': {
|
|
2580
|
+
if (args.profile === undefined)
|
|
2581
|
+
throw new Error('profile is required for novel_model_profiles(action="save")');
|
|
2582
|
+
const client = clientFor(exec);
|
|
2583
|
+
if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
|
|
2584
|
+
throw new Error('profile must be a JSON object');
|
|
2585
|
+
}
|
|
2586
|
+
const body = { ...args.profile };
|
|
2587
|
+
for (const key of ['api_key', 'remember_api_key']) {
|
|
2588
|
+
const value = args[key];
|
|
2589
|
+
if (value !== undefined)
|
|
2590
|
+
body[key] = value;
|
|
2591
|
+
}
|
|
2592
|
+
return await client.postJson('/api/model/profiles', body, exec.signal);
|
|
2593
|
+
}
|
|
2594
|
+
case 'delete': {
|
|
2595
|
+
if (args.profile_id === undefined)
|
|
2596
|
+
throw new Error('profile_id is required for novel_model_profiles(action="delete")');
|
|
2597
|
+
const client = clientFor(exec);
|
|
2598
|
+
if (!args.profile_id.trim())
|
|
2599
|
+
throw new Error('profile_id must be a non-empty string');
|
|
2600
|
+
const body = { profile_id: args.profile_id };
|
|
2601
|
+
if (args.fallback_id !== undefined)
|
|
2602
|
+
body['fallback_id'] = args.fallback_id;
|
|
2603
|
+
return await client.postJson('/api/model/profiles/delete', body, exec.signal);
|
|
2604
|
+
}
|
|
2605
|
+
case 'save_routes': {
|
|
2606
|
+
if (args.routes === undefined)
|
|
2607
|
+
throw new Error('routes is required for novel_model_profiles(action="save_routes")');
|
|
2608
|
+
const client = clientFor(exec);
|
|
2609
|
+
if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
|
|
2610
|
+
throw new Error('routes must be a JSON object');
|
|
2611
|
+
}
|
|
2612
|
+
return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
|
|
2613
|
+
}
|
|
2614
|
+
case 'configure': {
|
|
2615
|
+
if (args.model === undefined)
|
|
2616
|
+
throw new Error('model is required for novel_model_profiles(action="configure")');
|
|
2617
|
+
const client = clientFor(exec);
|
|
2618
|
+
if (!args.model.trim() || args.model.length > 120)
|
|
2619
|
+
throw new Error('model must be non-empty and at most 120 characters');
|
|
2620
|
+
const body = { model: args.model };
|
|
2621
|
+
for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
|
|
2622
|
+
const value = args[key];
|
|
2623
|
+
if (value !== undefined)
|
|
2624
|
+
body[key] = value;
|
|
2625
|
+
}
|
|
2626
|
+
return await client.postJson('/api/model', body, exec.signal);
|
|
2627
|
+
}
|
|
2628
|
+
case 'test': {
|
|
2629
|
+
const client = clientFor(exec);
|
|
2630
|
+
if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
|
|
2631
|
+
throw new Error('either id (stored profile) or model (candidate) is required');
|
|
2632
|
+
}
|
|
2633
|
+
const body = {};
|
|
2634
|
+
for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
|
|
2635
|
+
const value = args[key];
|
|
2636
|
+
if (value !== undefined)
|
|
2637
|
+
body[key] = value;
|
|
2638
|
+
}
|
|
2639
|
+
return await client.postJson('/api/model/test', body, exec.signal);
|
|
2640
|
+
}
|
|
2641
|
+
default: throw new Error(`novel_model_profiles: unknown action ${String(args.action)}`);
|
|
2642
|
+
}
|
|
2500
2643
|
},
|
|
2501
2644
|
}));
|
|
2502
2645
|
ctx.tools.register(defineTool({
|
|
@@ -2555,151 +2698,4 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2555
2698
|
return await client.postJson('/api/benchmarks', body, exec.signal);
|
|
2556
2699
|
},
|
|
2557
2700
|
}));
|
|
2558
|
-
ctx.tools.register(defineTool({
|
|
2559
|
-
name: 'novel_model_configure',
|
|
2560
|
-
description: 'Apply and persist the server\'s active model connection (OpenWrite\'s own LLM for writing/review — ' +
|
|
2561
|
-
'independent of the dsh model). max_tokens must stay below context_tokens.',
|
|
2562
|
-
parameters: {
|
|
2563
|
-
provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
|
|
2564
|
-
model: { type: 'string', required: true, description: 'Model name (max 120 characters).' },
|
|
2565
|
-
api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
|
|
2566
|
-
base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
|
|
2567
|
-
api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
|
|
2568
|
-
context_tokens: { type: 'integer', description: 'Context budget (min 12000).' },
|
|
2569
|
-
max_tokens: { type: 'integer', description: 'Max output tokens (min 256, must be < context_tokens).' },
|
|
2570
|
-
remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
|
|
2571
|
-
},
|
|
2572
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2573
|
-
timeoutMs,
|
|
2574
|
-
async execute(args, exec) {
|
|
2575
|
-
const client = clientFor(exec);
|
|
2576
|
-
if (!args.model.trim() || args.model.length > 120)
|
|
2577
|
-
throw new Error('model must be non-empty and at most 120 characters');
|
|
2578
|
-
const body = { model: args.model };
|
|
2579
|
-
for (const key of ['provider', 'api_key', 'base_url', 'api_format', 'context_tokens', 'max_tokens', 'remember_api_key']) {
|
|
2580
|
-
const value = args[key];
|
|
2581
|
-
if (value !== undefined)
|
|
2582
|
-
body[key] = value;
|
|
2583
|
-
}
|
|
2584
|
-
return await client.postJson('/api/model', body, exec.signal);
|
|
2585
|
-
},
|
|
2586
|
-
}));
|
|
2587
|
-
ctx.tools.register(defineTool({
|
|
2588
|
-
name: 'novel_model_test',
|
|
2589
|
-
description: 'Test a model connection without changing active settings. Pass id to test a stored profile, or a full ' +
|
|
2590
|
-
'candidate (provider/model/base_url/api_key...).',
|
|
2591
|
-
parameters: {
|
|
2592
|
-
id: { type: 'string', description: 'Stored profile id to test.' },
|
|
2593
|
-
provider: { type: 'string', enum: ['openai', 'anthropic', 'custom'], description: 'Provider (default "openai").' },
|
|
2594
|
-
model: { type: 'string', description: 'Model name (required when id is omitted).' },
|
|
2595
|
-
base_url: { type: 'string', description: 'Base URL (required for provider "custom").' },
|
|
2596
|
-
api_format: { type: 'string', enum: ['chat', 'responses'], description: 'API format (default "chat").' },
|
|
2597
|
-
api_key: { type: 'string', description: 'API key; omit to reuse the server environment key.' },
|
|
2598
|
-
context_tokens: { type: 'integer', description: 'Context budget.' },
|
|
2599
|
-
max_tokens: { type: 'integer', description: 'Max output tokens.' },
|
|
2600
|
-
},
|
|
2601
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2602
|
-
timeoutMs,
|
|
2603
|
-
isConcurrencySafe: () => true,
|
|
2604
|
-
async execute(args, exec) {
|
|
2605
|
-
const client = clientFor(exec);
|
|
2606
|
-
if (args.id === undefined && (args.model === undefined || !args.model.trim())) {
|
|
2607
|
-
throw new Error('either id (stored profile) or model (candidate) is required');
|
|
2608
|
-
}
|
|
2609
|
-
const body = {};
|
|
2610
|
-
for (const key of ['id', 'provider', 'model', 'base_url', 'api_format', 'api_key', 'context_tokens', 'max_tokens']) {
|
|
2611
|
-
const value = args[key];
|
|
2612
|
-
if (value !== undefined)
|
|
2613
|
-
body[key] = value;
|
|
2614
|
-
}
|
|
2615
|
-
return await client.postJson('/api/model/test', body, exec.signal);
|
|
2616
|
-
},
|
|
2617
|
-
}));
|
|
2618
|
-
ctx.tools.register(defineTool({
|
|
2619
|
-
name: 'novel_model_embedding_test',
|
|
2620
|
-
description: 'Probe an independent Embedding profile without changing Chat profiles.',
|
|
2621
|
-
parameters: {
|
|
2622
|
-
id: { type: 'string', description: 'Stored profile id to probe.' },
|
|
2623
|
-
candidate: { type: 'json', description: 'Embedding candidate settings object (merged into the request).' },
|
|
2624
|
-
api_key: { type: 'string', description: 'Embedding API key override.' },
|
|
2625
|
-
},
|
|
2626
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2627
|
-
timeoutMs,
|
|
2628
|
-
isConcurrencySafe: () => true,
|
|
2629
|
-
async execute(args, exec) {
|
|
2630
|
-
const client = clientFor(exec);
|
|
2631
|
-
const body = {};
|
|
2632
|
-
if (args.candidate !== undefined) {
|
|
2633
|
-
if (typeof args.candidate !== 'object' || args.candidate === null || Array.isArray(args.candidate)) {
|
|
2634
|
-
throw new Error('candidate must be a JSON object');
|
|
2635
|
-
}
|
|
2636
|
-
Object.assign(body, args.candidate);
|
|
2637
|
-
}
|
|
2638
|
-
for (const key of ['id', 'api_key']) {
|
|
2639
|
-
const value = args[key];
|
|
2640
|
-
if (value !== undefined)
|
|
2641
|
-
body[key] = value;
|
|
2642
|
-
}
|
|
2643
|
-
return await client.postJson('/api/model/embedding/test', body, exec.signal);
|
|
2644
|
-
},
|
|
2645
|
-
}));
|
|
2646
|
-
ctx.tools.register(defineTool({
|
|
2647
|
-
name: 'novel_model_profile_save',
|
|
2648
|
-
description: 'Save (create or update) a named model profile on the server, with an optional API key.',
|
|
2649
|
-
parameters: {
|
|
2650
|
-
profile: { type: 'json', required: true, description: 'Profile object, e.g. {"id": "fast", "label": "...", "provider": "openai", "model": "...", "base_url": "...", "max_output_tokens": 24000}.' },
|
|
2651
|
-
api_key: { type: 'string', description: 'Chat API key to store with the profile.' },
|
|
2652
|
-
remember_api_key: { type: 'boolean', description: 'Persist the API key on the server (default true).' },
|
|
2653
|
-
},
|
|
2654
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2655
|
-
timeoutMs,
|
|
2656
|
-
async execute(args, exec) {
|
|
2657
|
-
const client = clientFor(exec);
|
|
2658
|
-
if (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile)) {
|
|
2659
|
-
throw new Error('profile must be a JSON object');
|
|
2660
|
-
}
|
|
2661
|
-
const body = { ...args.profile };
|
|
2662
|
-
for (const key of ['api_key', 'remember_api_key']) {
|
|
2663
|
-
const value = args[key];
|
|
2664
|
-
if (value !== undefined)
|
|
2665
|
-
body[key] = value;
|
|
2666
|
-
}
|
|
2667
|
-
return await client.postJson('/api/model/profiles', body, exec.signal);
|
|
2668
|
-
},
|
|
2669
|
-
}));
|
|
2670
|
-
ctx.tools.register(defineTool({
|
|
2671
|
-
name: 'novel_model_profile_delete',
|
|
2672
|
-
description: 'Delete a stored model profile. Routes pointing at it fall back to fallback_id.',
|
|
2673
|
-
parameters: {
|
|
2674
|
-
profile_id: { type: 'string', required: true, description: 'Profile id to delete.' },
|
|
2675
|
-
fallback_id: { type: 'string', description: 'Profile id to reroute to (default: the server default profile).' },
|
|
2676
|
-
},
|
|
2677
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2678
|
-
timeoutMs,
|
|
2679
|
-
async execute(args, exec) {
|
|
2680
|
-
const client = clientFor(exec);
|
|
2681
|
-
if (!args.profile_id.trim())
|
|
2682
|
-
throw new Error('profile_id must be a non-empty string');
|
|
2683
|
-
const body = { profile_id: args.profile_id };
|
|
2684
|
-
if (args.fallback_id !== undefined)
|
|
2685
|
-
body['fallback_id'] = args.fallback_id;
|
|
2686
|
-
return await client.postJson('/api/model/profiles/delete', body, exec.signal);
|
|
2687
|
-
},
|
|
2688
|
-
}));
|
|
2689
|
-
ctx.tools.register(defineTool({
|
|
2690
|
-
name: 'novel_model_routes_save',
|
|
2691
|
-
description: 'Save per-task model routing: map operation names (e.g. "chapter_write", "review", "research") to profile ids.',
|
|
2692
|
-
parameters: {
|
|
2693
|
-
routes: { type: 'json', required: true, description: 'Object mapping operation names to profile ids.' },
|
|
2694
|
-
},
|
|
2695
|
-
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2696
|
-
timeoutMs,
|
|
2697
|
-
async execute(args, exec) {
|
|
2698
|
-
const client = clientFor(exec);
|
|
2699
|
-
if (typeof args.routes !== 'object' || args.routes === null || Array.isArray(args.routes)) {
|
|
2700
|
-
throw new Error('routes must be a JSON object');
|
|
2701
|
-
}
|
|
2702
|
-
return await client.postJson('/api/model/routes', { routes: args.routes }, exec.signal);
|
|
2703
|
-
},
|
|
2704
|
-
}));
|
|
2705
2701
|
}
|