manuscript reference as a tool

This commit is contained in:
2026-03-03 10:55:41 +10:00
parent d15b134d43
commit cc00c40214
7 changed files with 70 additions and 63 deletions

View File

@@ -1,6 +1,7 @@
import Anthropic from '@anthropic-ai/sdk'
import type { AIPayload, Attachment } from '../renderer/types/editor'
import type { DraftDocument } from './fileSystem'
import { readAllDraftFiles } from './fileSystem'
let _client: Anthropic | null = null
@@ -19,6 +20,15 @@ if (!apiKey || apiKey === 'your-api-key-here') {
const MAX_MANUSCRIPT_CHARS = 560_000 // ~140k tokens at 4 chars/token
const getManuscriptTool: Anthropic.Tool = {
name: 'get_manuscript',
description:
'Fetches all draft chapters in narrative order. ' +
'Call this when the user asks about events, characters, or passages beyond the current chapter, ' +
'or when a cross-chapter analysis (consistency, arcs, narrative structure) would benefit from the full novel.',
input_schema: { type: 'object', properties: {}, required: [] }
}
function buildStoryBibleBlock(content: string): string {
return [
'=== STORY BIBLE ===',
@@ -76,7 +86,7 @@ function buildManuscriptBlock(allDocs: DraftDocument[], currentPath: string): st
return lines.join('\n')
}
function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyBibleContent?: string): string {
function buildSystemPrompt(payload: AIPayload, storyBibleContent?: string): string {
const chapterName =
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
@@ -84,11 +94,7 @@ function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyB
? buildStoryBibleBlock(storyBibleContent) + '\n\n'
: ''
const manuscriptSection = allDocs
? buildManuscriptBlock(allDocs, payload.documentPath) + '\n\n'
: ''
const chapterContext = `${storyBibleSection}${manuscriptSection}You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
const chapterContext = `${storyBibleSection}You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
Key characters: Esti, Marko, Garbi, Irati, Cardinal Nikolai, Amaya, Izotz, Sua, Señor Jiménez, the Genboa family.
@@ -233,7 +239,6 @@ function buildUserContent(
export async function streamMessage(
payload: AIPayload,
allDocs: DraftDocument[] | undefined,
storyBibleContent: string | undefined,
onChunk: (chunk: string) => void
): Promise<void> {
@@ -247,10 +252,14 @@ export async function streamMessage(
}
]
const systemPrompt = buildSystemPrompt(payload, storyBibleContent)
// Phase 1 — initial response, with get_manuscript tool available
const stream = client.messages.stream({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: buildSystemPrompt(payload, allDocs, storyBibleContent),
system: systemPrompt,
tools: [getManuscriptTool],
messages
})
@@ -262,4 +271,51 @@ export async function streamMessage(
onChunk(chunk.delta.text)
}
}
const finalMsg = await stream.finalMessage()
// Phase 2 — only if Claude requested the manuscript
if (finalMsg.stop_reason === 'tool_use') {
const toolUse = finalMsg.content.find(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' && b.name === 'get_manuscript'
)
if (toolUse) {
onChunk('\n\n*Reading manuscript…*\n\n')
const allDocs = await readAllDraftFiles()
const manuscriptText = buildManuscriptBlock(allDocs, payload.documentPath)
const stream2 = client.messages.stream({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: systemPrompt,
tools: [getManuscriptTool],
messages: [
...messages,
{ role: 'assistant', content: finalMsg.content },
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: toolUse.id,
content: manuscriptText
}
]
}
]
})
for await (const chunk of stream2) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
onChunk(chunk.delta.text)
}
}
await stream2.finalMessage()
}
}
}

View File

@@ -1,7 +1,7 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync } from 'fs'
import { extname, basename } from 'path'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readAllDraftFiles, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload, Attachment } from '../renderer/types/editor'
@@ -123,9 +123,8 @@ export function registerIpcHandlers(): void {
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
const allDocs = payload.projectMode ? await readAllDraftFiles() : undefined
const storyBibleContent = payload.storyBibleMode ? (await readStoryBibleFile() ?? undefined) : undefined
await streamMessage(payload, allDocs, storyBibleContent, (chunk: string) => {
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
await streamMessage(payload, storyBibleContent, (chunk: string) => {
if (!event.sender.isDestroyed()) {
event.sender.send('ai:chunk', chunk)
}