story bible

This commit is contained in:
2026-03-02 22:13:09 +10:00
parent 84377c82af
commit aafebbc9fb
15 changed files with 557 additions and 77 deletions

View File

@@ -1,5 +1,6 @@
import Anthropic from '@anthropic-ai/sdk'
import type { AIPayload, Attachment } from '../renderer/types/editor'
import type { DraftDocument } from './fileSystem'
let _client: Anthropic | null = null
@@ -16,11 +17,78 @@ if (!apiKey || apiKey === 'your-api-key-here') {
return _client
}
function buildSystemPrompt(payload: AIPayload): string {
const MAX_MANUSCRIPT_CHARS = 560_000 // ~140k tokens at 4 chars/token
function buildStoryBibleBlock(content: string): string {
return [
'=== STORY BIBLE ===',
"The following is the author's curated reference document. Treat it as authoritative ground truth for characters, world, timeline, and consistency rules.",
'',
content,
'=== END STORY BIBLE ==='
].join('\n')
}
function buildManuscriptBlock(allDocs: DraftDocument[], currentPath: string): string {
const total = allDocs.length
const totalChars = allDocs.reduce((s, d) => s + d.content.length, 0)
let docs = allDocs
let truncatedCount = 0
if (totalChars > MAX_MANUSCRIPT_CHARS) {
const currentIdx = allDocs.findIndex(d => d.path === currentPath)
const pivot = currentIdx !== -1 ? currentIdx : 0
const priority: number[] = [pivot]
let lo = pivot - 1
let hi = pivot + 1
while (lo >= 0 || hi < allDocs.length) {
if (hi < allDocs.length) priority.push(hi++)
if (lo >= 0) priority.push(lo--)
}
let budget = MAX_MANUSCRIPT_CHARS
const included = new Set<number>()
for (const idx of priority) {
if (budget <= 0) break
if (budget - allDocs[idx].content.length >= 0) {
budget -= allDocs[idx].content.length
included.add(idx)
}
}
truncatedCount = allDocs.length - included.size
docs = allDocs.filter((_, i) => included.has(i))
}
const lines: string[] = [
'=== MANUSCRIPT: Full Story Context ===',
`The following ${docs.length} chapter(s) contain the novel in narrative order.`,
'Use them for cross-chapter analysis: consistency, character arcs, narrative structure.',
''
]
for (let i = 0; i < docs.length; i++) {
lines.push(`--- [${i + 1}/${total}] ${docs[i].relativePath} ---`)
lines.push(docs[i].content)
lines.push('')
}
if (truncatedCount > 0) {
lines.push(`[Note: ${truncatedCount} chapter(s) omitted to fit context. Chapters closest to the current chapter were prioritised.]`)
}
lines.push('=== END MANUSCRIPT ===')
return lines.join('\n')
}
function buildSystemPrompt(payload: AIPayload, allDocs?: DraftDocument[], storyBibleContent?: string): string {
const chapterName =
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
const chapterContext = `You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
const storyBibleSection = storyBibleContent
? 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}".
Key characters: Esti, Marko, Garbi, Irati, Cardinal Nikolai, Amaya, Izotz, Sua, Señor Jiménez, the Genboa family.
@@ -165,6 +233,8 @@ function buildUserContent(
export async function streamMessage(
payload: AIPayload,
allDocs: DraftDocument[] | undefined,
storyBibleContent: string | undefined,
onChunk: (chunk: string) => void
): Promise<void> {
const client = getClient()
@@ -180,7 +250,7 @@ export async function streamMessage(
const stream = client.messages.stream({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
system: buildSystemPrompt(payload),
system: buildSystemPrompt(payload, allDocs, storyBibleContent),
messages
})

View File

@@ -8,6 +8,31 @@ const DRAFT_ROOT =
const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
const SESSION_FILE = join(DRAFT_ROOT, '.session.json')
const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions')
const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff')
export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md')
const STORY_BIBLE_TEMPLATE = `# Story Bible
## Characters
<!-- Profile each character: role, appearance, personality, arc, key relationships -->
## World & Setting
<!-- The Basque Country: geography, historical period, cultural details, atmosphere -->
## Timeline
<!-- Key events in chronological order -->
## Themes & Motifs
<!-- Recurring symbols, imagery, thematic concerns -->
## Continuity Rules
<!-- Facts Claude must always respect: established plot points, internal logic, naming conventions -->
`
const MAX_REVISIONS = 50
@@ -122,6 +147,60 @@ function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
}
export interface DraftDocument {
path: string
relativePath: string // e.g. "Part I/Chapter 1"
content: string
}
function flattenFileNodes(nodes: FileNode[]): string[] {
const paths: string[] = []
for (const node of nodes) {
if (node.type === 'file') paths.push(node.path)
else if (node.children) paths.push(...flattenFileNodes(node.children))
}
return paths
}
export async function readAllDraftFiles(): Promise<DraftDocument[]> {
const tree = await listDraftFiles()
// Exclude Story Bible.md — it is injected separately via readStoryBibleFile()
const paths = flattenFileNodes(tree).filter(p => p !== STORY_BIBLE_PATH)
const prefix = DRAFT_ROOT + '/'
return Promise.all(
paths.map(async (p) => ({
path: p,
relativePath: (p.startsWith(prefix) ? p.slice(prefix.length) : p).replace(/\.md$/, ''),
content: await readFile(p, 'utf-8')
}))
)
}
export async function openStoryBibleFile(): Promise<{ path: string; content: string }> {
await mkdir(HOHOFF_DIR, { recursive: true })
let content: string
try {
content = await readFile(STORY_BIBLE_PATH, 'utf-8')
} catch {
content = STORY_BIBLE_TEMPLATE
await writeFile(STORY_BIBLE_PATH, content, 'utf-8')
}
return { path: STORY_BIBLE_PATH, content }
}
export async function writeStoryBibleFile(content: string): Promise<void> {
await mkdir(HOHOFF_DIR, { recursive: true })
await writeFile(STORY_BIBLE_PATH, content, 'utf-8')
}
export async function readStoryBibleFile(): Promise<string | null> {
try {
return await readFile(STORY_BIBLE_PATH, 'utf-8')
} catch {
return null
}
}
async function collectMarkdownPaths(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths: string[] = []

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 } from './fileSystem'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readAllDraftFiles, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload, Attachment } from '../renderer/types/editor'
@@ -70,6 +70,14 @@ export function registerIpcHandlers(): void {
return await moveFileOrDir(sourcePath, targetDirPath)
})
ipcMain.handle('fs:openStoryBible', async () => {
return await openStoryBibleFile()
})
ipcMain.handle('fs:writeStoryBible', async (_event, content: string) => {
await writeStoryBibleFile(content)
})
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
const win = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(win!, {
@@ -115,7 +123,9 @@ export function registerIpcHandlers(): void {
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
await streamMessage(payload, (chunk: string) => {
const allDocs = payload.projectMode ? await readAllDraftFiles() : undefined
const storyBibleContent = payload.storyBibleMode ? (await readStoryBibleFile() ?? undefined) : undefined
await streamMessage(payload, allDocs, storyBibleContent, (chunk: string) => {
if (!event.sender.isDestroyed()) {
event.sender.send('ai:chunk', chunk)
}