Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook

The layered split into Domain/Application/Infrastructure/Api was forcing
organisation by layer: adding one capability meant touching four projects and
four folders that each held a slice of it. Those four projects are now one
feature-organised Novelly.Api, where each folder — Projects, Characters,
Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and
endpoints together. Common/ holds what genuinely crosses features (the patch
semantics, the two exception types, DraftStatus) and Data/ holds the DbContext
and migrations.

Six .NET projects become five: the three layer projects are gone, and
Novelly.AppHost and Novelly.ServiceDefaults are new.

- Namespaces move from NovelSoftware.* to Novelly.*, including the entity type
  names recorded in the EF model snapshots. The migration ids are untouched, so
  an existing novel.db still migrates cleanly — verified against a fresh file.
- Aspire orchestration mirrors the mic-check setup: the AppHost starts the API
  on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry,
  health checks and service discovery from ServiceDefaults. /health and /alive
  now answer in development.
- A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web
  build. The scripts are plain bash so CI can run the same steps.
- The MCP server's env var is now NOVELLY_API_URL.

Verified beyond the build: 44 tests pass, the web client builds, the API was
exercised over curl (project/chapter/beat/tag round trip, tag cross-reference,
503 on the agent without a key while conversation listing still returns 200),
the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface
the API's own message rather than being flattened), and the AppHost was run to
confirm both resources come up and Vite proxies /api through to the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 30e0c6926e
commit 725758ccd9
120 changed files with 811 additions and 421 deletions
+265
View File
@@ -0,0 +1,265 @@
using System.Text.Json;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Agent;
/// <summary>
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
/// conversation, and returns the finished turn together with a record of what it changed.
/// </summary>
public class NovelAgentService(
INovelDbContext db,
IAgentModelClient model,
NovelAgentToolset toolset,
IOptions<AgentOptions> options,
ILogger<NovelAgentService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) =>
await db.Conversations
.Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct);
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{
var conversation = await LoadConversationAsync(conversationId, ct);
return new ConversationDto(
conversation.Id,
conversation.ProjectId,
conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
conversation.UpdatedAt);
}
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{
var conversation = await LoadConversationAsync(conversationId, ct);
db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn.
/// </summary>
public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
var conversation = request.ConversationId is { } id
? await LoadConversationAsync(id, ct)
: await StartConversationAsync(projectId, request.Message, ct);
// Persist the user's turn before running the loop. The tools save through the
// same DbContext, so leaving this pending would entangle it with their writes —
// and recording the question even if the model call fails is the behaviour we want.
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallDto>();
var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
foreach (var block in response.Content.OfType<AgentTextBlock>())
{
if (!string.IsNullOrWhiteSpace(block.Text))
{
text.AppendLine(block.Text.Trim());
}
}
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0)
{
break;
}
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
// single user turn — splitting the results would train the model out of
// requesting tools in parallel.
transcript.Add(AgentChatMessage.Assistant(response.Content));
var results = new List<AgentContentBlock>();
foreach (var call in requestedTools)
{
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
logger.LogInformation(
"Agent tool {Tool} on project {ProjectId} {Outcome}",
call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
}
transcript.Add(AgentChatMessage.User([.. results]));
if (iteration == _options.MaxIterations - 1)
{
logger.LogWarning(
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
_options.MaxIterations, projectId);
text.AppendLine(
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
}
}
var reply = await AppendMessageAsync(
conversation,
AgentRole.Assistant,
text.ToString().TrimEnd(),
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
ct);
return new AgentTurnDto(conversation.Id, ToDto(reply));
}
/// <summary>
/// Appends a turn and commits it. Messages are added to the set directly rather than
/// through the parent's collection so their insert never depends on EF discovering
/// the graph change at an inconvenient moment.
/// </summary>
private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{
var message = new AgentMessage
{
ConversationId = conversation.Id,
Role = role,
Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1,
Content = content,
ToolCallsJson = toolCallsJson
};
db.AgentMessages.Add(message);
conversation.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
// EF's relationship fixup normally puts the message into the parent's collection
// once both are tracked. Guard rather than assume, since the sequence number of
// the next turn is derived from it.
if (!conversation.Messages.Contains(message))
{
conversation.Messages.Add(message);
}
return message;
}
private async Task<AgentConversation> StartConversationAsync(
Guid projectId, string firstMessage, CancellationToken ct)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var conversation = new AgentConversation
{
ProjectId = projectId,
Title = Summarise(firstMessage)
};
db.Conversations.Add(conversation);
return conversation;
}
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
await db.Conversations
.Include(c => c.Messages)
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
/// <summary>
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
/// the agent re-reads current state through its tools, which is more reliable than
/// trusting a transcript of edits that may since have been changed in the UI.
/// </summary>
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
[
.. conversation.Messages
.Where(m => !string.IsNullOrWhiteSpace(m.Content))
.OrderBy(m => m.Sequence)
.Select(m => new AgentChatMessage(
m.Role == AgentRole.User ? "user" : "assistant",
[new AgentTextBlock(m.Content)]))
];
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
{
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
?? throw new NotFoundException(nameof(Project), projectId);
var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
return $"""
You are a developmental editor and writing partner embedded in the software the
writer is using to plan their novel. You have tools that read and write the
project's real data: the brief, character dossiers, the outline tree, chapters
and scenes.
The project you are working on:
{brief}
Working principles:
- Read before you write. Call get_project_brief, get_outline, or list_characters
to ground yourself rather than assuming what is already there.
- The book is the writer's. Ask about the choices that define the story — what a
character wants, what the ending costs them — instead of deciding for them.
- Do not invent biographical detail to fill an empty field. An unanswered
question in a dossier is more useful than a plausible-sounding fabrication.
- When you do have enough to act, act. Make the edit and say what you changed in
a sentence; do not narrate every tool call or ask permission for routine work.
- Prefer structural help — where a beat lands, whether a want and a need are
genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose.
- When drafting prose into a scene, match the voice already established in the
project. Write the scene, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first.
Keep replies short. Lead with the outcome, then the reasoning if it earns its place.
""";
}
private static AgentMessageDto ToDto(AgentMessage message) => new(
message.Id,
message.Role,
message.Content,
message.ToolCallsJson is null
? []
: JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
message.CreatedAt);
/// <summary>Derives a conversation title from its opening message.</summary>
private static string Summarise(string message)
{
var trimmed = message.Trim().ReplaceLineEndings(" ");
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
}
}