Add ChapterKind for front/back matter chapters

Chapters can now be marked FrontMatter/Body/BackMatter. Number stays
the manuscript sort key for every chapter; the author-facing display
number is now computed per-request as the chapter's ordinal among
Body chapters only, so a foreword or afterword no longer shifts the
numbering of the rest of the book. Surfaced through the API, agent
toolset, and import toolset.
This commit is contained in:
James Wampler
2026-08-19 17:54:20 -07:00
parent 71953220aa
commit ef5260a111
12 changed files with 1526 additions and 27 deletions
+55 -18
View File
@@ -385,7 +385,12 @@ public class NovelAgentToolset(
"list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(),
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
async (novelId, _, ct) =>
{
var list = await chapters.ListAsync(novelId, ct);
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
});
yield return new AgentTool(
"get_chapter",
@@ -396,15 +401,25 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId);
var chapter = await chapters.GetAsync(chapterId, ct);
if (chapter is null)
{
return new ToolNotFound("Chapter", chapterId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
});
yield return new AgentTool(
"create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
+ "Front matter (foreword, introduction, prologue) and back matter (afterword, about the "
+ "author) are labeled by title alone and do not count against the numbered chapters.",
new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.")
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.")
@@ -413,26 +428,39 @@ public class NovelAgentToolset(
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
async (novelId, input, ct) =>
{
var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct);
if (chapter is null)
{
return new ToolNotFound("Novel", novelId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
});
yield return new AgentTool(
"update_chapter",
"Revise a chapter's title, number, summary, locations, notes, status or drafted "
"Revise a chapter's title, number, kind, summary, locations, notes, status or drafted "
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
.Int("number", "Position in the manuscript.")
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.")
@@ -444,18 +472,27 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync(
var chapter = await chapters.UpdateAsync(
chapterId,
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind"),
JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
JsonInput.Strings(input, "tags")), ct);
if (chapter is null)
{
return new ToolNotFound("Chapter", chapterId);
}
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber);
});
yield return new AgentTool(