Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+44 -35
View File
@@ -104,6 +104,11 @@ public class NovelAgentToolset(
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<TEntity, TResponse>(
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
@@ -117,7 +122,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId));
yield return new AgentTool(
"update_project_brief",
@@ -139,20 +144,20 @@ public class NovelAgentToolset(
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse()));
yield return new AgentTool(
"create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
async (projectId, input, ct) => (await characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -169,7 +174,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_character",
@@ -199,7 +204,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
@@ -209,7 +214,7 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Build(),
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse()));
yield return new AgentTool(
"create_beat",
@@ -219,7 +224,7 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true)
.Build(),
async (_, input, ct) => await beats.CreateAsync(
async (_, input, ct) => (await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
@@ -228,7 +233,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_beat",
@@ -250,7 +255,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), "Beat", beatId);
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
});
yield return new AgentTool(
@@ -273,12 +278,12 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
async (_, input, ct) => (await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
.Where(g => g != Guid.Empty)]), ct)).Select(b => b.ToResponse()));
yield return new AgentTool(
"list_tags",
@@ -297,14 +302,14 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId);
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
});
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with scene and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool(
"get_chapter",
@@ -315,7 +320,7 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId);
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool(
@@ -332,7 +337,7 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
async (projectId, input, ct) => (await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
@@ -341,7 +346,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
JsonInput.Strings(input, "tags")), ct)).ToResponse());
yield return new AgentTool(
"update_chapter",
@@ -372,7 +377,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool(
@@ -383,7 +388,7 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true)
.Build(),
async (_, input, ct) => await scenes.CreateAsync(
async (_, input, ct) => (await scenes.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateSceneRequest(
JsonInput.RequiredString(input, "title"),
@@ -395,7 +400,7 @@ public class NovelAgentToolset(
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct)).ToResponse());
yield return new AgentTool(
"update_scene",
@@ -420,7 +425,7 @@ public class NovelAgentToolset(
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId);
});
yield return new AgentTool(
@@ -434,7 +439,11 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse()),
"Character",
characterId);
});
yield return new AgentTool(
@@ -444,8 +453,8 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
async (_, input, ct) => (await arcs.ListAsync(
JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse()));
yield return new AgentTool(
"add_arc_stage",
@@ -455,13 +464,13 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to add to.", required: true)
.Str("title", "A short handle for the change, three to five words.", required: true)
.Build(),
async (_, input, ct) => await arcs.CreateAsync(
async (_, input, ct) => (await arcs.CreateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
JsonInput.Guid(input, "chapter_id")), ct)).ToResponse());
yield return new AgentTool(
"update_arc_stage",
@@ -479,7 +488,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId);
JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
@@ -502,10 +511,10 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
.Build(),
async (_, input, ct) => await arcs.ReorderAsync(
async (_, input, ct) => (await arcs.ReorderAsync(
JsonInput.RequiredGuid(input, "character_id"),
new ReorderArcStagesRequest(
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct));
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)).Select(s => s.ToResponse()));
yield return new AgentTool(
"list_open_questions",
@@ -516,12 +525,12 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(),
async (projectId, input, ct) => await questions.ListAsync(
async (projectId, input, ct) => (await questions.ListAsync(
projectId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct));
ct)).Select(q => q.ToResponse()));
yield return new AgentTool(
"raise_open_question",
@@ -534,13 +543,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.")
.Build(),
async (projectId, input, ct) => await questions.CreateAsync(
async (projectId, input, ct) => (await questions.CreateAsync(
projectId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct));
JsonInput.Guid(input, "character_id")), ct)).ToResponse());
yield return new AgentTool(
"resolve_open_question",
@@ -558,7 +567,7 @@ public class NovelAgentToolset(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId);
});
yield return new AgentTool(
@@ -570,7 +579,7 @@ public class NovelAgentToolset(
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId);
});
yield return new AgentTool(