dsh-novel-write-engine 0.2.3 → 0.2.4
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/CHANGELOG.md +21 -0
- package/README.md +36 -11
- package/briefs/P2.md +1 -0
- package/lib/client.js +2 -2
- package/lib/index.d.ts +34 -14
- package/lib/index.js +539 -152
- package/lib/types/constants.d.ts +2 -0
- package/package.json +1 -1
- package/skills/entry.md +1 -1
package/lib/index.js
CHANGED
|
@@ -20,6 +20,8 @@ const PANEL_CHAPTER_LIMIT = 30;
|
|
|
20
20
|
/** Project-relative locations. Every write goes through these constants. */
|
|
21
21
|
const PROJECT_FILE = "novel.yaml";
|
|
22
22
|
const OUTLINE_FILE = "outline/outline.yaml";
|
|
23
|
+
/** Last outline replaced by a normal planning write; one level is enough for repair. */
|
|
24
|
+
const OUTLINE_DEPRECATED_FILE = "outline/outline.deprecated.yaml";
|
|
23
25
|
const WORLDBOOK_FILE = "outline/worldbook.yaml";
|
|
24
26
|
const STATE_DIR = "state";
|
|
25
27
|
const CHAPTERS_DIR = "chapters";
|
|
@@ -3810,7 +3812,7 @@ function buildEpub(project, chapters) {
|
|
|
3810
3812
|
const escapeXml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3811
3813
|
// src/engine/book.ts
|
|
3812
3814
|
const LENIENT_TITLE_PREFIX$1 = /^第\s*\d+\s*章\s*/;
|
|
3813
|
-
const text$
|
|
3815
|
+
const text$2 = (value, where, max = LIMITS.maxOutlineTextFieldChars) => {
|
|
3814
3816
|
if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
|
|
3815
3817
|
const trimmed = value.trim();
|
|
3816
3818
|
if (Array.from(trimmed).length > max) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} is ${Array.from(trimmed).length} characters; limit is ${max}`);
|
|
@@ -3829,13 +3831,13 @@ const stringArray$1 = (value, where, maxItems = 20) => {
|
|
|
3829
3831
|
if (value.length > maxItems) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains ${value.length} items; limit is ${maxItems}`);
|
|
3830
3832
|
const seen = /* @__PURE__ */ new Set();
|
|
3831
3833
|
return value.map((item, index) => {
|
|
3832
|
-
const entry = text$
|
|
3834
|
+
const entry = text$2(item, `${where}[${index}]`, 200);
|
|
3833
3835
|
if (seen.has(entry)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} contains duplicate "${entry}"`);
|
|
3834
3836
|
seen.add(entry);
|
|
3835
3837
|
return entry;
|
|
3836
3838
|
});
|
|
3837
3839
|
};
|
|
3838
|
-
const positiveInteger = (value, where) => {
|
|
3840
|
+
const positiveInteger$1 = (value, where) => {
|
|
3839
3841
|
if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
|
|
3840
3842
|
return value;
|
|
3841
3843
|
};
|
|
@@ -3845,7 +3847,7 @@ const wordPair = (value, where) => {
|
|
|
3845
3847
|
if (!Number.isInteger(min) || !Number.isInteger(max) || min <= 0 || max < min) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be [min, max] positive integers with min <= max`);
|
|
3846
3848
|
return [min, max];
|
|
3847
3849
|
};
|
|
3848
|
-
const isRecord$
|
|
3850
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3849
3851
|
/** Remove a leading 第N章 prefix; stored titles are bodies. */
|
|
3850
3852
|
function stripChapterPrefix(title) {
|
|
3851
3853
|
return title.replace(LENIENT_TITLE_PREFIX$1, "").trim();
|
|
@@ -3947,7 +3949,7 @@ const BOOK_PATCH_KEYS = /* @__PURE__ */ new Set([
|
|
|
3947
3949
|
/** Apply a partial book patch; omitted fields keep their existing value. */
|
|
3948
3950
|
function mergeBookPlan(existing, patch) {
|
|
3949
3951
|
if (patch === void 0) return { ...existing };
|
|
3950
|
-
if (!isRecord$
|
|
3952
|
+
if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "book must be an object");
|
|
3951
3953
|
for (const key of Object.keys(patch)) if (!BOOK_PATCH_KEYS.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `book has unknown field "${key}"`, `allowed: ${[...BOOK_PATCH_KEYS].join(", ")}`);
|
|
3952
3954
|
const result = { ...existing };
|
|
3953
3955
|
for (const key of [
|
|
@@ -3975,15 +3977,15 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
|
|
|
3975
3977
|
if (!Array.isArray(input)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
|
|
3976
3978
|
if (input.length > LIMITS.maxOutlineVolumes) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains ${input.length} items; limit is ${LIMITS.maxOutlineVolumes}`);
|
|
3977
3979
|
const volumes = input.map((raw, index) => {
|
|
3978
|
-
if (!isRecord$
|
|
3979
|
-
const start = positiveInteger(raw.start_chapter, `volumes[${index}].start_chapter`);
|
|
3980
|
-
const end = positiveInteger(raw.end_chapter, `volumes[${index}].end_chapter`);
|
|
3980
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
3981
|
+
const start = positiveInteger$1(raw.start_chapter, `volumes[${index}].start_chapter`);
|
|
3982
|
+
const end = positiveInteger$1(raw.end_chapter, `volumes[${index}].end_chapter`);
|
|
3981
3983
|
if (end < start) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] end_chapter ${end} is before start_chapter ${start}`);
|
|
3982
3984
|
const status = raw.status ?? "planned";
|
|
3983
3985
|
if (status !== "planned" && status !== "writing" && status !== "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}].status must be planned|writing|done`);
|
|
3984
3986
|
return {
|
|
3985
|
-
id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() :
|
|
3986
|
-
title: text$
|
|
3987
|
+
id: typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : "",
|
|
3988
|
+
title: text$2(raw.title, `volumes[${index}].title`, 120),
|
|
3987
3989
|
start_chapter: start,
|
|
3988
3990
|
end_chapter: end,
|
|
3989
3991
|
goal: optionalText(raw.goal, `volumes[${index}].goal`),
|
|
@@ -3992,6 +3994,23 @@ function normalizeVolumes(input, options = { totalChapters: null }) {
|
|
|
3992
3994
|
status
|
|
3993
3995
|
};
|
|
3994
3996
|
});
|
|
3997
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
3998
|
+
const usedTitles = /* @__PURE__ */ new Set();
|
|
3999
|
+
for (const volume of volumes) {
|
|
4000
|
+
if (usedTitles.has(volume.title) && options.rejectDuplicateTitles !== false) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume title "${volume.title}"`);
|
|
4001
|
+
usedTitles.add(volume.title);
|
|
4002
|
+
if (volume.id === "") continue;
|
|
4003
|
+
if (usedIds.has(volume.id)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `duplicate volume id "${volume.id}"`);
|
|
4004
|
+
usedIds.add(volume.id);
|
|
4005
|
+
}
|
|
4006
|
+
let nextId = 1;
|
|
4007
|
+
for (const volume of volumes) {
|
|
4008
|
+
if (volume.id !== "") continue;
|
|
4009
|
+
while (usedIds.has(`V${nextId}`)) nextId += 1;
|
|
4010
|
+
volume.id = `V${nextId}`;
|
|
4011
|
+
usedIds.add(volume.id);
|
|
4012
|
+
nextId += 1;
|
|
4013
|
+
}
|
|
3995
4014
|
volumes.sort((left, right) => left.start_chapter - right.start_chapter || left.end_chapter - right.end_chapter);
|
|
3996
4015
|
const ids = /* @__PURE__ */ new Set();
|
|
3997
4016
|
for (const [index, volume] of volumes.entries()) {
|
|
@@ -4075,8 +4094,8 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4075
4094
|
const archived = new Map(options.archived.map((record) => [record.num, record]));
|
|
4076
4095
|
const normalized = /* @__PURE__ */ new Map();
|
|
4077
4096
|
for (const [index, raw] of input.entries()) {
|
|
4078
|
-
if (!isRecord$
|
|
4079
|
-
const num = positiveInteger(raw.num, `chapters[${index}].num`);
|
|
4097
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4098
|
+
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4080
4099
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`, "raise total_chapters with novel_blueprint before planning a longer book");
|
|
4081
4100
|
if (normalized.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
4082
4101
|
const record = archived.get(num);
|
|
@@ -4093,9 +4112,9 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4093
4112
|
if (cast.length !== base.cast.length || cast.some((name) => !base.cast.includes(name))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its cast cannot change`, "call novel_reopen first if the manuscript must be revised");
|
|
4094
4113
|
normalized.set(num, {
|
|
4095
4114
|
...base,
|
|
4096
|
-
goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$
|
|
4097
|
-
conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$
|
|
4098
|
-
hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$
|
|
4115
|
+
goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`),
|
|
4116
|
+
conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`),
|
|
4117
|
+
hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`),
|
|
4099
4118
|
notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
|
|
4100
4119
|
volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
|
|
4101
4120
|
status: "archived",
|
|
@@ -4114,18 +4133,19 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4114
4133
|
"情感爆发章",
|
|
4115
4134
|
"纯叙事章"
|
|
4116
4135
|
].includes(raw.scene_type)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}].scene_type is invalid`);
|
|
4117
|
-
const
|
|
4136
|
+
const previousCard = existing.get(num);
|
|
4137
|
+
const cast = raw.cast == null && previousCard?.status === "planned" ? previousCard.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
|
|
4118
4138
|
normalized.set(num, {
|
|
4119
4139
|
num,
|
|
4120
|
-
volume: volumeNumberFor(options.volumes, num, raw.volume),
|
|
4140
|
+
volume: raw.volume !== void 0 ? volumeNumberFor(options.volumes, num, raw.volume) : previousCard?.status === "planned" ? volumeForChapter(options.volumes, num, previousCard.volume) : volumeNumberFor(options.volumes, num, void 0),
|
|
4121
4141
|
title,
|
|
4122
4142
|
scene_type: raw.scene_type,
|
|
4123
|
-
goal: text$
|
|
4124
|
-
conflict: text$
|
|
4125
|
-
hook: text$
|
|
4143
|
+
goal: text$2(raw.goal, `chapters[${index}].goal`),
|
|
4144
|
+
conflict: text$2(raw.conflict, `chapters[${index}].conflict`),
|
|
4145
|
+
hook: text$2(raw.hook, `chapters[${index}].hook`),
|
|
4126
4146
|
cast,
|
|
4127
4147
|
target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
|
|
4128
|
-
notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
4148
|
+
notes: raw.notes == null && previousCard?.status === "planned" ? previousCard.notes : optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
4129
4149
|
status: "planned",
|
|
4130
4150
|
archived_at: null,
|
|
4131
4151
|
word_count: null,
|
|
@@ -4148,7 +4168,7 @@ function normalizeOutlineChapters(input, options) {
|
|
|
4148
4168
|
}
|
|
4149
4169
|
/** Validate a document loaded from disk. Throws a fault, not an input error. */
|
|
4150
4170
|
function validateOutlineDocument(value, options) {
|
|
4151
|
-
if (!isRecord$
|
|
4171
|
+
if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml must be a mapping");
|
|
4152
4172
|
try {
|
|
4153
4173
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4154
4174
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4160,7 +4180,7 @@ function validateOutlineDocument(value, options) {
|
|
|
4160
4180
|
]);
|
|
4161
4181
|
for (const key of Object.keys(value)) if (!allowedTopLevel.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml has unknown key "${key}"`);
|
|
4162
4182
|
if (typeof value.updated_at !== "string" || value.updated_at.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml updated_at must be a string");
|
|
4163
|
-
if (!isRecord$
|
|
4183
|
+
if (!isRecord$2(value.book)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "outline/outline.yaml book must be a mapping");
|
|
4164
4184
|
const allowedBook = /* @__PURE__ */ new Set([
|
|
4165
4185
|
"premise",
|
|
4166
4186
|
"logline",
|
|
@@ -4175,14 +4195,17 @@ function validateOutlineDocument(value, options) {
|
|
|
4175
4195
|
]);
|
|
4176
4196
|
for (const key of Object.keys(value.book)) if (!allowedBook.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/outline.yaml book has unknown key "${key}"`);
|
|
4177
4197
|
const book = mergeBookPlan(emptyBookPlan(), value.book);
|
|
4178
|
-
const volumes = normalizeVolumes(value.volumes, {
|
|
4198
|
+
const volumes = normalizeVolumes(value.volumes, {
|
|
4199
|
+
totalChapters: options.totalChapters,
|
|
4200
|
+
rejectDuplicateTitles: false
|
|
4201
|
+
});
|
|
4179
4202
|
if (!Array.isArray(value.chapters)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "chapters must be an array");
|
|
4180
4203
|
if (value.chapters.length > LIMITS.maxOutlineChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters contains ${value.chapters.length} items; limit is ${LIMITS.maxOutlineChapters}`);
|
|
4181
4204
|
const chapters = [];
|
|
4182
4205
|
const seen = /* @__PURE__ */ new Set();
|
|
4183
4206
|
for (const [index, raw] of value.chapters.entries()) {
|
|
4184
|
-
if (!isRecord$
|
|
4185
|
-
const num = positiveInteger(raw.num, `chapters[${index}].num`);
|
|
4207
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapters[${index}] must be an object`);
|
|
4208
|
+
const num = positiveInteger$1(raw.num, `chapters[${index}].num`);
|
|
4186
4209
|
if (options.totalChapters !== null && num > options.totalChapters) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} exceeds total_chapters ${options.totalChapters}`);
|
|
4187
4210
|
if (seen.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} appears more than once`);
|
|
4188
4211
|
seen.add(num);
|
|
@@ -4208,9 +4231,9 @@ function validateOutlineDocument(value, options) {
|
|
|
4208
4231
|
volume: volumeNumberFor(volumes, num, raw.volume),
|
|
4209
4232
|
title: chapterTitleBody(raw.title, options.titleRule, `chapters[${index}].title`),
|
|
4210
4233
|
scene_type: scene,
|
|
4211
|
-
goal: planned ? text$
|
|
4212
|
-
conflict: planned ? text$
|
|
4213
|
-
hook: planned ? text$
|
|
4234
|
+
goal: planned ? text$2(raw.goal, `chapters[${index}].goal`) : optionalText(raw.goal, `chapters[${index}].goal`),
|
|
4235
|
+
conflict: planned ? text$2(raw.conflict, `chapters[${index}].conflict`) : optionalText(raw.conflict, `chapters[${index}].conflict`),
|
|
4236
|
+
hook: planned ? text$2(raw.hook, `chapters[${index}].hook`) : optionalText(raw.hook, `chapters[${index}].hook`),
|
|
4214
4237
|
cast: stringArray$1(raw.cast, `chapters[${index}].cast`, 8),
|
|
4215
4238
|
target_words: wordPair(raw.target_words, `chapters[${index}].target_words`),
|
|
4216
4239
|
notes: optionalText(raw.notes, `chapters[${index}].notes`, LIMITS.maxOutlineNotesChars),
|
|
@@ -4247,7 +4270,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4247
4270
|
...existing,
|
|
4248
4271
|
updated_at: now
|
|
4249
4272
|
};
|
|
4250
|
-
if (!isRecord$
|
|
4273
|
+
if (!isRecord$2(patch)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "worldbook must be an object");
|
|
4251
4274
|
const result = {
|
|
4252
4275
|
...existing,
|
|
4253
4276
|
updated_at: now,
|
|
@@ -4267,21 +4290,21 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4267
4290
|
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} must be an array`);
|
|
4268
4291
|
if (value.length > LIMITS.maxWorldbookEntries) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key} contains ${value.length} items; limit is ${LIMITS.maxWorldbookEntries}`);
|
|
4269
4292
|
return value.map((raw, index) => {
|
|
4270
|
-
if (!isRecord$
|
|
4293
|
+
if (!isRecord$2(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${key}[${index}] must be an object`);
|
|
4271
4294
|
return raw;
|
|
4272
4295
|
});
|
|
4273
4296
|
};
|
|
4274
4297
|
if (patch.rules !== void 0) {
|
|
4275
4298
|
result.rules = records(patch.rules, "rules").map((raw, index) => ({
|
|
4276
|
-
name: text$
|
|
4277
|
-
description: text$
|
|
4299
|
+
name: text$2(raw.name, `rules[${index}].name`, 120),
|
|
4300
|
+
description: text$2(raw.description, `rules[${index}].description`, LIMITS.maxWorldbookTextChars)
|
|
4278
4301
|
}));
|
|
4279
4302
|
uniqueNames(result.rules, "rules");
|
|
4280
4303
|
}
|
|
4281
4304
|
if (patch.factions !== void 0) {
|
|
4282
4305
|
result.factions = records(patch.factions, "factions").map((raw, index) => ({
|
|
4283
|
-
name: text$
|
|
4284
|
-
description: text$
|
|
4306
|
+
name: text$2(raw.name, `factions[${index}].name`, 120),
|
|
4307
|
+
description: text$2(raw.description, `factions[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4285
4308
|
goal: optionalText(raw.goal, `factions[${index}].goal`)
|
|
4286
4309
|
}));
|
|
4287
4310
|
uniqueNames(result.factions, "factions");
|
|
@@ -4291,8 +4314,8 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4291
4314
|
const capacity = raw.capacity ?? 0;
|
|
4292
4315
|
if (!Number.isInteger(capacity) || capacity < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `locations[${index}].capacity must be a non-negative integer`);
|
|
4293
4316
|
return {
|
|
4294
|
-
name: text$
|
|
4295
|
-
description: text$
|
|
4317
|
+
name: text$2(raw.name, `locations[${index}].name`, 120),
|
|
4318
|
+
description: text$2(raw.description, `locations[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4296
4319
|
layout: optionalText(raw.layout, `locations[${index}].layout`),
|
|
4297
4320
|
capacity
|
|
4298
4321
|
};
|
|
@@ -4301,17 +4324,17 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4301
4324
|
}
|
|
4302
4325
|
if (patch.characters !== void 0) {
|
|
4303
4326
|
result.characters = records(patch.characters, "characters").map((raw, index) => ({
|
|
4304
|
-
name: text$
|
|
4305
|
-
role: text$
|
|
4306
|
-
description: text$
|
|
4327
|
+
name: text$2(raw.name, `characters[${index}].name`, 120),
|
|
4328
|
+
role: text$2(raw.role, `characters[${index}].role`, 60),
|
|
4329
|
+
description: text$2(raw.description, `characters[${index}].description`, LIMITS.maxWorldbookTextChars),
|
|
4307
4330
|
arc: optionalText(raw.arc, `characters[${index}].arc`)
|
|
4308
4331
|
}));
|
|
4309
4332
|
uniqueNames(result.characters, "characters");
|
|
4310
4333
|
}
|
|
4311
4334
|
if (patch.glossary !== void 0) {
|
|
4312
4335
|
result.glossary = records(patch.glossary, "glossary").map((raw, index) => ({
|
|
4313
|
-
term: text$
|
|
4314
|
-
definition: text$
|
|
4336
|
+
term: text$2(raw.term, `glossary[${index}].term`, 120),
|
|
4337
|
+
definition: text$2(raw.definition, `glossary[${index}].definition`, LIMITS.maxWorldbookTextChars)
|
|
4315
4338
|
}));
|
|
4316
4339
|
const seen = /* @__PURE__ */ new Set();
|
|
4317
4340
|
for (const entry of result.glossary) {
|
|
@@ -4320,8 +4343,8 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4320
4343
|
}
|
|
4321
4344
|
}
|
|
4322
4345
|
if (patch.backstory !== void 0) result.backstory = records(patch.backstory, "backstory").map((raw, index) => ({
|
|
4323
|
-
time: text$
|
|
4324
|
-
event: text$
|
|
4346
|
+
time: text$2(raw.time, `backstory[${index}].time`, 120),
|
|
4347
|
+
event: text$2(raw.event, `backstory[${index}].event`, LIMITS.maxWorldbookTextChars)
|
|
4325
4348
|
}));
|
|
4326
4349
|
if (patch.facts !== void 0) {
|
|
4327
4350
|
result.facts = records(patch.facts, "facts").map((raw, index) => {
|
|
@@ -4330,9 +4353,9 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4330
4353
|
const tolerance = raw.tolerance ?? 0;
|
|
4331
4354
|
if (typeof tolerance !== "number" || !Number.isFinite(tolerance) || tolerance < 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `facts[${index}].tolerance must be a non-negative number`);
|
|
4332
4355
|
return {
|
|
4333
|
-
alias: text$
|
|
4356
|
+
alias: text$2(raw.alias, `facts[${index}].alias`, 120),
|
|
4334
4357
|
value,
|
|
4335
|
-
unit: text$
|
|
4358
|
+
unit: text$2(raw.unit, `facts[${index}].unit`, 40),
|
|
4336
4359
|
tolerance,
|
|
4337
4360
|
...raw.note === void 0 ? {} : { note: optionalText(raw.note, `facts[${index}].note`) }
|
|
4338
4361
|
};
|
|
@@ -4347,7 +4370,7 @@ function mergeWorldbook(existing, patch, now) {
|
|
|
4347
4370
|
}
|
|
4348
4371
|
/** Validate a worldbook loaded from disk. */
|
|
4349
4372
|
function validateWorldbookDocument(value, now) {
|
|
4350
|
-
if (!isRecord$
|
|
4373
|
+
if (!isRecord$2(value)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, "outline/worldbook.yaml must be a mapping");
|
|
4351
4374
|
try {
|
|
4352
4375
|
if (value.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline/worldbook.yaml schema_version must be 1, got ${String(value.schema_version)}`);
|
|
4353
4376
|
const allowedTopLevel = /* @__PURE__ */ new Set([
|
|
@@ -4758,7 +4781,7 @@ function parseOutlineData(content, format) {
|
|
|
4758
4781
|
} catch (error) {
|
|
4759
4782
|
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
4760
4783
|
}
|
|
4761
|
-
if (!isRecord$
|
|
4784
|
+
if (!isRecord$2(parsed)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} must be a mapping`);
|
|
4762
4785
|
if (parsed.schema_version !== void 0 && parsed.schema_version !== 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline ${format} schema_version must be 1, got ${String(parsed.schema_version)}`);
|
|
4763
4786
|
const warnings = [];
|
|
4764
4787
|
const allowed = /* @__PURE__ */ new Set([
|
|
@@ -4855,6 +4878,216 @@ function renderWorldbookMarkdown(project, document) {
|
|
|
4855
4878
|
section("前史", document.backstory.map((entry) => `- ${oneLine(entry.time)}:${oneLine(entry.event)}`));
|
|
4856
4879
|
return parts.join("\n");
|
|
4857
4880
|
}
|
|
4881
|
+
// src/engine/outline-merge.ts
|
|
4882
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4883
|
+
const text$1 = (value, where) => {
|
|
4884
|
+
if (typeof value !== "string" || value.trim() === "") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a non-empty string`);
|
|
4885
|
+
return value.trim();
|
|
4886
|
+
};
|
|
4887
|
+
const positiveInteger = (value, where) => {
|
|
4888
|
+
if (!Number.isInteger(value) || value < 1) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${where} must be a positive integer`);
|
|
4889
|
+
return value;
|
|
4890
|
+
};
|
|
4891
|
+
const parseChapterNumbers = (value, field) => {
|
|
4892
|
+
if (value === void 0) return /* @__PURE__ */ new Set();
|
|
4893
|
+
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of chapter numbers`);
|
|
4894
|
+
const result = /* @__PURE__ */ new Set();
|
|
4895
|
+
for (const [index, raw] of value.entries()) {
|
|
4896
|
+
const num = positiveInteger(raw, `${field}[${index}]`);
|
|
4897
|
+
if (result.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate chapter ${num}`);
|
|
4898
|
+
result.add(num);
|
|
4899
|
+
}
|
|
4900
|
+
return result;
|
|
4901
|
+
};
|
|
4902
|
+
const parseVolumeTitles = (value, field) => {
|
|
4903
|
+
if (value === void 0) return /* @__PURE__ */ new Set();
|
|
4904
|
+
if (!Array.isArray(value)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} must be an array of volume titles`);
|
|
4905
|
+
const result = /* @__PURE__ */ new Set();
|
|
4906
|
+
for (const [index, raw] of value.entries()) {
|
|
4907
|
+
const title = text$1(raw, `${field}[${index}]`);
|
|
4908
|
+
if (result.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${field} contains duplicate "${title}"`);
|
|
4909
|
+
result.add(title);
|
|
4910
|
+
}
|
|
4911
|
+
return result;
|
|
4912
|
+
};
|
|
4913
|
+
const submittedNumbers = (input) => {
|
|
4914
|
+
const result = /* @__PURE__ */ new Set();
|
|
4915
|
+
if (!Array.isArray(input)) return result;
|
|
4916
|
+
for (const raw of input) {
|
|
4917
|
+
if (!isRecord$1(raw) || !Number.isInteger(raw.num) || raw.num < 1) continue;
|
|
4918
|
+
result.add(raw.num);
|
|
4919
|
+
}
|
|
4920
|
+
return result;
|
|
4921
|
+
};
|
|
4922
|
+
const volumeByTitle = (volumes) => new Map(volumes.map((volume) => [volume.title, volume]));
|
|
4923
|
+
/**
|
|
4924
|
+
* `volumes` is an upsert keyed by title: omitted volumes survive, and removal
|
|
4925
|
+
* requires naming the volume in `remove_volumes`. This deliberately does not
|
|
4926
|
+
* accept a positional or full-list replacement.
|
|
4927
|
+
*/
|
|
4928
|
+
const mergeVolumes = (options) => {
|
|
4929
|
+
const removed = parseVolumeTitles(options.remove_volumes, "remove_volumes");
|
|
4930
|
+
if (options.volumes === void 0 && removed.size === 0) return {
|
|
4931
|
+
volumes: [...options.existingVolumes],
|
|
4932
|
+
added: [],
|
|
4933
|
+
updated: [],
|
|
4934
|
+
removed: []
|
|
4935
|
+
};
|
|
4936
|
+
if (options.volumes !== void 0 && !Array.isArray(options.volumes)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "volumes must be an array");
|
|
4937
|
+
if (options.volumes !== void 0 || removed.size > 0) {
|
|
4938
|
+
const existingTitles = /* @__PURE__ */ new Set();
|
|
4939
|
+
for (const volume of options.existingVolumes) {
|
|
4940
|
+
if (existingTitles.has(volume.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `outline has duplicate volume title "${volume.title}"`, "repair the volume titles with novel_amend before submitting volumes");
|
|
4941
|
+
existingTitles.add(volume.title);
|
|
4942
|
+
}
|
|
4943
|
+
}
|
|
4944
|
+
const byTitle = volumeByTitle(options.existingVolumes);
|
|
4945
|
+
const usedIds = new Set(options.existingVolumes.map((volume) => volume.id));
|
|
4946
|
+
let nextGeneratedId = 1;
|
|
4947
|
+
const generatedId = () => {
|
|
4948
|
+
while (usedIds.has(`V${nextGeneratedId}`)) nextGeneratedId += 1;
|
|
4949
|
+
const id = `V${nextGeneratedId}`;
|
|
4950
|
+
usedIds.add(id);
|
|
4951
|
+
nextGeneratedId += 1;
|
|
4952
|
+
return id;
|
|
4953
|
+
};
|
|
4954
|
+
const submitted = /* @__PURE__ */ new Set();
|
|
4955
|
+
for (const [index, raw] of (options.volumes ?? []).entries()) {
|
|
4956
|
+
if (!isRecord$1(raw)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes[${index}] must be an object`);
|
|
4957
|
+
const title = text$1(raw.title, `volumes[${index}].title`);
|
|
4958
|
+
if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volumes contains duplicate title "${title}"`);
|
|
4959
|
+
submitted.add(title);
|
|
4960
|
+
const base = byTitle.get(title);
|
|
4961
|
+
const explicitId = typeof raw.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : void 0;
|
|
4962
|
+
const id = base?.id ?? explicitId ?? generatedId();
|
|
4963
|
+
if (!usedIds.has(id)) usedIds.add(id);
|
|
4964
|
+
byTitle.set(title, {
|
|
4965
|
+
...base ?? {},
|
|
4966
|
+
...raw,
|
|
4967
|
+
id,
|
|
4968
|
+
title
|
|
4969
|
+
});
|
|
4970
|
+
}
|
|
4971
|
+
for (const title of removed) {
|
|
4972
|
+
if (submitted.has(title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${title}" cannot be both submitted and removed`);
|
|
4973
|
+
const existing = byTitle.get(title);
|
|
4974
|
+
if (existing === void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": it is not in the plan`);
|
|
4975
|
+
if (existing.status === "done") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${title}": its status is done`, "a completed volume is archive evidence; remove only planned/writing volumes");
|
|
4976
|
+
byTitle.delete(title);
|
|
4977
|
+
}
|
|
4978
|
+
const volumes = normalizeVolumes([...byTitle.values()], { totalChapters: options.totalChapters });
|
|
4979
|
+
const before = volumeByTitle(options.existingVolumes);
|
|
4980
|
+
const after = volumeByTitle(volumes);
|
|
4981
|
+
return {
|
|
4982
|
+
volumes,
|
|
4983
|
+
added: [...after.keys()].filter((title) => !before.has(title)),
|
|
4984
|
+
updated: [...after.keys()].filter((title) => before.has(title) && stableJson(before.get(title)) !== stableJson(after.get(title))),
|
|
4985
|
+
removed: [...removed]
|
|
4986
|
+
};
|
|
4987
|
+
};
|
|
4988
|
+
const remapVolume = (card, oldVolumes, newVolumes) => {
|
|
4989
|
+
if (card.volume === null || card.volume === void 0) return card;
|
|
4990
|
+
const previous = oldVolumes[card.volume - 1];
|
|
4991
|
+
if (previous === void 0) return {
|
|
4992
|
+
...card,
|
|
4993
|
+
volume: null
|
|
4994
|
+
};
|
|
4995
|
+
const nextIndex = newVolumes.findIndex((volume) => volume.title === previous.title);
|
|
4996
|
+
if (nextIndex < 0) return {
|
|
4997
|
+
...card,
|
|
4998
|
+
volume: null
|
|
4999
|
+
};
|
|
5000
|
+
const next = newVolumes[nextIndex];
|
|
5001
|
+
return {
|
|
5002
|
+
...card,
|
|
5003
|
+
volume: card.num >= next.start_chapter && card.num <= next.end_chapter ? nextIndex + 1 : null
|
|
5004
|
+
};
|
|
5005
|
+
};
|
|
5006
|
+
const assertArchivedVolumes = (options, volumes, removedTitles) => {
|
|
5007
|
+
const removed = new Set(removedTitles);
|
|
5008
|
+
const oldByTitle = volumeByTitle(options.existingVolumes);
|
|
5009
|
+
const newByTitle = volumeByTitle(volumes);
|
|
5010
|
+
for (const record of options.archivedChapters) {
|
|
5011
|
+
const card = options.existing.find((entry) => entry.num === record.num);
|
|
5012
|
+
if (card === void 0 || card.volume === null) continue;
|
|
5013
|
+
const previous = options.existingVolumes[card.volume - 1];
|
|
5014
|
+
if (previous === void 0) continue;
|
|
5015
|
+
if (removed.has(previous.title) || !oldByTitle.has(previous.title)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove volume "${previous.title}": archived chapter ${record.num} belongs to it`, "archived cards are evidence; keep the volume or reopen the chapter first");
|
|
5016
|
+
const next = newByTitle.get(previous.title);
|
|
5017
|
+
if (next === void 0 || record.num < next.start_chapter || record.num > next.end_chapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `volume "${previous.title}" no longer covers archived chapter ${record.num}`, "keep archived chapter ranges inside their existing volume");
|
|
5018
|
+
}
|
|
5019
|
+
};
|
|
5020
|
+
/**
|
|
5021
|
+
* Merge chapter cards by num. Existing planned cards are appended back when
|
|
5022
|
+
* omitted; removal is name-only (`remove`), and archived cards are always
|
|
5023
|
+
* backfilled from the chapter index.
|
|
5024
|
+
*/
|
|
5025
|
+
const mergeOutline = (options) => {
|
|
5026
|
+
const submitted = submittedNumbers(options.chapters);
|
|
5027
|
+
const removed = parseChapterNumbers(options.remove, "remove");
|
|
5028
|
+
const volumeChanges = mergeVolumes(options);
|
|
5029
|
+
assertArchivedVolumes(options, volumeChanges.volumes, volumeChanges.removed);
|
|
5030
|
+
const existingByNum = new Map(options.existing.map((card) => [card.num, card]));
|
|
5031
|
+
const archivedByNum = new Map(options.archivedChapters.map((record) => [record.num, record]));
|
|
5032
|
+
const archivedNums = new Set(archivedByNum.keys());
|
|
5033
|
+
for (const num of removed) {
|
|
5034
|
+
if (submitted.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} cannot be both submitted and removed`);
|
|
5035
|
+
if (num === options.activeChapter) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove the active chapter ${num} card`, "archive or move the active chapter first; the card is required while the chapter is in flight");
|
|
5036
|
+
if (archivedByNum.has(num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is archived`, "archived cards are evidence; call novel_reopen first");
|
|
5037
|
+
if (existingByNum.get(num)?.status !== "planned") throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `cannot remove chapter ${num}: it is not a planned card`);
|
|
5038
|
+
}
|
|
5039
|
+
const normalized = normalizeOutlineChapters(options.chapters, {
|
|
5040
|
+
volumes: volumeChanges.volumes,
|
|
5041
|
+
existing: options.existing,
|
|
5042
|
+
archived: options.archivedChapters,
|
|
5043
|
+
titleRule: options.titleRule,
|
|
5044
|
+
totalChapters: options.totalChapters,
|
|
5045
|
+
defaultTargetWords: options.defaultTargetWords,
|
|
5046
|
+
now: options.now
|
|
5047
|
+
});
|
|
5048
|
+
const byNum = new Map(normalized.map((card) => [card.num, card]));
|
|
5049
|
+
const updated = /* @__PURE__ */ new Set();
|
|
5050
|
+
const added = /* @__PURE__ */ new Set();
|
|
5051
|
+
for (const card of normalized) {
|
|
5052
|
+
if (!submitted.has(card.num)) continue;
|
|
5053
|
+
const before = existingByNum.get(card.num);
|
|
5054
|
+
if (before === void 0) added.add(card.num);
|
|
5055
|
+
else if (stableJson(before) !== stableJson(card)) updated.add(card.num);
|
|
5056
|
+
}
|
|
5057
|
+
for (const card of options.existing) {
|
|
5058
|
+
if (submitted.has(card.num) || removed.has(card.num)) continue;
|
|
5059
|
+
if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${card.num} has an archived outline card but is not in the chapter index`, "repair the outline or reopen/archive the chapter so the card status matches the index");
|
|
5060
|
+
if (archivedNums.has(card.num)) {
|
|
5061
|
+
if (!byNum.has(card.num)) byNum.set(card.num, {
|
|
5062
|
+
...card,
|
|
5063
|
+
status: "archived"
|
|
5064
|
+
});
|
|
5065
|
+
continue;
|
|
5066
|
+
}
|
|
5067
|
+
const remapped = remapVolume(card, options.existingVolumes, volumeChanges.volumes);
|
|
5068
|
+
if (byNum.has(card.num)) {
|
|
5069
|
+
const current = byNum.get(card.num);
|
|
5070
|
+
if (stableJson(current) !== stableJson(remapped)) {
|
|
5071
|
+
byNum.set(card.num, remapped);
|
|
5072
|
+
if (card.status === "planned") updated.add(card.num);
|
|
5073
|
+
}
|
|
5074
|
+
} else {
|
|
5075
|
+
byNum.set(card.num, remapped);
|
|
5076
|
+
if (card.status === "planned" && stableJson(card) !== stableJson(remapped)) updated.add(card.num);
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
for (const num of removed) byNum.delete(num);
|
|
5080
|
+
return {
|
|
5081
|
+
chapters: [...byNum.values()].sort((left, right) => left.num - right.num),
|
|
5082
|
+
volumes: volumeChanges.volumes,
|
|
5083
|
+
added: [...added].sort((left, right) => left - right),
|
|
5084
|
+
updated: [...updated].sort((left, right) => left - right),
|
|
5085
|
+
removed: [...removed].sort((left, right) => left - right),
|
|
5086
|
+
volumes_added: volumeChanges.added,
|
|
5087
|
+
volumes_updated: volumeChanges.updated,
|
|
5088
|
+
volumes_removed: volumeChanges.removed
|
|
5089
|
+
};
|
|
5090
|
+
};
|
|
4858
5091
|
// src/engine/state-delta.ts
|
|
4859
5092
|
const SECTIONS = [
|
|
4860
5093
|
"characters",
|
|
@@ -6994,7 +7227,7 @@ const outlineAlignment = (input) => {
|
|
|
6994
7227
|
const card = outline.chapters.find((candidate) => candidate.num === input.chapter);
|
|
6995
7228
|
if (card === void 0) {
|
|
6996
7229
|
if (!outline.book.strict) return [];
|
|
6997
|
-
return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit
|
|
7230
|
+
return [block("PG-13", `chapter ${input.chapter} has no outline card while strict mode is on`, "read novel_state {kind:\"outline\"}, then submit only the missing card with novel_outline (upsert by num; omitted cards are kept); do not turn strict off")];
|
|
6998
7231
|
}
|
|
6999
7232
|
const problems = [];
|
|
7000
7233
|
if (card.status === "archived") problems.push(`chapter ${input.chapter} is already archived but is still the active chapter; call novel_reopen first`);
|
|
@@ -8212,6 +8445,7 @@ var EngineService = class {
|
|
|
8212
8445
|
"ending",
|
|
8213
8446
|
"strict",
|
|
8214
8447
|
"volumes",
|
|
8448
|
+
"confirm_remove",
|
|
8215
8449
|
"root"
|
|
8216
8450
|
]);
|
|
8217
8451
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_blueprint argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
|
|
@@ -8228,9 +8462,11 @@ var EngineService = class {
|
|
|
8228
8462
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_blueprint");
|
|
8229
8463
|
this.assertArchiveIndexReadable(snapshot, "novel_blueprint");
|
|
8230
8464
|
const now = this.now().toISOString();
|
|
8231
|
-
const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "root"));
|
|
8465
|
+
const bookPatch = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "volumes" && key !== "confirm_remove" && key !== "root"));
|
|
8232
8466
|
const book = mergeBookPlan(snapshot.outline.book, bookPatch);
|
|
8233
8467
|
const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
|
|
8468
|
+
const removedVolumes = snapshot.outline.volumes.filter((volume) => !volumes.some((next) => next.title === volume.title)).map((volume) => volume.title);
|
|
8469
|
+
if (removedVolumes.length > 0 && input.confirm_remove !== true) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `novel_blueprint would remove volumes: ${removedVolumes.join(", ")}`, "pass confirm_remove:true to replace the volume list, or omit volumes to keep them");
|
|
8234
8470
|
let chapters = snapshot.outline.chapters.map((card) => ({
|
|
8235
8471
|
...card,
|
|
8236
8472
|
volume: volumeForChapter(volumes, card.num, card.volume)
|
|
@@ -8284,12 +8520,66 @@ var EngineService = class {
|
|
|
8284
8520
|
}
|
|
8285
8521
|
});
|
|
8286
8522
|
}
|
|
8523
|
+
outlinePlanChange(snapshot, input, now) {
|
|
8524
|
+
const book = mergeBookPlan(snapshot.outline.book, input.book === void 0 ? void 0 : {
|
|
8525
|
+
...input.book,
|
|
8526
|
+
...input.strict === void 0 ? {} : { strict: input.strict }
|
|
8527
|
+
});
|
|
8528
|
+
if (input.strict !== void 0) {
|
|
8529
|
+
book.strict = input.strict;
|
|
8530
|
+
book.strict_explicit = true;
|
|
8531
|
+
} else if (input.book?.strict === void 0) {
|
|
8532
|
+
if ((input.chapters?.length ?? 0) > 0 && snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
|
|
8533
|
+
book.strict = true;
|
|
8534
|
+
book.strict_explicit = true;
|
|
8535
|
+
}
|
|
8536
|
+
}
|
|
8537
|
+
const merged = mergeOutline({
|
|
8538
|
+
chapters: input.chapters ?? [],
|
|
8539
|
+
remove: input.remove,
|
|
8540
|
+
volumes: input.volumes,
|
|
8541
|
+
remove_volumes: input.remove_volumes,
|
|
8542
|
+
existing: snapshot.outline.chapters,
|
|
8543
|
+
archivedChapters: snapshot.chapters.chapters,
|
|
8544
|
+
existingVolumes: snapshot.outline.volumes,
|
|
8545
|
+
titleRule: this.rulepack.format.title,
|
|
8546
|
+
totalChapters: snapshot.project.targets.total_chapters,
|
|
8547
|
+
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8548
|
+
now,
|
|
8549
|
+
activeChapter: snapshot.pipeline.chapter
|
|
8550
|
+
});
|
|
8551
|
+
return {
|
|
8552
|
+
outline: {
|
|
8553
|
+
schema_version: 1,
|
|
8554
|
+
updated_at: now,
|
|
8555
|
+
book,
|
|
8556
|
+
volumes: merged.volumes,
|
|
8557
|
+
chapters: merged.chapters
|
|
8558
|
+
},
|
|
8559
|
+
data: {
|
|
8560
|
+
planned: merged.chapters.filter((card) => card.status === "planned").length,
|
|
8561
|
+
archived: merged.chapters.filter((card) => card.status === "archived").length,
|
|
8562
|
+
total: merged.chapters.length,
|
|
8563
|
+
strict: book.strict,
|
|
8564
|
+
volumes: merged.volumes.length,
|
|
8565
|
+
added: merged.added,
|
|
8566
|
+
updated: merged.updated,
|
|
8567
|
+
removed: merged.removed,
|
|
8568
|
+
volumes_added: merged.volumes_added,
|
|
8569
|
+
volumes_updated: merged.volumes_updated,
|
|
8570
|
+
volumes_removed: merged.volumes_removed
|
|
8571
|
+
}
|
|
8572
|
+
};
|
|
8573
|
+
}
|
|
8287
8574
|
async submitOutline(input) {
|
|
8288
8575
|
const allowed = /* @__PURE__ */ new Set([
|
|
8289
8576
|
"book",
|
|
8290
8577
|
"volumes",
|
|
8291
8578
|
"chapters",
|
|
8292
8579
|
"strict",
|
|
8580
|
+
"remove",
|
|
8581
|
+
"remove_volumes",
|
|
8582
|
+
"dry_run",
|
|
8293
8583
|
"root"
|
|
8294
8584
|
]);
|
|
8295
8585
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `unknown novel_outline argument "${key}"`, `allowed: ${[...allowed].join(", ")}`);
|
|
@@ -8297,6 +8587,41 @@ var EngineService = class {
|
|
|
8297
8587
|
this.assertProjectReadable(current, "novel_outline");
|
|
8298
8588
|
this.assertPlanningDocumentReadable(current, "outline", "novel_outline");
|
|
8299
8589
|
this.assertArchiveIndexReadable(current, "novel_outline");
|
|
8590
|
+
if ((input.chapters === void 0 || Array.isArray(input.chapters) && input.chapters.length === 0) && (input.remove === void 0 || input.remove.length === 0) && (input.remove_volumes === void 0 || input.remove_volumes.length === 0) && (input.volumes === void 0 || Array.isArray(input.volumes) && input.volumes.length === 0) && (input.book === void 0 || typeof input.book === "object" && input.book !== null && Object.keys(input.book).length === 0) && input.strict === void 0 && input.dry_run !== true) return {
|
|
8591
|
+
data: {
|
|
8592
|
+
planned: current.outline.chapters.filter((card) => card.status === "planned").length,
|
|
8593
|
+
archived: current.outline.chapters.filter((card) => card.status === "archived").length,
|
|
8594
|
+
total: current.outline.chapters.length,
|
|
8595
|
+
strict: current.outline.book.strict,
|
|
8596
|
+
volumes: current.outline.volumes.length,
|
|
8597
|
+
added: [],
|
|
8598
|
+
updated: [],
|
|
8599
|
+
removed: [],
|
|
8600
|
+
volumes_added: [],
|
|
8601
|
+
volumes_updated: [],
|
|
8602
|
+
volumes_removed: []
|
|
8603
|
+
},
|
|
8604
|
+
revision: current.pipeline.revision,
|
|
8605
|
+
warnings: []
|
|
8606
|
+
};
|
|
8607
|
+
if (input.dry_run === true) {
|
|
8608
|
+
const plan = this.outlinePlanChange(current, input, this.now().toISOString());
|
|
8609
|
+
this.assertOutlineWritable(current, plan.outline, "novel_outline");
|
|
8610
|
+
return {
|
|
8611
|
+
data: {
|
|
8612
|
+
...plan.data,
|
|
8613
|
+
added: [],
|
|
8614
|
+
updated: [],
|
|
8615
|
+
removed: [],
|
|
8616
|
+
dry_run: true,
|
|
8617
|
+
would_add: plan.data.added,
|
|
8618
|
+
would_update: plan.data.updated,
|
|
8619
|
+
would_remove: plan.data.removed
|
|
8620
|
+
},
|
|
8621
|
+
revision: current.pipeline.revision,
|
|
8622
|
+
warnings: []
|
|
8623
|
+
};
|
|
8624
|
+
}
|
|
8300
8625
|
return this.mutate({
|
|
8301
8626
|
root: input.root,
|
|
8302
8627
|
op: "submitOutline",
|
|
@@ -8305,72 +8630,34 @@ var EngineService = class {
|
|
|
8305
8630
|
this.assertProjectReadable(snapshot, "novel_outline");
|
|
8306
8631
|
this.assertPlanningDocumentReadable(snapshot, "outline", "novel_outline");
|
|
8307
8632
|
this.assertArchiveIndexReadable(snapshot, "novel_outline");
|
|
8308
|
-
const
|
|
8309
|
-
|
|
8310
|
-
...input.book,
|
|
8311
|
-
...input.strict === void 0 ? {} : { strict: input.strict }
|
|
8312
|
-
});
|
|
8313
|
-
if (input.strict !== void 0) {
|
|
8314
|
-
book.strict = input.strict;
|
|
8315
|
-
book.strict_explicit = true;
|
|
8316
|
-
} else if (input.book?.strict === void 0) {
|
|
8317
|
-
if (snapshot.outline.chapters.length === 0 && snapshot.outline.book.strict_explicit !== true) {
|
|
8318
|
-
book.strict = true;
|
|
8319
|
-
book.strict_explicit = true;
|
|
8320
|
-
}
|
|
8321
|
-
}
|
|
8322
|
-
const volumes = input.volumes === void 0 ? snapshot.outline.volumes : normalizeVolumes(input.volumes, { totalChapters: snapshot.project.targets.total_chapters });
|
|
8323
|
-
const chapters = normalizeOutlineChapters(input.chapters, {
|
|
8324
|
-
volumes,
|
|
8325
|
-
existing: snapshot.outline.chapters,
|
|
8326
|
-
archived: snapshot.chapters.chapters,
|
|
8327
|
-
titleRule: this.rulepack.format.title,
|
|
8328
|
-
totalChapters: snapshot.project.targets.total_chapters,
|
|
8329
|
-
defaultTargetWords: snapshot.project.targets.words_per_chapter,
|
|
8330
|
-
now
|
|
8331
|
-
});
|
|
8332
|
-
const outline = {
|
|
8333
|
-
schema_version: 1,
|
|
8334
|
-
updated_at: now,
|
|
8335
|
-
book,
|
|
8336
|
-
volumes,
|
|
8337
|
-
chapters
|
|
8338
|
-
};
|
|
8339
|
-
this.assertActiveCardPreserved(snapshot, book.strict, chapters, "novel_outline");
|
|
8340
|
-
this.assertOutlineWritable(snapshot, outline, "novel_outline");
|
|
8341
|
-
const planned = chapters.filter((card) => card.status === "planned").length;
|
|
8342
|
-
const archived = chapters.filter((card) => card.status === "archived").length;
|
|
8343
|
-
const kept = new Set(chapters.filter((card) => card.status === "planned").map((card) => card.num));
|
|
8344
|
-
const dropped = snapshot.outline.chapters.filter((card) => card.status === "planned" && !kept.has(card.num)).map((card) => card.num);
|
|
8633
|
+
const plan = this.outlinePlanChange(snapshot, input, this.now().toISOString());
|
|
8634
|
+
this.assertOutlineWritable(snapshot, plan.outline, "novel_outline");
|
|
8345
8635
|
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_outline", this.now(), shortDigest(stableJson(input))) });
|
|
8636
|
+
const files = [{
|
|
8637
|
+
path: OUTLINE_FILE,
|
|
8638
|
+
text: yaml(plan.outline)
|
|
8639
|
+
}, {
|
|
8640
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8641
|
+
text: yaml(nextPipeline)
|
|
8642
|
+
}];
|
|
8643
|
+
const prior = await context.store.readText(OUTLINE_FILE);
|
|
8644
|
+
if (prior !== null) files.push({
|
|
8645
|
+
path: OUTLINE_DEPRECATED_FILE,
|
|
8646
|
+
text: prior
|
|
8647
|
+
});
|
|
8346
8648
|
return {
|
|
8347
|
-
files
|
|
8348
|
-
path: OUTLINE_FILE,
|
|
8349
|
-
text: yaml(outline)
|
|
8350
|
-
}, {
|
|
8351
|
-
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
8352
|
-
text: yaml(nextPipeline)
|
|
8353
|
-
}],
|
|
8649
|
+
files,
|
|
8354
8650
|
backup: true,
|
|
8355
|
-
data:
|
|
8356
|
-
|
|
8357
|
-
archived,
|
|
8358
|
-
total: chapters.length,
|
|
8359
|
-
strict: book.strict,
|
|
8360
|
-
volumes: volumes.length
|
|
8361
|
-
},
|
|
8362
|
-
warnings: dropped.length === 0 ? [] : [{
|
|
8363
|
-
code: "OUTLINE_PLANNED_DROPPED",
|
|
8364
|
-
message: `this replacement dropped planned cards: ${dropped.join(", ")}; resubmit every existing card when adding one`
|
|
8365
|
-
}],
|
|
8651
|
+
data: plan.data,
|
|
8652
|
+
warnings: [],
|
|
8366
8653
|
event: {
|
|
8367
8654
|
name: "novel/planned",
|
|
8368
8655
|
payload: {
|
|
8369
8656
|
schemaVersion: 1,
|
|
8370
8657
|
projectRoot: snapshot.root,
|
|
8371
8658
|
kind: "outline",
|
|
8372
|
-
chapters:
|
|
8373
|
-
volumes: volumes
|
|
8659
|
+
chapters: plan.data.total,
|
|
8660
|
+
volumes: plan.data.volumes
|
|
8374
8661
|
}
|
|
8375
8662
|
}
|
|
8376
8663
|
};
|
|
@@ -8737,6 +9024,53 @@ var EngineService = class {
|
|
|
8737
9024
|
run: async (snapshot, context) => {
|
|
8738
9025
|
const manifest = await readBackupManifest(context.store, input.txid);
|
|
8739
9026
|
if (manifest === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} has no readable manifest`, "list backups with novel_state {kind:\"backups\"}; only marked engine backups can be restored");
|
|
9027
|
+
if (input.paths?.length === 1 && input.paths[0] === "outline/outline.yaml") {
|
|
9028
|
+
this.assertArchiveIndexReadable(snapshot, "novel_restore");
|
|
9029
|
+
if (input.chapter !== void 0) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, "restoring outline/outline.yaml does not accept a chapter", "omit chapter when paths is exactly [outline/outline.yaml]");
|
|
9030
|
+
if (!manifest.files.includes("outline/outline.yaml")) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "choose a backup created by novel_outline, novel_blueprint or a planning amendment");
|
|
9031
|
+
const text = await context.store.readText(`backups/${input.txid}/${OUTLINE_FILE}`);
|
|
9032
|
+
if (text === null) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} does not contain ${OUTLINE_FILE}`, "the file was created by that transaction, so it has no pre-image to restore");
|
|
9033
|
+
let document;
|
|
9034
|
+
try {
|
|
9035
|
+
document = validateOutlineDocument(parse(text), {
|
|
9036
|
+
titleRule: this.rulepack.format.title,
|
|
9037
|
+
totalChapters: snapshot.project.targets.total_chapters
|
|
9038
|
+
});
|
|
9039
|
+
} catch (error) {
|
|
9040
|
+
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} contains an invalid outline: ${errorMessage(error)}`, "restore a different backup or repair the file manually");
|
|
9041
|
+
}
|
|
9042
|
+
const restoredCards = new Map(document.chapters.map((card) => [card.num, card]));
|
|
9043
|
+
const archivedNums = new Set(snapshot.chapters.chapters.map((record) => record.num));
|
|
9044
|
+
for (const record of snapshot.chapters.chapters) {
|
|
9045
|
+
const restoredCard = restoredCards.get(record.num);
|
|
9046
|
+
if (restoredCard?.status !== "archived") throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would make archived chapter ${record.num} ${restoredCard === void 0 ? "missing its card" : "a planned card"}`, "call novel_reopen before restoring a pre-archive outline, or choose an outline backup that already contains the archived card");
|
|
9047
|
+
}
|
|
9048
|
+
for (const card of document.chapters) if (card.status === "archived" && !archivedNums.has(card.num)) throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `backup ${input.txid} would restore an archived card for chapter ${card.num}, but that chapter is not archived`, "choose an outline backup that matches the current chapter index, or reopen/archive the chapter first");
|
|
9049
|
+
const files = [{
|
|
9050
|
+
path: OUTLINE_FILE,
|
|
9051
|
+
text
|
|
9052
|
+
}];
|
|
9053
|
+
const prior = await context.store.readText(OUTLINE_FILE);
|
|
9054
|
+
if (prior !== null) files.push({
|
|
9055
|
+
path: OUTLINE_DEPRECATED_FILE,
|
|
9056
|
+
text: prior
|
|
9057
|
+
});
|
|
9058
|
+
const nextPipeline = pipelineWith(snapshot, context.revision, { last_action: action("novel_restore", this.now(), shortDigest(input.txid)) });
|
|
9059
|
+
files.push({
|
|
9060
|
+
path: joinRel(STATE_DIR, STATE_FILES.pipeline),
|
|
9061
|
+
text: yaml(nextPipeline)
|
|
9062
|
+
});
|
|
9063
|
+
return {
|
|
9064
|
+
files,
|
|
9065
|
+
backup: true,
|
|
9066
|
+
data: {
|
|
9067
|
+
txid: input.txid,
|
|
9068
|
+
chapter: snapshot.pipeline.chapter,
|
|
9069
|
+
restored: [OUTLINE_FILE]
|
|
9070
|
+
},
|
|
9071
|
+
warnings: []
|
|
9072
|
+
};
|
|
9073
|
+
}
|
|
8740
9074
|
const chapter = input.chapter ?? snapshot.pipeline.chapter;
|
|
8741
9075
|
if (snapshot.chapters.chapters.some((record) => record.num === chapter)) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is archived; restoring it behind the index would break its content hash`, "call novel_reopen for the chapter, then restore the backup");
|
|
8742
9076
|
if (chapter !== snapshot.pipeline.chapter) throw new EngineInputError(ERROR_CODES.STAGE_ORDER, `chapter ${chapter} is not the active chapter ${snapshot.pipeline.chapter}; restore only the active chapter`, "switch the active chapter first, or restore files from the active chapter");
|
|
@@ -11963,17 +12297,15 @@ var EngineService = class {
|
|
|
11963
12297
|
throw new EngineFaultError(ERROR_CODES.SCHEMA_INVALID, `${path} is unreadable; ${operation} would overwrite the unreadable planning document`, `read ${path} with novel_state and repair it with novel_amend before ${operation}`);
|
|
11964
12298
|
}
|
|
11965
12299
|
/**
|
|
11966
|
-
*
|
|
11967
|
-
*
|
|
11968
|
-
* strict project non-strict in effect and archive would auto-create the card
|
|
11969
|
-
* the author was required to provide.
|
|
12300
|
+
* An import is a full replacement, unlike novel_outline's keyed upsert. It
|
|
12301
|
+
* must not silently lose the active card while strict mode still requires it.
|
|
11970
12302
|
*/
|
|
11971
12303
|
assertActiveCardPreserved(snapshot, strict, chapters, operation) {
|
|
11972
12304
|
if (!strict) return;
|
|
11973
12305
|
const chapter = snapshot.pipeline.chapter;
|
|
11974
12306
|
if (!snapshot.outline.chapters.some((card) => card.num === chapter)) return;
|
|
11975
12307
|
if (chapters.some((card) => card.num === chapter)) return;
|
|
11976
|
-
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the
|
|
12308
|
+
throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `${operation} cannot remove the active chapter ${chapter} card while strict mode is on`, "include the active card in the imported outline; import replaces the whole plan and will not turn strict mode off for you");
|
|
11977
12309
|
}
|
|
11978
12310
|
/**
|
|
11979
12311
|
* The outline is a derived progress view and the PG-13 input. Every write
|
|
@@ -14164,6 +14496,8 @@ function diffLines(before, after) {
|
|
|
14164
14496
|
hunks
|
|
14165
14497
|
};
|
|
14166
14498
|
}
|
|
14499
|
+
// src/tools/semantics.ts
|
|
14500
|
+
const arraySemanticsDescription = (semantics, description) => `[x-semantics: ${semantics}] ${description}`;
|
|
14167
14501
|
// src/tools/index.ts
|
|
14168
14502
|
/**
|
|
14169
14503
|
* Providers sometimes serialize a free-form `json` parameter as a JSON string
|
|
@@ -14239,10 +14573,11 @@ const jsonOutput = {
|
|
|
14239
14573
|
schema: { type: "json" },
|
|
14240
14574
|
render: (_args, raw) => withReplay(text(payload(raw)), raw)
|
|
14241
14575
|
};
|
|
14242
|
-
const stringArray = {
|
|
14576
|
+
const stringArray = (semantics, description) => ({
|
|
14243
14577
|
type: "array",
|
|
14244
|
-
items: { type: "string" }
|
|
14245
|
-
|
|
14578
|
+
items: { type: "string" },
|
|
14579
|
+
description: arraySemanticsDescription(semantics, description)
|
|
14580
|
+
});
|
|
14246
14581
|
const root = {
|
|
14247
14582
|
type: "string",
|
|
14248
14583
|
description: "Project root. Passing it selects this project for the current session and persists the choice. When omitted: session binding -> unique project in the workspace -> single registered project -> configured projectRoot -> error. Multiple projects require an explicit choice."
|
|
@@ -14284,7 +14619,8 @@ function createNovelTools(engine, options = {}) {
|
|
|
14284
14619
|
description: { type: "string" },
|
|
14285
14620
|
words_per_chapter: {
|
|
14286
14621
|
type: "array",
|
|
14287
|
-
items: { type: "integer" }
|
|
14622
|
+
items: { type: "integer" },
|
|
14623
|
+
description: arraySemanticsDescription("replace", "Fixed [min,max] word target for chapters.")
|
|
14288
14624
|
},
|
|
14289
14625
|
total_chapters: { type: "integer" },
|
|
14290
14626
|
premise: {
|
|
@@ -14292,11 +14628,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14292
14628
|
description: "Optional one-sentence premise for the book plan."
|
|
14293
14629
|
},
|
|
14294
14630
|
logline: { type: "string" },
|
|
14295
|
-
themes: stringArray,
|
|
14631
|
+
themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
|
|
14296
14632
|
tone: { type: "string" },
|
|
14297
14633
|
pov: { type: "string" },
|
|
14298
14634
|
audience: { type: "string" },
|
|
14299
|
-
selling_points: stringArray,
|
|
14635
|
+
selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
|
|
14300
14636
|
ending: { type: "string" },
|
|
14301
14637
|
strict_outline: { type: "boolean" },
|
|
14302
14638
|
project_root: initRoot
|
|
@@ -14393,10 +14729,16 @@ function createNovelTools(engine, options = {}) {
|
|
|
14393
14729
|
type: "string",
|
|
14394
14730
|
required: true
|
|
14395
14731
|
},
|
|
14396
|
-
constraints: stringArray,
|
|
14397
|
-
characters: {
|
|
14398
|
-
|
|
14399
|
-
|
|
14732
|
+
constraints: stringArray("replace", "P1 constraints; supplied list replaces the plan list."),
|
|
14733
|
+
characters: {
|
|
14734
|
+
type: "json",
|
|
14735
|
+
description: arraySemanticsDescription("replace", "P1 cast; supplied list replaces the cast.")
|
|
14736
|
+
},
|
|
14737
|
+
locations: {
|
|
14738
|
+
type: "json",
|
|
14739
|
+
description: arraySemanticsDescription("replace", "P1 locations; supplied list replaces them.")
|
|
14740
|
+
},
|
|
14741
|
+
continuity_notes: stringArray("replace", "P1 continuity notes; supplied list replaces them."),
|
|
14400
14742
|
project_root: root
|
|
14401
14743
|
},
|
|
14402
14744
|
output: jsonOutput,
|
|
@@ -14428,7 +14770,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
14428
14770
|
required: true
|
|
14429
14771
|
},
|
|
14430
14772
|
beats: {
|
|
14431
|
-
...stringArray,
|
|
14773
|
+
...stringArray("replace", "Ordered plan beats; supplied list replaces the beats."),
|
|
14432
14774
|
required: true
|
|
14433
14775
|
},
|
|
14434
14776
|
opening: {
|
|
@@ -14442,11 +14784,12 @@ function createNovelTools(engine, options = {}) {
|
|
|
14442
14784
|
target_words: {
|
|
14443
14785
|
type: "array",
|
|
14444
14786
|
items: { type: "integer" },
|
|
14445
|
-
required: true
|
|
14787
|
+
required: true,
|
|
14788
|
+
description: arraySemanticsDescription("replace", "Plan [min,max] word target.")
|
|
14446
14789
|
},
|
|
14447
14790
|
cast: {
|
|
14448
14791
|
type: "json",
|
|
14449
|
-
description: "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger."
|
|
14792
|
+
description: arraySemanticsDescription("replace", "Optional 1–8 characters on stage this chapter. Each: name, entry_state, goal, knowledge, exit_change. Names must exist in the P1 world plan or the character ledger.")
|
|
14450
14793
|
},
|
|
14451
14794
|
project_root: root
|
|
14452
14795
|
},
|
|
@@ -14517,9 +14860,13 @@ function createNovelTools(engine, options = {}) {
|
|
|
14517
14860
|
},
|
|
14518
14861
|
dimensions: {
|
|
14519
14862
|
type: "json",
|
|
14520
|
-
required: true
|
|
14863
|
+
required: true,
|
|
14864
|
+
description: arraySemanticsDescription("replace", "Fifteen scored dimensions.")
|
|
14865
|
+
},
|
|
14866
|
+
issues: {
|
|
14867
|
+
type: "json",
|
|
14868
|
+
description: arraySemanticsDescription("replace", "Review issue list.")
|
|
14521
14869
|
},
|
|
14522
|
-
issues: { type: "json" },
|
|
14523
14870
|
total: {
|
|
14524
14871
|
type: "integer",
|
|
14525
14872
|
required: true,
|
|
@@ -14527,7 +14874,8 @@ function createNovelTools(engine, options = {}) {
|
|
|
14527
14874
|
},
|
|
14528
14875
|
proof: {
|
|
14529
14876
|
type: "json",
|
|
14530
|
-
required: true
|
|
14877
|
+
required: true,
|
|
14878
|
+
description: arraySemanticsDescription("replace", "Three reading-proof quotes.")
|
|
14531
14879
|
},
|
|
14532
14880
|
project_root: root
|
|
14533
14881
|
},
|
|
@@ -14560,12 +14908,12 @@ function createNovelTools(engine, options = {}) {
|
|
|
14560
14908
|
summary: { type: "json" },
|
|
14561
14909
|
issues: {
|
|
14562
14910
|
type: "json",
|
|
14563
|
-
description: "Updated issue statuses and evidence for PG-08."
|
|
14911
|
+
description: arraySemanticsDescription("replace", "Updated issue statuses and evidence for PG-08.")
|
|
14564
14912
|
},
|
|
14565
14913
|
proof: {
|
|
14566
14914
|
type: "json",
|
|
14567
14915
|
required: true,
|
|
14568
|
-
description: "Three quotes from the revised text; PG-09 verifies them."
|
|
14916
|
+
description: arraySemanticsDescription("replace", "Three quotes from the revised text; PG-09 verifies them.")
|
|
14569
14917
|
},
|
|
14570
14918
|
project_root: root
|
|
14571
14919
|
},
|
|
@@ -14588,16 +14936,16 @@ function createNovelTools(engine, options = {}) {
|
|
|
14588
14936
|
type: "integer",
|
|
14589
14937
|
required: true
|
|
14590
14938
|
},
|
|
14591
|
-
scope: stringArray,
|
|
14939
|
+
scope: stringArray("replace", "Requested check scopes; supplied list replaces the request."),
|
|
14592
14940
|
semantic: {
|
|
14593
14941
|
type: "json",
|
|
14594
14942
|
required: true,
|
|
14595
|
-
description: "Exactly the five P6 semantic items, each with a verbatim evidence quote."
|
|
14943
|
+
description: arraySemanticsDescription("replace", "Exactly the five P6 semantic items, each with a verbatim evidence quote.")
|
|
14596
14944
|
},
|
|
14597
14945
|
acknowledge: {
|
|
14598
14946
|
type: "array",
|
|
14599
14947
|
items: { type: "string" },
|
|
14600
|
-
description: "Advisory codes read and accepted; blockers cannot be acknowledged."
|
|
14948
|
+
description: arraySemanticsDescription("replace", "Advisory codes read and accepted; blockers cannot be acknowledged.")
|
|
14601
14949
|
},
|
|
14602
14950
|
project_root: root
|
|
14603
14951
|
},
|
|
@@ -14696,7 +15044,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
14696
15044
|
chapter_range: {
|
|
14697
15045
|
type: "array",
|
|
14698
15046
|
items: { type: "integer" },
|
|
14699
|
-
description: "For kind=chapters: inclusive [start, end] range."
|
|
15047
|
+
description: arraySemanticsDescription("replace", "For kind=chapters: inclusive [start, end] range.")
|
|
14700
15048
|
},
|
|
14701
15049
|
project_root: root
|
|
14702
15050
|
},
|
|
@@ -14864,11 +15212,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14864
15212
|
parameters: {
|
|
14865
15213
|
premise: { type: "string" },
|
|
14866
15214
|
logline: { type: "string" },
|
|
14867
|
-
themes: stringArray,
|
|
15215
|
+
themes: stringArray("replace", "Book themes; supplied list replaces the theme list."),
|
|
14868
15216
|
tone: { type: "string" },
|
|
14869
15217
|
pov: { type: "string" },
|
|
14870
15218
|
audience: { type: "string" },
|
|
14871
|
-
selling_points: stringArray,
|
|
15219
|
+
selling_points: stringArray("replace", "Selling points; supplied list replaces the list."),
|
|
14872
15220
|
ending: { type: "string" },
|
|
14873
15221
|
strict: {
|
|
14874
15222
|
type: "boolean",
|
|
@@ -14876,7 +15224,11 @@ function createNovelTools(engine, options = {}) {
|
|
|
14876
15224
|
},
|
|
14877
15225
|
volumes: {
|
|
14878
15226
|
type: "json",
|
|
14879
|
-
description: "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes."
|
|
15227
|
+
description: arraySemanticsDescription("replace", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Replaces the current volumes; dropping any volume requires confirm_remove:true.")
|
|
15228
|
+
},
|
|
15229
|
+
confirm_remove: {
|
|
15230
|
+
type: "boolean",
|
|
15231
|
+
description: "Explicitly confirm a volume-list replacement that drops existing volumes."
|
|
14880
15232
|
},
|
|
14881
15233
|
project_root: root
|
|
14882
15234
|
},
|
|
@@ -14892,12 +15244,13 @@ function createNovelTools(engine, options = {}) {
|
|
|
14892
15244
|
ending: args.ending,
|
|
14893
15245
|
strict: args.strict,
|
|
14894
15246
|
volumes: asJson(args.volumes, "volumes"),
|
|
15247
|
+
confirm_remove: args.confirm_remove,
|
|
14895
15248
|
root: args.project_root
|
|
14896
15249
|
}))
|
|
14897
15250
|
}),
|
|
14898
15251
|
defineTool({
|
|
14899
15252
|
name: "novel_outline",
|
|
14900
|
-
description: "
|
|
15253
|
+
description: "Upsert volume/chapter cards by key: chapters by num, volumes by title. Omitted cards are preserved; deletion requires remove/remove_volumes. Archived cards are preserved and cannot be rewritten. Use dry_run to preview. On the first submission strict mode defaults on; with an existing card, PG-13 checks title, scene, target words and cast even in non-strict mode.",
|
|
14901
15254
|
parameters: {
|
|
14902
15255
|
book: {
|
|
14903
15256
|
type: "json",
|
|
@@ -14905,12 +15258,25 @@ function createNovelTools(engine, options = {}) {
|
|
|
14905
15258
|
},
|
|
14906
15259
|
volumes: {
|
|
14907
15260
|
type: "json",
|
|
14908
|
-
description: "
|
|
15261
|
+
description: arraySemanticsDescription("upsert", "Volume list: [{title,start_chapter,end_chapter,goal?,arc?,climax?,status?}]. Upserts by title; omitted volumes are kept.")
|
|
14909
15262
|
},
|
|
14910
15263
|
chapters: {
|
|
14911
15264
|
type: "json",
|
|
14912
|
-
|
|
14913
|
-
|
|
15265
|
+
description: arraySemanticsDescription("upsert", "Chapter cards: [{num,title,scene_type,goal,conflict,hook,cast?,target_words:[min,max],volume?,notes?}]. Upserts by num; omitted cards are kept.")
|
|
15266
|
+
},
|
|
15267
|
+
remove: {
|
|
15268
|
+
type: "array",
|
|
15269
|
+
items: { type: "integer" },
|
|
15270
|
+
description: arraySemanticsDescription("remove", "Explicit chapter numbers to delete.")
|
|
15271
|
+
},
|
|
15272
|
+
remove_volumes: {
|
|
15273
|
+
type: "array",
|
|
15274
|
+
items: { type: "string" },
|
|
15275
|
+
description: arraySemanticsDescription("remove", "Explicit volume titles to delete.")
|
|
15276
|
+
},
|
|
15277
|
+
dry_run: {
|
|
15278
|
+
type: "boolean",
|
|
15279
|
+
description: "Preview changes without writing or journaling."
|
|
14914
15280
|
},
|
|
14915
15281
|
strict: { type: "boolean" },
|
|
14916
15282
|
project_root: root
|
|
@@ -14920,6 +15286,9 @@ function createNovelTools(engine, options = {}) {
|
|
|
14920
15286
|
book: asJson(args.book, "book"),
|
|
14921
15287
|
volumes: asJson(args.volumes, "volumes"),
|
|
14922
15288
|
chapters: asJson(args.chapters, "chapters"),
|
|
15289
|
+
remove: args.remove,
|
|
15290
|
+
remove_volumes: args.remove_volumes,
|
|
15291
|
+
dry_run: args.dry_run,
|
|
14923
15292
|
strict: args.strict,
|
|
14924
15293
|
root: args.project_root
|
|
14925
15294
|
}))
|
|
@@ -14958,17 +15327,35 @@ function createNovelTools(engine, options = {}) {
|
|
|
14958
15327
|
parameters: {
|
|
14959
15328
|
premise: { type: "string" },
|
|
14960
15329
|
setting: { type: "string" },
|
|
14961
|
-
rules: {
|
|
14962
|
-
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
|
|
15330
|
+
rules: {
|
|
15331
|
+
type: "json",
|
|
15332
|
+
description: arraySemanticsDescription("replace", "Rules section list.")
|
|
15333
|
+
},
|
|
15334
|
+
factions: {
|
|
15335
|
+
type: "json",
|
|
15336
|
+
description: arraySemanticsDescription("replace", "Factions section list.")
|
|
15337
|
+
},
|
|
15338
|
+
locations: {
|
|
15339
|
+
type: "json",
|
|
15340
|
+
description: arraySemanticsDescription("replace", "Locations section list.")
|
|
15341
|
+
},
|
|
15342
|
+
characters: {
|
|
15343
|
+
type: "json",
|
|
15344
|
+
description: arraySemanticsDescription("replace", "Characters section list.")
|
|
15345
|
+
},
|
|
15346
|
+
glossary: {
|
|
15347
|
+
type: "json",
|
|
15348
|
+
description: arraySemanticsDescription("replace", "Glossary section list.")
|
|
15349
|
+
},
|
|
15350
|
+
backstory: {
|
|
15351
|
+
type: "json",
|
|
15352
|
+
description: arraySemanticsDescription("replace", "Backstory section list.")
|
|
15353
|
+
},
|
|
14967
15354
|
facts: {
|
|
14968
15355
|
type: "json",
|
|
14969
|
-
description: "Declared numeric canon: [{alias,value,unit,tolerance?,note?}]."
|
|
15356
|
+
description: arraySemanticsDescription("replace", "Declared numeric canon: [{alias,value,unit,tolerance?,note?}].")
|
|
14970
15357
|
},
|
|
14971
|
-
themes: stringArray,
|
|
15358
|
+
themes: stringArray("replace", "Worldbook themes; supplied list replaces the list."),
|
|
14972
15359
|
project_root: root
|
|
14973
15360
|
},
|
|
14974
15361
|
output: jsonOutput,
|
|
@@ -15060,7 +15447,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
15060
15447
|
}),
|
|
15061
15448
|
defineTool({
|
|
15062
15449
|
name: "novel_restore",
|
|
15063
|
-
description: "Restore an active chapter file from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
|
|
15450
|
+
description: "Restore an active chapter file, or outline/outline.yaml, from a marked backup transaction. Archived chapters are refused; use novel_reopen first. Every restore is itself journaled and backed up.",
|
|
15064
15451
|
parameters: {
|
|
15065
15452
|
txid: {
|
|
15066
15453
|
type: "string",
|
|
@@ -15070,7 +15457,7 @@ function createNovelTools(engine, options = {}) {
|
|
|
15070
15457
|
paths: {
|
|
15071
15458
|
type: "array",
|
|
15072
15459
|
items: { type: "string" },
|
|
15073
|
-
description: "Project-relative chapter files; omitted restores every restorable chapter file
|
|
15460
|
+
description: arraySemanticsDescription("replace", "Project-relative chapter files, or exactly [outline/outline.yaml]; omitted restores every restorable chapter file the manifest records.")
|
|
15074
15461
|
},
|
|
15075
15462
|
project_root: root
|
|
15076
15463
|
},
|
|
@@ -16153,4 +16540,4 @@ function connectionFence(scope) {
|
|
|
16153
16540
|
}
|
|
16154
16541
|
if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
|
|
16155
16542
|
}
|
|
16156
|
-
export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
|
|
16543
|
+
export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
|