diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..790b758 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,176 @@ +# GitHub Copilot — Bindery + +Bindery is a **VS Code extension** + **MCP server** for markdown book authoring. +TypeScript throughout. Two independent packages — keep them in sync when adding features. + +--- + +## Repo layout + +``` +vscode-ext/ ← VS Code extension (published to Marketplace) + src/ + extension.ts ← activation, all commands, format-on-save handler + workspace.ts ← reads/writes .bindery/settings.json + translations.json + merge.ts ← chapter discovery, markdown assembly, pandoc/LibreOffice + format.ts ← typography transforms (curly quotes, em-dash, ellipsis) + mcp.ts ← vscode.lm.registerTool registrations + mcp.json writer + ai-setup.ts ← generates CLAUDE.md, copilot-instructions.md, skills, etc. + +mcp-ts/ ← Standalone MCP server (also bundled inside vscode-ext) + src/ + index.ts ← McpServer entry point, all server.registerTool() calls + tools.ts ← one exported function per tool (pure: root + args → string) + registry.ts ← book registry (--book flags + BINDERY_BOOKS env var) + search.ts ← BM25 index build/load/search + optional Ollama reranking + docstore.ts ← file discovery and chunking + format.ts ← typography (shared logic, duplicated from vscode-ext) + +mcpb/ ← Claude Desktop extension package (mcpb manifest + bundled server) + manifest.json ← mcpb manifest v0.3 — tools list, user_config, privacy_policies + server/ ← bundled output (copy of mcp-ts/out/) — DO NOT hand-edit +``` + +--- + +## MCP server — rules for every tool change + +The MCP server is published to the Anthropic MCPB directory. These rules are hard requirements to stay publishable. + +### Every tool MUST have exactly one annotation +```typescript +annotations: { readOnlyHint: true } // reads only — never writes files +annotations: { destructiveHint: true } // writes, creates, or modifies files +``` +No annotation = mcpb submission rejection. Both annotations on one tool = also wrong. + +| Tool behaviour | Annotation | +|---|---| +| list, get, read, search, status checks | `readOnlyHint: true` | +| write, append, overwrite, format, commit, create | `destructiveHint: true` | + +### Adding a new tool — checklist +When adding a tool, touch **all four** of these: missing any breaks one surface. + +1. **`mcp-ts/src/tools.ts`** — add the implementation function (`toolXxx(root, args): string`) +2. **`mcp-ts/src/index.ts`** — `server.registerTool(...)` with Zod schema, description, and ONE annotation +3. **`vscode-ext/src/mcp.ts`** — `vscode.lm.registerTool('bindery_xxx', ...)` + add input interface + add to `McpTools` +4. **`vscode-ext/package.json`** — add entry to `languageModelTools[]` with JSON Schema input +5. **`mcpb/manifest.json`** — add `{ "name": "xxx", "description": "..." }` to `tools[]` + +### mcpb manifest rules +- `manifest_version` must stay `"0.3"` or higher +- `privacy_policies` array must contain a valid HTTPS URL — do not remove it +- `tools[]` is a flat list of `{ name, description }` — one entry per tool, no inputSchema here +- The `server/` folder is a bundled copy of `mcp-ts/out/` — update it when releasing + +--- + +## MCP tools reference + +All tools take a `book` argument (string name from `--book` config). The VS Code surface has no `book` arg — it uses the open workspace root. + +| Tool | Annotation | Description | +|---|---|---| +| `list_books` | readOnly | List configured books | +| `identify_book` | readOnly | Match a working directory to a book | +| `health` | readOnly | Server/index/embedding status | +| `init_workspace` | destructive | Create or update .bindery/settings.json + translations.json | +| `index_build` | destructive | Build/rebuild BM25 search index | +| `index_status` | readOnly | Index chunk count and build time | +| `get_text` | readOnly | Read file by relative path, optional line range | +| `get_chapter` | readOnly | Full chapter by number + language | +| `get_overview` | readOnly | Chapter structure (acts, chapters, titles) | +| `get_notes` | readOnly | Notes/ and Details_*.md, filterable | +| `search` | readOnly | BM25 full-text search with ranked snippets | +| `retrieve_context` | readOnly | Semantic passage retrieval | +| `format` | destructive | Apply typography formatting to file/folder | +| `get_review_text` | destructive | Git diff with optional autoStage | +| `git_snapshot` | destructive | Git commit of story/notes/arc changes | +| `get_translation` | readOnly | List all rules for a language, or look up a specific word (forgiving) | +| `add_translation` | destructive | Add/update .bindery/translations.json rule | +| `memory_list` | readOnly | List Notes/Memories/ files with line counts | +| `memory_append` | destructive | Append dated session entry to a memory file | +| `memory_compact` | destructive | Overwrite memory file with summary (backs up original) | + +--- + +## VS Code extension — key design rules + +- **Generic**: no project-specific strings in source (use "Book", not a title) +- **Config priority**: `.bindery/settings.json` → VS Code workspace settings → VS Code user settings → code defaults +- **Machine paths only in user settings**: `pandocPath`, `libreOfficePath` — never in workspace settings +- **Substitution tiers**: built-in (`merge.ts`) → user-general (`bindery.generalSubstitutions`) → project (`.bindery/translations.json`). Later tiers win. +- **formatOnSave**: only fires for files inside the configured `storyFolder` +- **Activation**: `onStartupFinished` (to ensure LM tools register at launch) + `onLanguage:markdown` +- **Command namespace**: all commands are `bindery.*` — must match in both `package.json` and `extension.ts` + +## VS Code extension — commands + +| Command | Description | +|---|---| +| `bindery.init` | Create `.bindery/settings.json` + `translations.json` | +| `bindery.setupAI` | Generate CLAUDE.md / copilot-instructions.md / skills / AGENTS.md | +| `bindery.formatDocument` / `bindery.formatFolder` | Typography formatting | +| `bindery.mergeMarkdown/Docx/Epub/Pdf/All` | Merge chapters → output format | +| `bindery.findProbableUsToUkWords` | Surface probable US spellings in EN source | +| `bindery.addUkReplacement` | Add a substitution rule | +| `bindery.openTranslations` | Open translations.json | +| `bindery.registerMcp` | Write .vscode/mcp.json for Claude/Codex MCP discovery | + +--- + +## AI setup (`ai-setup.ts`) + +`setupAiFiles()` generates per-target instruction files. Each target is independent. + +| Target | Output | +|---|---| +| `claude` | `CLAUDE.md` + `.claude/skills//SKILL.md` for each selected skill | +| `copilot` | `.github/copilot-instructions.md` | +| `cursor` | `.cursor/rules` | +| `agents` | `AGENTS.md` | + +Skills: `review`, `brainstorm`, `memory`, `translate`, `status`, `continuity`, `read_aloud`. + +The **memory skill** uses `memory_list` → `memory_append` → `memory_compact`. Do not fall back to `get_text` + Edit tool for memory writes. + +### AI setup versioning +`AI_SETUP_VERSION` (integer constant in `ai-setup.ts`) controls staleness detection. + +- `setupAiFiles()` stamps `.bindery/ai-version.json` with the current version after every run. +- On extension activation, if `.bindery/settings.json` exists and the stamped version is older than `AI_SETUP_VERSION`, the user is notified and offered an "Update now" button that opens `bindery.setupAI`. +- **Bump `AI_SETUP_VERSION` by 1 whenever skill templates or instruction file templates change significantly** (i.e. existing users should regenerate). Small copy fixes do not require a bump. + +--- + +## Memory system + +Memory files live in `.bindery/memories/` inside the book root. +- `global.md` — cross-chapter decisions +- `chXX.md` — per-chapter notes (e.g. `ch10.md`) +- `archive/` — compacted originals (auto-created by `memory_compact`) + +Format of an appended entry (stamped by `memory_append`, not the caller): +``` +## Session YYYY-MM-DD — [title] +[content lines] +``` + +--- + +## Build + +```bash +# MCP server +cd mcp-ts && npm run build # outputs to mcp-ts/out/ + +# VS Code extension +cd vscode-ext && npm run compile # outputs to vscode-ext/out/ + +# Type-check only (no emit) +cd mcp-ts && npx tsc --noEmit +cd vscode-ext && npx tsc --noEmit +``` + +Before releasing mcpb: copy `mcp-ts/out/` → `mcpb/server/`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a85f9d6..2a8b21d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,9 @@ jobs: npm ci npm run compile + - name: Sync templates to vscode-ext + run: cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts + - name: Set version from tag and publish extension run: | VERSION="${GITHUB_REF_NAME#v}" diff --git a/.gitignore b/.gitignore index 5e8bed2..4779d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ Thumbs.db *.swp *.swo *~ + +# misc +.FluxCoreInfo/ +ai-setup-templates.ts \ No newline at end of file diff --git a/mcp-ts/src/aisetup.ts b/mcp-ts/src/aisetup.ts new file mode 100644 index 0000000..0e20f7d --- /dev/null +++ b/mcp-ts/src/aisetup.ts @@ -0,0 +1,147 @@ +/** + * AI instruction file generation for Bindery (MCP server). + * + * Generates CLAUDE.md, .github/copilot-instructions.md, .cursor/rules, + * AGENTS.md, and .claude/skills//SKILL.md from the book's + * .bindery/settings.json. + * + * Templates live in templates.ts — the single source of truth. + * vscode-ext/src/ai-setup.ts imports its copy via ai-setup-templates.ts. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { renderTemplate, type TemplateContext } from './templates.js'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +export type AiTarget = 'claude' | 'copilot' | 'cursor' | 'agents'; + +export type SkillTemplate = + | 'review' + | 'brainstorm' + | 'memory' + | 'translate' + | 'status' + | 'continuity' + | 'read_aloud'; + +export const ALL_SKILLS: SkillTemplate[] = [ + 'review', 'brainstorm', 'memory', 'translate', 'status', 'continuity', 'read_aloud', +]; + +export interface AiSetupOptions { + root: string; + targets: AiTarget[]; + skills?: SkillTemplate[]; + overwrite?: boolean; +} + +export interface AiSetupResult { + created: string[]; + skipped: string[]; +} + +/** + * Bump this integer whenever templates change significantly enough that + * existing users should regenerate their AI files. + * Must be kept in sync with AI_SETUP_VERSION in vscode-ext/src/ai-setup.ts. + */ +export const AI_SETUP_VERSION = 4; + +// ─── Settings types ─────────────────────────────────────────────────────────── + +interface Settings { + bookTitle?: string | Record; + author?: string; + description?: string; + genre?: string; + targetAudience?: string; + storyFolder?: string; + languages?: Array<{ code: string; folderName: string }>; +} + +// ─── Context builder ────────────────────────────────────────────────────────── + +function buildContext(s: Settings): TemplateContext { + const title = (typeof s.bookTitle === 'string' ? s.bookTitle : undefined) ?? 'Untitled'; + const author = s.author ?? ''; + const description = s.description ?? ''; + const genre = s.genre ?? ''; + const audience = s.targetAudience ?? ''; + const storyFolder = s.storyFolder ?? 'Story'; + const languages = s.languages ?? []; + + const langList = languages.length > 0 + ? languages.map((l, i) => i === 0 ? `${l.code} (source)` : `${l.code} (translation)`).join(', ') + : 'EN (source)'; + + return { + title, author, description, genre, audience, + storyFolder, notesFolder: 'Notes', arcFolder: 'Arc', + memoriesFolder: '.bindery/memories', + languages, langList, + hasMultiLang: languages.length > 1, + }; +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + +export function setupAiFiles(options: AiSetupOptions): AiSetupResult { + const { root, targets, skills = ALL_SKILLS, overwrite = false } = options; + + const settingsPath = path.join(root, '.bindery', 'settings.json'); + if (!fs.existsSync(settingsPath)) { + throw new Error('settings.json not found — run init_workspace first.'); + } + const settings: Settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Settings; + const ctx = buildContext(settings); + + const result: AiSetupResult = { created: [], skipped: [] }; + + for (const target of targets) { + switch (target) { + case 'claude': + writeFile(root, 'CLAUDE.md', renderTemplate('claude', ctx), overwrite, result); + for (const skill of skills) { + writeFile(root, path.join('.claude', 'skills', skill, 'SKILL.md'), renderTemplate(skill, ctx), overwrite, result); + } + break; + case 'copilot': + writeFile(root, path.join('.github', 'copilot-instructions.md'), renderTemplate('copilot', ctx), overwrite, result); + break; + case 'cursor': + writeFile(root, path.join('.cursor', 'rules'), renderTemplate('cursor', ctx), overwrite, result); + break; + case 'agents': + writeFile(root, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, result); + break; + } + } + + stampAiVersion(root); + return result; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function writeFile(root: string, relPath: string, content: string, overwrite: boolean, result: AiSetupResult): void { + const full = path.join(root, relPath); + if (fs.existsSync(full) && !overwrite) { + result.skipped.push(relPath); + return; + } + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, 'utf-8'); + result.created.push(relPath); +} + +function stampAiVersion(root: string): void { + const dir = path.join(root, '.bindery'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'ai-version.json'), + JSON.stringify({ version: AI_SETUP_VERSION }, null, 2) + '\n', + 'utf-8' + ); +} diff --git a/mcp-ts/src/index.ts b/mcp-ts/src/index.ts index 9130ae8..b12de22 100644 --- a/mcp-ts/src/index.ts +++ b/mcp-ts/src/index.ts @@ -27,7 +27,13 @@ import { toolFormat, toolGetReviewText, toolGitSnapshot, + toolGetTranslation, toolAddTranslation, + toolInitWorkspace, + toolSetupAiFiles, + toolMemoryList, + toolMemoryAppend, + toolMemoryCompact, } from './tools.js'; import { resolveBook, listBooks, findBookByPath } from './registry.js'; @@ -249,6 +255,22 @@ server.registerTool('git_snapshot', { try { return ok(toolGitSnapshot(resolveBook(book).root, { message })); } catch (e) { return err(e); } }); +server.registerTool('get_translation', { + title: 'Get Translation', + description: + 'Look up translation/substitution rules in .bindery/translations.json. ' + + 'Without a word, lists all rules for the language. ' + + 'With a word, does a forgiving case-insensitive lookup including plural and inflected forms.', + inputSchema: { + book: bookSchema, + language: z.string().describe('Language key, label, or code (e.g. "nl", "en-gb", "British English")'), + word: z.string().optional().describe('Word or term to look up (optional — omit to list all rules)'), + }, + annotations: { readOnlyHint: true }, +}, async ({ book, language, word }) => { + try { return ok(toolGetTranslation(resolveBook(book).root, { language, word })); } catch (e) { return err(e); } +}); + server.registerTool('add_translation', { title: 'Add Translation', description: @@ -266,6 +288,86 @@ server.registerTool('add_translation', { try { return ok(toolAddTranslation(resolveBook(book).root, { langKey, from, to })); } catch (e) { return err(e); } }); +server.registerTool('init_workspace', { + title: 'Init Workspace', + description: + 'Create or update .bindery/settings.json and .bindery/translations.json. ' + + 'All arguments are optional — smart defaults are used for any omitted values. ' + + 'Safe to run on an existing workspace: existing settings are preserved unless explicitly overridden. ' + + 'Detects language folders in the story directory automatically.', + inputSchema: { + book: bookSchema, + bookTitle: z.string().optional().describe('Book title (defaults to folder name)'), + author: z.string().optional().describe('Author name'), + storyFolder: z.string().optional().describe('Story folder name relative to root (default: Story)'), + genre: z.string().optional().describe('Genre, e.g. sci-fi/fantasy'), + description: z.string().optional().describe('One-line description used in AI instruction files'), + targetAudience: z.string().optional().describe('Target audience, e.g. 12+ or adults'), + }, + annotations: { destructiveHint: true }, +}, async ({ book, bookTitle, author, storyFolder, genre, description, targetAudience }) => { + try { return ok(toolInitWorkspace(resolveBook(book).root, { bookTitle, author, storyFolder, genre, description, targetAudience })); } catch (e) { return err(e); } +}); + +server.registerTool('setup_ai_files', { + title: 'Setup AI Files', + description: + 'Generate AI assistant instruction files (CLAUDE.md, .github/copilot-instructions.md, ' + + '.cursor/rules, AGENTS.md) and Claude skill templates from .bindery/settings.json. ' + + 'Run init_workspace first. Safe to run multiple times — skips existing files unless overwrite is true.', + inputSchema: { + book: bookSchema, + targets: z.array(z.string()).optional().describe('Which files to generate: claude, copilot, cursor, agents. Default: all.'), + skills: z.array(z.string()).optional().describe('Which Claude skills to generate: review, brainstorm, memory, translate, status, continuity, read_aloud. Default: all.'), + overwrite: z.boolean().optional().describe('Overwrite existing files? Default false (skip existing).'), + }, + annotations: { destructiveHint: true }, +}, async ({ book, targets, skills, overwrite }) => { + try { return ok(toolSetupAiFiles(resolveBook(book).root, { targets, skills, overwrite })); } catch (e) { return err(e); } +}); + +server.registerTool('memory_list', { + title: 'Memory List', + description: 'List all session memory files in .bindery/memories/. Returns each filename and its line count.', + inputSchema: { book: bookSchema }, + annotations: { readOnlyHint: true }, +}, async ({ book }) => { + try { return ok(toolMemoryList(resolveBook(book).root)); } catch (e) { return err(e); } +}); + +server.registerTool('memory_append', { + title: 'Memory Append', + description: + 'Append a dated session entry to a memory file in .bindery/memories/. ' + + 'Creates the file if it does not exist. ' + + 'The tool stamps the current date; supply a short title and the content to record.', + inputSchema: { + book: bookSchema, + file: z.string().describe('Filename within .bindery/memories/, e.g. global.md or ch10.md'), + title: z.string().describe('Short session title describing the topic'), + content: z.string().describe('Text to record under this session entry'), + }, + annotations: { destructiveHint: true }, +}, async ({ book, file, title, content }) => { + try { return ok(toolMemoryAppend(resolveBook(book).root, { file, title, content })); } catch (e) { return err(e); } +}); + +server.registerTool('memory_compact', { + title: 'Memory Compact', + description: + 'Overwrite a memory file with a compacted version supplied by the model. ' + + 'The original is backed up to .bindery/memories/archive/ before overwriting. ' + + 'Use this when a memory file has grown too large and needs to be summarised.', + inputSchema: { + book: bookSchema, + file: z.string().describe('Filename within .bindery/memories/, e.g. global.md'), + compacted_content: z.string().describe('Full replacement content (model-supplied summary)'), + }, + annotations: { destructiveHint: true }, +}, async ({ book, file, compacted_content }) => { + try { return ok(toolMemoryCompact(resolveBook(book).root, { file, compacted_content })); } catch (e) { return err(e); } +}); + // ─── Start ──────────────────────────────────────────────────────────────────── async function main(): Promise { diff --git a/mcp-ts/src/templates.ts b/mcp-ts/src/templates.ts new file mode 100644 index 0000000..2608c00 --- /dev/null +++ b/mcp-ts/src/templates.ts @@ -0,0 +1,533 @@ +/** + * Bindery AI instruction file templates. + * + * SINGLE SOURCE OF TRUTH — do not edit the copy in vscode-ext/src/. + * The copy at vscode-ext/src/ai-setup-templates.ts is generated automatically: + * cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts + * + * This file has zero imports. It exports only TemplateContext and renderTemplate. + */ + +// ─── Context ────────────────────────────────────────────────────────────────── + +export interface TemplateContext { + title: string; + author: string; + description: string; + genre: string; + audience: string; + storyFolder: string; + notesFolder: string; + arcFolder: string; + memoriesFolder: string; + languages: Array<{ code: string; folderName: string }>; + langList: string; + hasMultiLang: boolean; +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + +/** + * Render a named template with the given context. + * + * Top-level file templates: 'claude', 'copilot', 'cursor', 'agents' + * Skill templates: 'review', 'brainstorm', 'memory', 'translate', + * 'status', 'continuity', 'read_aloud' + */ +export function renderTemplate(name: string, ctx: TemplateContext): string { + switch (name) { + case 'claude': return claudeMd(ctx); + case 'copilot': return copilotMd(ctx); + case 'cursor': return cursorRules(ctx); + case 'agents': return agentsMd(ctx); + case 'review': return skillReview(ctx); + case 'brainstorm': return skillBrainstorm(ctx); + case 'memory': return skillMemory(ctx); + case 'translate': return skillTranslate(ctx); + case 'status': return skillStatus(ctx); + case 'continuity': return skillContinuity(ctx); + case 'read_aloud': return skillReadAloud(ctx); + default: return `Unknown template: ${name}`; + } +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +function audienceNote(ctx: TemplateContext): string { + return ctx.audience ? `Target audience: ${ctx.audience}.` : ''; +} + +function languageSection(ctx: TemplateContext): string { + if (!ctx.hasMultiLang) { return ''; } + return `\nLanguages: ${ctx.langList}.\n`; +} + +// ─── Top-level templates ────────────────────────────────────────────────────── + +function claudeMd(ctx: TemplateContext): string { + const { title, author, description, genre, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx; + const lines: string[] = [ + `# Claude — ${title}`, + '', + '## Project', + ]; + if (genre) { lines.push(`Genre: ${genre}.`); } + if (description) { lines.push(description); } + if (ctx.audience){ lines.push(audienceNote(ctx)); } + if (author) { lines.push(`Author: ${author}.`); } + lines.push(languageSection(ctx)); + + lines.push( + '## Start of session', + `1. Read COWORK.md (if present) for current focus and context.`, + `2. Read ${memoriesFolder}/global.md for cross-chapter decisions.`, + `3. If working on a specific chapter, read ${memoriesFolder}/chXX.md if it exists.`, + '', + '## Repo layout', + '```', + `${arcFolder}/ ← story arc files (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`, + `${notesFolder}/ ← story bible (characters, world, translation table, memories)`, + `${storyFolder}/`, + ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`), + '```', + '', + '## Writing rules', + '- Never rewrite paragraphs unless explicitly asked. Suggest edits only.', + '- HTML comments `` in chapter files are writer notes. Treat as context, not prose.', + '- Quotation marks and dashes in chapter files are managed by the Bindery extension. Do not flag these as formatting errors.', + ); + if (ctx.audience) { + lines.push(`- Content is aimed at ${ctx.audience}. Keep language accessible and themes age-appropriate.`); + } + lines.push( + '', + '## Key reference files', + '| File | Contains |', + '|---|---|', + `| \`${arcFolder}/Overall.md\` | Full story arc |`, + `| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`, + `| \`${notesFolder}/Details_Characters.md\` | Character profiles |`, + `| \`${notesFolder}/Details_World_and_Magic.md\` | World rules and magic system |`, + `| \`${notesFolder}/Details_Translation_notes.md\` | Term translations / glossary |`, + `| \`${memoriesFolder}/global.md\` | Cross-session decisions |`, + '', + '## Available skills', + 'Use these slash commands to trigger structured workflows:', + '| Command | Purpose |', + '|---|---|', + '| `/review` | Review a chapter for language, arc consistency, and age-appropriateness |', + '| `/brainstorm` | Generate plot/character/scene ideas |', + '| `/memory` | Update memory files and compact if needed |', + '| `/translate` | Assist with chapter translation |', + '| `/status` | Book progress snapshot |', + '| `/continuity` | Check a chapter for consistency errors |', + '| `/read-aloud` | Test how a passage reads when spoken |', + ); + return lines.filter(l => l !== '\n').join('\n') + '\n'; +} + +function copilotMd(ctx: TemplateContext): string { + const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx; + const lines: string[] = [`# GitHub Copilot — ${title}`, '']; + if (genre || description || ctx.audience) { + lines.push('## Project'); + if (genre) { lines.push(`${genre} novel.`); } + if (description) { lines.push(description); } + if (ctx.audience){ lines.push(audienceNote(ctx)); } + if (author) { lines.push(`Author: ${author}.`); } + lines.push(languageSection(ctx), ''); + } + lines.push( + '## Repo layout', + '```', + `${arcFolder}/ ← story arc files`, + `${notesFolder}/ ← story bible, translation table, memories`, + `${storyFolder}/`, + ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters`), + '```', + '', + '## Writing guidelines', + '- HTML comments `` in chapter files are writer notes — treat as context only.', + '- Quotation marks and dashes are managed by the Bindery VS Code extension. Do not normalise them.', + '- Check `Notes/Details_Translation_notes.md` before using or translating world-specific terms.', + ); + if (ctx.audience) { + lines.push(`- Content targets ${ctx.audience}. Keep vocabulary accessible and themes appropriate.`); + } + return lines.join('\n') + '\n'; +} + +function cursorRules(ctx: TemplateContext): string { + const { title, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx; + const lines: string[] = [ + `# Cursor rules — ${title}`, + '', + `Story folder: \`${storyFolder}/\``, + `Notes folder: \`${notesFolder}/\``, + `Arc folder: \`${arcFolder}/\` (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`, + '', + '## Context files to read', + `- \`${memoriesFolder}/global.md\` — cross-chapter decisions (read at start of session)`, + `- \`${arcFolder}/Overall.md\` — full story arc`, + `- \`${notesFolder}/Details_Characters.md\` — character profiles`, + `- \`${notesFolder}/Details_World_and_Magic.md\` — world rules`, + `- \`${notesFolder}/Details_Translation_notes.md\` — term translations`, + '', + '## Rules', + '- HTML comments `` in chapter files are writer notes. Treat as context, not story content.', + '- Do not normalise quotation marks or dashes — these are managed by the Bindery extension.', + '- Do not rewrite prose unless explicitly asked. Suggest edits only.', + ]; + if (ctx.audience) { + lines.push(`- Target audience is ${ctx.audience}. Flag content that is too complex or inappropriate.`); + } + return lines.join('\n') + '\n'; +} + +function agentsMd(ctx: TemplateContext): string { + const { title, author, description, genre, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx; + const lines: string[] = [`# Agent Instructions — ${title}`, '']; + lines.push('## Project overview'); + if (genre) { lines.push(`${genre} novel.`); } + if (description) { lines.push(description); } + if (ctx.audience){ lines.push(audienceNote(ctx)); } + if (author) { lines.push(`Author: ${author}.`); } + lines.push(languageSection(ctx), ''); + lines.push( + '## Start of session', + `1. Read \`${memoriesFolder}/global.md\` for cross-chapter context.`, + `2. If working on a specific chapter, read \`${memoriesFolder}/chXX.md\` if it exists.`, + `3. Check \`${notesFolder}/Details_Translation_notes.md\` before using or translating world-specific terms.`, + '', + '## Story files', + `- Chapter files are \`.md\` files in \`${storyFolder}/\`, organised in act subfolders.`, + '- HTML comments `` are writer notes — treat as context only, not prose.', + '- Quotation marks and em-dashes are managed by the Bindery extension. Do not normalise them.', + '', + '## Writing guidelines', + '- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.', + ); + if (ctx.audience) { + lines.push(`- Audience is ${ctx.audience}. Keep vocabulary clear and themes age-appropriate.`); + } + lines.push( + '', + '## Key reference files', + '| File | Contains |', + '|---|---|', + `| \`${arcFolder}/Overall.md\` | Full story arc |`, + `| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`, + `| \`${notesFolder}/Details_Characters.md\` | Character profiles |`, + `| \`${notesFolder}/Details_World_and_Magic.md\` | World rules |`, + `| \`${notesFolder}/Details_Translation_notes.md\` | EN ↔ translation term table |`, + `| \`${memoriesFolder}/global.md\` | Cross-session decisions |`, + ); + return lines.join('\n') + '\n'; +} + +// ─── Skill templates ────────────────────────────────────────────────────────── + +function skillReview(ctx: TemplateContext): string { + const { title, arcFolder, memoriesFolder } = ctx; + const audienceStr = ctx.audience || 'the target audience'; + return `# Skill: /review + +Review a chapter of "${title}" and give structured feedback. + +## Trigger +User says \`/review\`, "review chapter X", "quick review", or "review my changes". + +## Clarify first +- Which chapter number? +- Type: **Full** (language + arc + age-appropriateness) or **Quick** (language and typos only)? + +## Tools +Use these Bindery MCP tools to gather context: +- \`get_review_text(language)\` — get the git diff of uncommitted changes (for "review my changes") +- \`get_chapter(chapterNumber, language)\` — read the full chapter text +- \`get_notes(category, name)\` — look up character profiles (\`category: "Characters"\`) or world rules +- \`retrieve_context(query, language)\` — find related passages across the book +- \`git_snapshot(message)\` — after a successful review, suggest saving a snapshot + +## Steps + +### 1. Load context +- Read \`${memoriesFolder}/global.md\` +- Use \`get_chapter\` to load the chapter +- For a Full review, read the relevant arc file: \`${arcFolder}/Act_I_[X].md\`, \`Act_II_[X].md\`, or \`Act_III_[X].md\` +- For "review my changes", use \`get_review_text\` to get the diff + +### 2. Perform the review + +**Quick** — language and typos only. + +**Full** — adds: +- Arc consistency with the arc file +- Age-appropriateness for ${audienceStr} +- Character consistency (use \`get_notes(category: "Characters")\`) + +### 3. Output format + +| Location | Before | Suggested | Reason | +|---|---|---|---| +| Line X | ...original... | ...suggestion... | reason | + +- Bold changed words +- Group by category for Full reviews +- End with a 2-3 sentence overall impression + +### 4. After review +If the review looks good, suggest: "Want me to save a snapshot?" (calls \`git_snapshot\`). + +## Rules +- Do not rewrite unless asked — suggest only +`; +} + +function skillBrainstorm(ctx: TemplateContext): string { + const { title, arcFolder, memoriesFolder } = ctx; + const audienceStr = ctx.audience || 'the target audience'; + return `# Skill: /brainstorm + +Brainstorm story ideas, character moments, or plot solutions for "${title}". + +## Trigger +User says \`/brainstorm\`, "I'm stuck", "help me think of ideas", or "Am I stuck?". + +## Clarify first +- Focus: plot beat / character moment / scene idea / chapter opening-closing? +- Which chapter or story point? +- Any constraints to respect? + +## Tools +Use these Bindery MCP tools to gather context: +- \`retrieve_context(query, language)\` — find thematic parallels and related moments across the book +- \`get_notes(category, name)\` — look up character profiles, world rules, or equipment details +- \`get_chapter(chapterNumber, language)\` — read a specific chapter for reference + +## Steps + +1. Read \`${memoriesFolder}/global.md\` and the relevant arc file from \`${arcFolder}/\`. +2. If character-focused, use \`get_notes(category: "Characters")\` for character profiles. +3. Use \`retrieve_context\` to find related moments or themes already in the book. +4. Generate 3-5 concrete ideas that fit the arc and feel true to the characters. + +## Output format + +**Option A — [short title]** +[3-5 sentence description] + +... + +End with a brief note on which options feel most aligned with the arc. + +## Rules +- Respect established world rules and character voices +- Keep ideas appropriate for ${audienceStr} +`; +} + +function skillMemory(ctx: TemplateContext): string { + return `# Skill: /memory + +Update project memory files with decisions from the current session. + +## Trigger +User says \`/memory\`, "save this to memory", "update memories", or at session end. + +## Tools +Use these Bindery MCP tools: +- \`memory_list\` — discover which memory files exist and their line counts +- \`memory_append(file, title, content)\` — append a dated session entry; the tool stamps the date automatically +- \`memory_compact(file, compacted_content)\` — overwrite a file with a summary; backs up the original to \`archive/\` automatically +- \`git_snapshot(message)\` — after updating memories, offer to save a snapshot + +## Steps + +### 1. Identify what to save +List the decisions, insights, or facts from the session worth preserving. + +### 2. Check existing files +Use \`memory_list\` to see which memory files exist and how large they are. + +### 3. Append the entry +Use \`memory_append\` to write to the right file: +- \`global.md\` — cross-chapter decisions (character names, world rules, style choices) +- \`chXX.md\` — chapter-specific decisions (e.g. \`ch10.md\`) + +Arguments: +- \`file\`: just the filename, e.g. \`global.md\` or \`ch10.md\` +- \`title\`: short topic label, e.g. \`"Daeven introduction — character decisions"\` +- \`content\`: the decisions to record, one per line + +The tool stamps the current date. Do not add a date to the content. + +### 4. Compact if needed +If \`memory_list\` shows a file exceeding ~150 lines, offer to compact it: +- Summarise the existing content into a concise replacement +- Call \`memory_compact(file, compacted_content)\` — original is backed up automatically + +### 5. Snapshot +Offer to save a snapshot with \`git_snapshot\`. + +## Rules +- Always use \`memory_append\` — never use the Edit tool to write to memory files +- Do not add dates to content — the tool stamps them automatically +- Compaction is always opt-in +`; +} + +function skillTranslate(ctx: TemplateContext): string { + return `# Skill: /translate + +Translate a chapter or passage into the target language. + +## Trigger +User says \`/translate\`, "translate chapter X", or "help me with the translation". + +## Clarify first +- Which chapter number and target language? +- Full translation or spot-check an existing translation? + +## Tools +Use these Bindery MCP tools: +- \`get_chapter(chapterNumber, language)\` — read a chapter in any language (source or existing translation) +- \`get_translation(language)\` — list all rules for a language key (e.g. \`"nl"\` or \`"en-gb"\`) +- \`get_translation(language, word)\` — look up a specific term; forgiving: case-insensitive, handles plurals and inflected forms +- \`search(query, language)\` — verify how a term was rendered in other translated chapters +- \`add_translation(langKey, from, to)\` — save a new term pair to \`.bindery/translations.json\` when the user confirms a translation choice + +## Steps + +### 1. Load the translation table +Call \`get_translation(language)\` to load all known term mappings for the target language before translating anything. + +### 2. Load the chapter +Use \`get_chapter(chapterNumber, sourceLanguage)\` to read the source chapter. +For spot-check mode, also call \`get_chapter(chapterNumber, targetLanguage)\` to read the existing translation. + +### 3. Translate or review +**Full translation** — translate paragraph by paragraph, applying all terms from the glossary. Output the full result in a fenced \`\`\`markdown block for easy pasting. + +**Spot-check** — compare source and translation side-by-side. Use a feedback table: + +| Location | Source | Current translation | Suggestion | Reason | +|---|---|---|---|---| + +### 4. Save confirmed terms +When the user confirms a new or corrected term translation, call \`add_translation\` to persist it so future exports apply it automatically. + +## Rules +- Always load the translation table first — never invent translations for world-specific terms +- Flag uncertain terms rather than guessing +`; +} + +function skillStatus(ctx: TemplateContext): string { + const { notesFolder, arcFolder, memoriesFolder } = ctx; + return `# Skill: /status + +Snapshot of the book's progress: what's done, in progress, and coming up. + +## Trigger +User says \`/status\`, "what's the book status", or "where are we". + +## Tools +Use these Bindery MCP tools: +- \`get_overview(language)\` — list all acts and chapters with titles +- \`get_text(identifier)\` — read COWORK.md, \`${notesFolder}/Chapter_Status.md\`, and \`${memoriesFolder}/global.md\` +- \`memory_list\` — discover which chapter memory files exist (\`chXX.md\`) + +## Steps + +1. Use \`memory_list\` to check available memory files. Use \`get_text\` to read COWORK.md (current focus), \`${notesFolder}/Chapter_Status.md\`, and \`${memoriesFolder}/global.md\`. +2. Use \`get_overview\` for the full chapter listing. +3. Check \`${arcFolder}/\` for what's planned vs written (Overall.md + the relevant act file). +4. Output: overall count / done / in-progress / coming up (next 2-3 chapters) / open questions. + +## Output +Keep it scannable — bold headers, short lines. This is a working tool, not a narrative summary. +`; +} + +function skillContinuity(ctx: TemplateContext): string { + const { memoriesFolder } = ctx; + return `# Skill: /continuity + +Cross-check a chapter for consistency errors. + +## Trigger +User says \`/continuity\`, "check continuity", or "check chapter X for errors". + +## Clarify first +- Chapter number? +- Focus: All / Characters / World rules / Timeline? + +## Tools +Use these Bindery MCP tools: +- \`get_chapter(chapterNumber, language)\` — read the chapter (and previous chapter for timeline checks) +- \`get_notes(category, name)\` — look up character profiles or world rules +- \`retrieve_context(query, language)\` — find earlier mentions of a character detail or event +- \`search(query, language)\` — exact-match search for names, places, or specific terms +- \`memory_list\` — check whether a chapter-specific memory file exists (\`chXX.md\`) + +## Steps + +1. Use \`get_chapter\` to read the chapter. +2. Use \`get_text\` to read \`${memoriesFolder}/global.md\`. Use \`memory_list\` to check if a chapter-specific memory file (\`chXX.md\`) exists; if so, read it with \`get_text\` too. Use \`get_notes(category: "Characters")\` for character profiles. +3. For world rules: use \`get_notes(category: "World")\`. +4. For timeline: also use \`get_chapter\` to read the previous chapter. +5. Use \`retrieve_context\` or \`search\` to verify specific details against earlier chapters. + +## Output format + +| Type | Location | Issue | Reference | +|---|---|---|---| +| Character | Line X | Description contradicts... | global.md | + +End with a one-line overall assessment. If no issues found, say so clearly. + +## Rules +- Flag issues only — do not suggest rewrites +- Phrase uncertain items as questions, not errors +`; +} + +function skillReadAloud(ctx: TemplateContext): string { + const audienceStr = ctx.audience || 'the target audience'; + return `# Skill: /read-aloud + +Test how a chapter sounds when read aloud to ${audienceStr}. + +## Trigger +User says \`/read-aloud\`, "reading test", or "how does this sound". + +## Clarify first +- Whole chapter or specific passage? + +## Tools +Use these Bindery MCP tools: +- \`get_chapter(chapterNumber, language)\` — read the full chapter +- \`get_text(identifier, startLine, endLine)\` — read a specific passage by line range + +## What to check +- Sentences over ~30 words +- Sequences of 3+ short sentences (staccato) +- Vocabulary too complex for ${audienceStr} +- Said-bookisms in dialogue ("she exclaimed breathlessly" → prefer "said" or action beat) +- Paragraphs over 8 lines without a break +- Accidental word repetition within 2-3 sentences + +## Output format + +| Type | Location | Flagged text | Note | +|---|---|---|---| +| Long sentence | Para 3 | "..." (34 words) | Consider splitting | + +Brief overall impression (2-3 sentences) after the table. + +## Rules +- Focus on how it sounds when spoken — not a content review +- Suggestions are gentle ("consider", not "must change") +`; +} diff --git a/mcp-ts/src/tools.ts b/mcp-ts/src/tools.ts index b6d90e2..a65ea20 100644 --- a/mcp-ts/src/tools.ts +++ b/mcp-ts/src/tools.ts @@ -15,6 +15,7 @@ import { buildIndex, loadIndex, indexPath, search, rerank, type SearchResult, } from './search.js'; +import { setupAiFiles, ALL_SKILLS, type AiTarget, type SkillTemplate } from './aisetup.js'; // ─── Shared helpers ─────────────────────────────────────────────────────────── @@ -45,7 +46,17 @@ export function toolHealth(root: string): string { const lines: string[] = [`root: ${root}`]; const settingsPath = path.join(root, '.bindery', 'settings.json'); - lines.push(`settings.json: ${fs.existsSync(settingsPath) ? 'present' : 'missing'}`); + if (fs.existsSync(settingsPath)) { + lines.push('settings.json: present'); + } else { + lines.push('settings.json: missing — run init_workspace to set up this book'); + } + + const memDir = path.join(root, '.bindery', 'memories'); + const memFiles = fs.existsSync(memDir) + ? fs.readdirSync(memDir).filter(f => f.endsWith('.md')).length + : -1; + lines.push(`memories: ${memFiles >= 0 ? `present (${memFiles} file${memFiles === 1 ? '' : 's'})` : 'not created yet'}`); const idxPath = indexPath(root); if (fs.existsSync(idxPath)) { @@ -531,6 +542,72 @@ export function toolGitSnapshot(root: string, args: GitSnapshotArgs): string { return `Snapshot saved: "${msg}" (${fileCount} file${fileCount === 1 ? '' : 's'})`; } +// ─── get_translation ───────────────────────────────────────────────────────── + +export interface GetTranslationArgs { + language: string; + word?: string; +} + +export function toolGetTranslation(root: string, args: GetTranslationArgs): string { + const filePath = path.join(root, '.bindery', 'translations.json'); + if (!fs.existsSync(filePath)) { + return 'No translations.json found. Run "Bindery: Initialise Workspace" or "Bindery: Add Translation" first.'; + } + + let translations: TranslationsFile; + try { translations = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TranslationsFile; } + catch { return 'Error: failed to parse .bindery/translations.json'; } + + // Resolve the requested language key — case-insensitive, accept code or label + const langLower = args.language.toLowerCase(); + const matchedKey = Object.keys(translations).find( + k => k.toLowerCase() === langLower || + translations[k].label?.toLowerCase() === langLower || + translations[k].sourceLanguage?.toLowerCase() === langLower + ); + + if (!matchedKey) { + const available = Object.entries(translations) + .map(([k, e]) => `${k}${e.label ? ` (${e.label})` : ''}`) + .join(', '); + return `No translation entry found for "${args.language}". Available: ${available || 'none'}`; + } + + const entry = translations[matchedKey]; + const rules = entry.rules ?? []; + + if (!args.word) { + // Dump all rules for this language + if (rules.length === 0) { return `No rules defined for "${matchedKey}" yet.`; } + const header = `${matchedKey}${entry.label ? ` — ${entry.label}` : ''} (${entry.type}, ${rules.length} rules):`; + return [header, ...rules.map(r => ` ${r.from} → ${r.to}`)].join('\n'); + } + + // Word lookup — forgiving: case-insensitive + stem variants + const stems = wordStems(args.word.toLowerCase()); + const matches = rules.filter(r => stems.some(s => r.from.toLowerCase() === s)); + + if (matches.length === 0) { + return `"${args.word}" not found in ${matchedKey} translations.`; + } + return matches.map(r => `${r.from} → ${r.to} [${matchedKey}]`).join('\n'); +} + +/** Generate stem variants for forgiving word lookup. */ +function wordStems(word: string): string[] { + const variants = new Set([word]); + // strip common suffixes to reach a base form + if (word.endsWith('ies')) { variants.add(word.slice(0, -3) + 'y'); } + if (word.endsWith('es')) { variants.add(word.slice(0, -2)); } + if (word.endsWith('s')) { variants.add(word.slice(0, -1)); } + if (word.endsWith('ed')) { variants.add(word.slice(0, -2)); variants.add(word.slice(0, -1)); } + if (word.endsWith('ing')) { variants.add(word.slice(0, -3)); variants.add(word.slice(0, -3) + 'e'); } + // also try adding -s so a bare stem matches plurals stored in the file + variants.add(word + 's'); + return Array.from(variants); +} + // ─── add_translation ────────────────────────────────────────────────────────── export interface AddTranslationArgs { @@ -656,6 +733,203 @@ function formatReviewFiles(files: DiffFile[]): string { return parts.join('\n\n---\n\n'); } +// ─── init_workspace ────────────────────────────────────────────────────────── + +export interface InitWorkspaceArgs { + bookTitle?: string; + author?: string; + storyFolder?: string; + genre?: string; + description?: string; + targetAudience?: string; +} + +export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string { + const settingsPath = path.join(root, '.bindery', 'settings.json'); + const translationsPath = path.join(root, '.bindery', 'translations.json'); + + // Load existing settings to preserve any keys not being updated + let existing: Record = {}; + const isNew = !fs.existsSync(settingsPath); + if (!isNew) { + try { existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; } + catch { /* corrupt — treat as new */ } + } + + const storyFolderName = args.storyFolder ?? (existing['storyFolder'] as string | undefined) ?? 'Story'; + const bookTitle = args.bookTitle ?? (existing['bookTitle'] as string | undefined) ?? path.basename(root); + + // Detect language folders from the story directory + const storyPath = path.join(root, storyFolderName); + const detectedLangs: Array<{ code: string; folderName: string; chapterWord: string; actPrefix: string; prologueLabel: string; epilogueLabel: string }> = []; + if (fs.existsSync(storyPath)) { + for (const entry of fs.readdirSync(storyPath, { withFileTypes: true })) { + if (entry.isDirectory() && /^[A-Z]{2,3}$/i.test(entry.name)) { + const code = entry.name.toUpperCase(); + detectedLangs.push({ code, folderName: entry.name, chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' }); + } + } + } + const languages = detectedLangs.length > 0 + ? detectedLangs + : [{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' }]; + + const slug = bookTitle.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book'; + const settings: Record = { + ...existing, + bookTitle, + ...(args.author ? { author: args.author } : {}), + ...(args.genre ? { genre: args.genre } : {}), + ...(args.description ? { description: args.description } : {}), + ...(args.targetAudience ? { targetAudience: args.targetAudience }: {}), + storyFolder: storyFolderName, + mergedOutputDir: (existing['mergedOutputDir'] as string | undefined) ?? 'Merged', + mergeFilePrefix: (existing['mergeFilePrefix'] as string | undefined) ?? slug, + formatOnSave: (existing['formatOnSave'] as boolean | undefined) ?? false, + languages, + }; + + fs.mkdirSync(path.join(root, '.bindery'), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + const created: string[] = ['.bindery/settings.json']; + + // Create translations.json only if it does not already exist + if (!fs.existsSync(translationsPath)) { + const translations = { + 'en-gb': { label: 'British English', type: 'substitution', sourceLanguage: 'en', rules: [], ignoredWords: [] }, + }; + fs.writeFileSync(translationsPath, JSON.stringify(translations, null, 2) + '\n', 'utf-8'); + created.push('.bindery/translations.json'); + } + + const action = isNew ? 'Initialised' : 'Updated'; + const langNote = languages.map(l => l.code).join(', '); + const hint = isNew + ? '\n\nTip: AI instruction files (CLAUDE.md, skills, copilot-instructions.md) are not yet set up. Run setup_ai_files to generate them, or use "Bindery: Set Up AI Files" in VS Code.' + : ''; + return `${action}: ${created.join(', ')}. Book: "${bookTitle}", story folder: ${storyFolderName}/, languages: ${langNote}.${hint}`; +} + +// ─── setup_ai_files ────────────────────────────────────────────────────────── + +export interface SetupAiFilesArgs { + targets?: string[]; // 'claude' | 'copilot' | 'cursor' | 'agents' + skills?: string[]; // skill names; omit for all + overwrite?: boolean; +} + +export function toolSetupAiFiles(root: string, args: SetupAiFilesArgs): string { + const validTargets: AiTarget[] = ['claude', 'copilot', 'cursor', 'agents']; + const validSkills = new Set(ALL_SKILLS); + + const targets: AiTarget[] = (args.targets ?? validTargets) + .filter((t): t is AiTarget => validTargets.includes(t as AiTarget)); + + const skills: SkillTemplate[] = args.skills + ? args.skills.filter((s): s is SkillTemplate => validSkills.has(s as SkillTemplate)) + : ALL_SKILLS; + + if (targets.length === 0) { + return `No valid targets specified. Valid targets: ${validTargets.join(', ')}`; + } + + let result; + try { + result = setupAiFiles({ root, targets, skills, overwrite: args.overwrite ?? false }); + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}`; + } + + const lines: string[] = []; + if (result.created.length > 0) { + lines.push(`Created (${result.created.length}):\n${result.created.map(f => ` ${f}`).join('\n')}`); + } + if (result.skipped.length > 0) { + lines.push(`Skipped — already exist (pass overwrite: true to replace) (${result.skipped.length}):\n${result.skipped.map(f => ` ${f}`).join('\n')}`); + } + if (lines.length === 0) { return 'Nothing to do.'; } + return lines.join('\n\n'); +} + +// ─── memory_list ───────────────────────────────────────────────────────────── + +export function toolMemoryList(root: string): string { + const memDir = path.join(root, '.bindery', 'memories'); + if (!fs.existsSync(memDir)) { return 'No memory files found yet.'; } + + const files = fs.readdirSync(memDir, { withFileTypes: true }) + .filter(e => e.isFile() && e.name.endsWith('.md')) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (files.length === 0) { return 'No memory files found yet.'; } + + return files.map(e => { + const lineCount = fs.readFileSync(path.join(memDir, e.name), 'utf-8').split(/\r?\n/).length; + return `${e.name} (${lineCount} lines)`; + }).join('\n'); +} + +// ─── memory_append ──────────────────────────────────────────────────────────── + +export interface MemoryAppendArgs { + file: string; + title: string; + content: string; +} + +export function toolMemoryAppend(root: string, args: MemoryAppendArgs): string { + const memDir = path.join(root, '.bindery', 'memories'); + fs.mkdirSync(memDir, { recursive: true }); + + const filePath = path.join(memDir, args.file); + const date = new Date().toISOString().slice(0, 10); + const header = `## Session ${date} — ${args.title}`; + const addition = `\n${header}\n${args.content}`; + + fs.appendFileSync(filePath, addition, 'utf-8'); + + const newTotal = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/).length; + const addedLines = addition.split(/\r?\n/).length; + + return `Appended to ${args.file}: ${addedLines} lines added, ${newTotal} total lines.`; +} + +// ─── memory_compact ─────────────────────────────────────────────────────────── + +export interface MemoryCompactArgs { + file: string; + compacted_content: string; +} + +export function toolMemoryCompact(root: string, args: MemoryCompactArgs): string { + const memDir = path.join(root, '.bindery', 'memories'); + const filePath = path.join(memDir, args.file); + + const oldLineCount = fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8').split(/\r?\n/).length + : 0; + + const archiveDir = path.join(memDir, 'archive'); + fs.mkdirSync(archiveDir, { recursive: true }); + + const date = new Date().toISOString().slice(0, 10); + const basename = path.basename(args.file, '.md'); + const backupName = `${basename}_${date}.md`; + const backupPath = path.join(archiveDir, backupName); + + if (fs.existsSync(filePath)) { + fs.copyFileSync(filePath, backupPath); + } + + fs.mkdirSync(memDir, { recursive: true }); + fs.writeFileSync(filePath, args.compacted_content, 'utf-8'); + + const newLineCount = args.compacted_content.split(/\r?\n/).length; + const relBackup = path.join('.bindery', 'memories', 'archive', backupName); + + return `Compacted ${args.file}: backup → ${relBackup}, old lines: ${oldLineCount}, new lines: ${newLineCount}.`; +} + // ─── Shared formatter ───────────────────────────────────────────────────────── function formatResult(r: SearchResult, idx: number): string { diff --git a/mcpb/README.md b/mcpb/README.md index 1b917be..47ac431 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -1,20 +1,23 @@ # Bindery MCP — Desktop Extension (.mcpb) Book authoring tools for Claude Desktop: chapter navigation, full-text search, -translation management, typography formatting, and version snapshots. Works with -any Markdown book project structured with the Bindery VS Code extension. +translation management, session memory, typography formatting, and version snapshots. +Works with any Markdown book project structured with the Bindery VS Code extension. ## Features - **Chapter navigation** — jump to any chapter by number and language - **Full-text search** — BM25 ranked search across all story and notes files - **Context retrieval** — "where did X happen" queries with ranked passages -- **Translation management** — add and update dialect substitution rules +- **Translation management** — list, look up, add, and update translation and dialect substitution rules +- **Session memory** — append, list, and compact persistent session notes in `Notes/Memories/` - **Typography formatting** — curly quotes, em-dashes, ellipses - **Version snapshots** — git-based save points after writing sessions - **Review diffs** — structured git diff of uncommitted changes -## Installation +## Manual Installation + +To install manually without using published Claude Connectors 1. Download the `.mcpb` file from the latest release 2. Open Claude Desktop → **Settings** → **Extensions** @@ -61,6 +64,82 @@ during future exports. Claude calls `get_review_text` to show the diff, reviews it, then calls `git_snapshot` to commit the changes with a descriptive message. +### Look up a translation rule + +> "How is 'flux' translated in the Dutch version?" + +Claude calls `get_translation` with `language: "nl"` and `word: "flux"`. The +lookup is forgiving — it matches case-insensitively and checks plural and +inflected forms automatically. If no rule exists yet, Claude can call +`add_translation` to create one. + +### List all known substitution rules for a language + +> "Show me all the British English substitution rules" + +Claude calls `get_translation` with `language: "en-gb"` (omitting `word`) to +dump every `from → to` rule in the `en-gb` entry of `.bindery/translations.json`. +Useful before a translation or export session to see what's already configured. + +### Save session decisions to memory + +> "Save today's character decisions to memory" + +Claude calls `memory_list` to check which files already exist and their sizes, +then calls `memory_append` with `file: "global.md"`, a short title, and the +decisions to record. The tool stamps the current date automatically — no manual +date formatting needed. + +### Compact a memory file that has grown too large + +> "The global memory file is getting long — please compact it" + +Claude reads the current content, summarises it, then calls `memory_compact` +with the compacted text. The original is automatically backed up to +`Notes/Memories/archive/global_YYYY-MM-DD.md` before the file is overwritten. + +### Spot-check a chapter translation + +> "Compare chapter 10 in EN and NL and flag any translation issues" + +Claude calls `get_chapter` twice — once for EN, once for NL — then calls +`get_translation` with the target language to load the known term table. +Discrepancies are presented in a side-by-side table. Any confirmed corrections +are saved back with `add_translation`. + +### Check continuity across chapters + +> "Check chapter 8 for consistency errors — character descriptions and world rules" + +Claude calls `get_chapter` to read the chapter, `get_notes` to load character +profiles and world rules, then uses `retrieve_context` and `search` to verify +specific details against earlier chapters. Results are presented in a table with +issue type, location, and the reference that contradicts it. + +## Tools reference + +| Tool | What it does | +|---|---| +| `list_books` | List all configured book names | +| `identify_book` | Match a working directory to a book name | +| `health` | Server status: settings, index, embedding backend | +| `index_build` | Build or rebuild the full-text search index | +| `index_status` | Show index chunk count and build time | +| `get_text` | Read any file by relative path, with optional line range | +| `get_chapter` | Full chapter content by number and language | +| `get_overview` | Chapter structure — acts, chapters, titles | +| `get_notes` | Notes/ and Details_*.md files, filterable by category or name | +| `search` | BM25 full-text search with ranked snippets | +| `retrieve_context` | Semantic passage retrieval for "where did X happen" queries | +| `format` | Apply typography formatting to a file or folder | +| `get_review_text` | Structured git diff with optional auto-staging | +| `git_snapshot` | Git commit of story, notes, and arc changes | +| `get_translation` | List all rules for a language, or look up a specific word (forgiving) | +| `add_translation` | Add or update a rule in `.bindery/translations.json` | +| `memory_list` | List `Notes/Memories/` files with line counts | +| `memory_append` | Append a dated session entry to a memory file | +| `memory_compact` | Overwrite a memory file with a summary (backs up original) | + ## Privacy Policy Bindery MCP runs entirely on your local machine. No data is sent to external diff --git a/mcpb/manifest.json b/mcpb/manifest.json index fd23b9c..d899bdc 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -45,6 +45,8 @@ { "name": "list_books", "description": "List all books configured in the MCP server. Call this first to discover available book names." }, { "name": "identify_book", "description": "Identify which book matches a working directory. Pass your cwd and the server matches by folder name or .bindery/settings.json." }, { "name": "health", "description": "Check server status: active book, settings, search index, and embedding backend." }, + { "name": "init_workspace", "description": "Create or update .bindery/settings.json and translations.json. All arguments optional — smart defaults used if omitted. Detects language folders automatically. Safe to run on an existing workspace." }, + { "name": "setup_ai_files", "description": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) and Claude skill templates from settings. Run init_workspace first. Skips existing files unless overwrite: true." }, { "name": "index_build", "description": "Build or rebuild the full-text search index for a book. Run once after cloning, then as needed." }, { "name": "index_status", "description": "Show current index metadata: chunk count and build time." }, { "name": "retrieve_context", "description": "Find the most relevant passages for a query. Use for 'where did X happen' or 'what did character Y say about Z'." }, @@ -56,6 +58,10 @@ { "name": "format", "description": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder." }, { "name": "get_review_text", "description": "Structured git diff of uncommitted changes. Filter by language (EN, NL, ALL). Set autoStage to stage reviewed files." }, { "name": "git_snapshot", "description": "Save a snapshot (git commit) of all changes in story, notes, and arc folders. Use after writing sessions or reviews." }, - { "name": "add_translation", "description": "Add or update a substitution rule in .bindery/translations.json for dialect conversion (e.g. US→UK spelling)." } + { "name": "add_translation", "description": "Add or update a substitution rule in .bindery/translations.json for translation or dialect conversion (e.g. US→UK spelling)." }, + { "name": "get_translation", "description": "Look up translation/substitution rules from .bindery/translations.json. List all rules for a language or do a forgiving word lookup (case-insensitive, handles plurals and inflected forms)." }, + { "name": "memory_list", "description": "List all session memory files in .bindery/memories/ with their line counts." }, + { "name": "memory_append", "description": "Append a dated session entry to a memory file in .bindery/memories/. Supply a short title and content; the tool stamps the date." }, + { "name": "memory_compact", "description": "Overwrite a memory file with a compacted summary. The original is backed up to .bindery/memories/archive/ first." } ] } \ No newline at end of file diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 1ebe617..25c9540 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -28,6 +28,7 @@ "dialect" ], "activationEvents": [ + "onStartupFinished", "onLanguage:markdown" ], "main": "./out/extension.js", @@ -345,6 +346,85 @@ }, "required": ["langKey", "from", "to"] } + }, + { "name": "bindery_get_translation", + "tags": ["bindery"], + "displayName": "Bindery: Get Translation", + "modelDescription": "Look up translation/substitution rules in .bindery/translations.json. Without a word, lists all rules for the language. With a word, does a forgiving case-insensitive lookup including plural and inflected forms.", + "inputSchema": { + "type": "object", + "properties": { + "language": { "type": "string", "description": "Language key, label, or code (e.g. 'nl', 'en-gb', 'British English')" }, + "word": { "type": "string", "description": "Word or term to look up (omit to list all rules for the language)" } + }, + "required": ["language"] + } + }, + { + "name": "bindery_setup_ai_files", + "tags": ["bindery"], + "displayName": "Bindery: Setup AI Files", + "modelDescription": "Generate AI assistant instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) and Claude skill templates from .bindery/settings.json. Run init_workspace first. Skips existing files unless overwrite is true.", + "inputSchema": { + "type": "object", + "properties": { + "targets": { "type": "array", "items": { "type": "string" }, "description": "Files to generate: claude, copilot, cursor, agents. Default: all." }, + "skills": { "type": "array", "items": { "type": "string" }, "description": "Claude skills to include: review, brainstorm, memory, translate, status, continuity, read_aloud. Default: all." }, + "overwrite": { "type": "boolean", "description": "Overwrite existing files? Default false." } + } + } + }, + { + "name": "bindery_init_workspace", + "tags": ["bindery"], + "displayName": "Bindery: Init Workspace", + "modelDescription": "Create or update .bindery/settings.json and translations.json. All arguments optional — smart defaults used for any omitted values. Detects language folders automatically. Safe to run on an existing workspace.", + "inputSchema": { + "type": "object", + "properties": { + "bookTitle": { "type": "string", "description": "Book title (defaults to folder name)" }, + "author": { "type": "string" }, + "storyFolder": { "type": "string", "description": "Story folder name relative to root (default: Story)" }, + "genre": { "type": "string" }, + "description": { "type": "string", "description": "One-line description for AI instruction files" }, + "targetAudience": { "type": "string", "description": "Target audience, e.g. 12+ or adults" } + } + } + }, + { "name": "bindery_memory_list", + "tags": ["bindery"], + "displayName": "Bindery: Memory List", + "modelDescription": "List all session memory files in .bindery/memories/ with their line counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "bindery_memory_append", + "tags": ["bindery"], + "displayName": "Bindery: Memory Append", + "modelDescription": "Append a dated session entry to a memory file in .bindery/memories/. The tool stamps the date; supply a short title and content.", + "inputSchema": { + "type": "object", + "properties": { + "file": { "type": "string", "description": "Filename within .bindery/memories/, e.g. global.md or ch10.md" }, + "title": { "type": "string", "description": "Short session title describing the topic" }, + "content": { "type": "string", "description": "Text to record under this session entry" } + }, + "required": ["file", "title", "content"] + } + }, + { + "name": "bindery_memory_compact", + "tags": ["bindery"], + "displayName": "Bindery: Memory Compact", + "modelDescription": "Overwrite a memory file with a compacted summary. The original is backed up to .bindery/memories/archive/ first.", + "inputSchema": { + "type": "object", + "properties": { + "file": { "type": "string", "description": "Filename within .bindery/memories/, e.g. global.md" }, + "compacted_content": { "type": "string", "description": "Full replacement content (model-supplied summary)" } + }, + "required": ["file", "compacted_content"] + } } ], "menus": { diff --git a/vscode-ext/src/ai-setup.ts b/vscode-ext/src/ai-setup.ts index 4d077a3..839a2df 100644 --- a/vscode-ext/src/ai-setup.ts +++ b/vscode-ext/src/ai-setup.ts @@ -8,12 +8,18 @@ * copilot → .github/copilot-instructions.md * cursor → .cursor/rules * agents → AGENTS.md (OpenAI Agents, Aider, Codex, etc.) + * + * Templates live in ai-setup-templates.ts, which is a copy of + * mcp-ts/src/templates.ts — the single source of truth. + * The copy is kept in sync by the CI workflow and locally via: + * cp mcp-ts/src/templates.ts vscode-ext/src/ai-setup-templates.ts */ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import type { WorkspaceSettings } from './workspace'; import type { LanguageConfig } from './merge'; +import { renderTemplate } from './ai-setup-templates'; // ─── Public types ───────────────────────────────────────────────────────────── @@ -47,6 +53,13 @@ export const ALL_SKILLS: SkillTemplate[] = [ 'review', 'brainstorm', 'memory', 'translate', 'status', 'continuity', 'read_aloud', ]; +/** + * Bump this number whenever the generated skill/instruction templates change + * significantly enough that existing users should regenerate their AI files. + * Written to .bindery/ai-version.json after each successful setupAiFiles() run. + */ +export const AI_SETUP_VERSION = 4; + // ─── Entry point ────────────────────────────────────────────────────────────── export function setupAiFiles(options: AiSetupOptions): AiSetupResult { @@ -58,44 +71,66 @@ export function setupAiFiles(options: AiSetupOptions): AiSetupResult { for (const target of targets) { switch (target) { case 'claude': - writeFile(root, 'CLAUDE.md', claudeMd(ctx), overwrite, result); + writeFile(root, 'CLAUDE.md', renderTemplate('claude', ctx), overwrite, result); for (const skill of skills) { const skillDir = path.join('.claude', 'skills', skill); - writeFile(root, path.join(skillDir, 'SKILL.md'), skillMd(skill, ctx), overwrite, result); + writeFile(root, path.join(skillDir, 'SKILL.md'), renderTemplate(skill, ctx), overwrite, result); } break; case 'copilot': - writeFile(root, path.join('.github', 'copilot-instructions.md'), copilotMd(ctx), overwrite, result); + writeFile(root, path.join('.github', 'copilot-instructions.md'), renderTemplate('copilot', ctx), overwrite, result); break; case 'cursor': - writeFile(root, path.join('.cursor', 'rules'), cursorRules(ctx), overwrite, result); + writeFile(root, path.join('.cursor', 'rules'), renderTemplate('cursor', ctx), overwrite, result); break; case 'agents': - writeFile(root, 'AGENTS.md', agentsMd(ctx), overwrite, result); + writeFile(root, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, result); break; } } + stampAiVersion(root); return result; } -// ─── Template context ───────────────────────────────────────────────────────── +// ─── Version stamp ──────────────────────────────────────────────────────────── + +/** Read the ai-version.json version number, or 0 if absent / unreadable. */ +export function readAiSetupVersion(root: string): number { + const p = path.join(root, '.bindery', 'ai-version.json'); + if (!fs.existsSync(p)) { return 0; } + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as { version?: unknown }; + return typeof raw.version === 'number' ? raw.version : 0; + } catch { return 0; } +} + +function stampAiVersion(root: string): void { + const dir = path.join(root, '.bindery'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'ai-version.json'), + JSON.stringify({ version: AI_SETUP_VERSION }, null, 2) + '\n', + 'utf-8' + ); +} interface TemplateContext { - title: string; - author: string; - description: string; - genre: string; - audience: string; - storyFolder: string; - notesFolder: string; - arcFolder: string; - languages: LanguageConfig[]; - langList: string; // e.g. "EN (source), NL (translation)" - hasMultiLang: boolean; + title: string; + author: string; + description: string; + genre: string; + audience: string; + storyFolder: string; + notesFolder: string; + arcFolder: string; + memoriesFolder: string; + languages: LanguageConfig[]; + langList: string; + hasMultiLang: boolean; } function buildContext(s: WorkspaceSettings): TemplateContext { @@ -113,7 +148,7 @@ function buildContext(s: WorkspaceSettings): TemplateContext { ? languages.map((l, i) => i === 0 ? `${l.code} (source)` : `${l.code} (translation)`).join(', ') : 'EN (source)'; - return { title, author, description, genre, audience, storyFolder, notesFolder, arcFolder, languages, langList, hasMultiLang: languages.length > 1 }; + return { title, author, description, genre, audience, storyFolder, notesFolder, arcFolder, languages, langList, hasMultiLang: languages.length > 1, memoriesFolder: '.bindery/memories' }; } // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -135,451 +170,3 @@ function writeFile( result.created.push(relPath); } -function audienceNote(ctx: TemplateContext): string { - return ctx.audience ? `Target audience: ${ctx.audience}.` : ''; -} - -function languageSection(ctx: TemplateContext): string { - if (!ctx.hasMultiLang) { return ''; } - return `\nLanguages: ${ctx.langList}.\n`; -} - -// ─── CLAUDE.md ──────────────────────────────────────────────────────────────── - -function claudeMd(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx; - const lines: string[] = [ - `# Claude — ${title}`, - '', - '## Project', - ]; - if (genre) { lines.push(`Genre: ${genre}.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push(languageSection(ctx)); - - lines.push( - '## Start of session', - `1. Read COWORK.md (if present) for current focus and context.`, - `2. Read ${notesFolder}/Memories/global.md for cross-chapter decisions.`, - `3. If working on a specific chapter, read ${notesFolder}/Memories/chXX.md if it exists.`, - '', - '## Repo layout', - '```', - `${arcFolder}/ ← story arc files (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`, - `${notesFolder}/ ← story bible (characters, world, translation table, memories)`, - `${storyFolder}/`, - ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`), - '```', - '', - '## Writing rules', - '- Never rewrite paragraphs unless explicitly asked. Suggest edits only.', - '- HTML comments `` in chapter files are writer notes. Treat as context, not prose.', - '- Quotation marks and dashes in chapter files are managed by the Bindery extension. Do not flag these as formatting errors.', - ); - if (ctx.audience) { - lines.push(`- Content is aimed at ${ctx.audience}. Keep language accessible and themes age-appropriate.`); - } - lines.push( - '', - '## Key reference files', - '| File | Contains |', - '|---|---|', - `| \`${arcFolder}/Overall.md\` | Full story arc |`, - `| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`, - `| \`${notesFolder}/Details_Characters.md\` | Character profiles |`, - `| \`${notesFolder}/Details_World_and_Magic.md\` | World rules and magic system |`, - `| \`${notesFolder}/Details_Translation_notes.md\` | Term translations / glossary |`, - `| \`${notesFolder}/Memories/global.md\` | Cross-session decisions |`, - '', - '## Available skills', - 'Use these slash commands to trigger structured workflows:', - '| Command | Purpose |', - '|---|---|', - '| `/review` | Review a chapter for language, arc consistency, and age-appropriateness |', - '| `/brainstorm` | Generate plot/character/scene ideas |', - '| `/memory` | Update memory files and compact if needed |', - '| `/translate` | Assist with chapter translation |', - '| `/status` | Book progress snapshot |', - '| `/continuity` | Check a chapter for consistency errors |', - '| `/read-aloud` | Test how a passage reads when spoken |', - ); - - return lines.filter(l => l !== '\n').join('\n') + '\n'; -} - -// ─── .github/copilot-instructions.md ───────────────────────────────────────── - -function copilotMd(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx; - const lines: string[] = [`# GitHub Copilot — ${title}`, '']; - if (genre || description || ctx.audience) { - lines.push('## Project'); - if (genre) { lines.push(`${genre} novel.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push(languageSection(ctx), ''); - } - - lines.push( - '## Repo layout', - '```', - `${arcFolder}/ ← story arc files`, - `${notesFolder}/ ← story bible, translation table, memories`, - `${storyFolder}/`, - ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters`), - '```', - '', - '## Writing guidelines', - '- HTML comments `` in chapter files are writer notes — treat as context only.', - '- Quotation marks and dashes are managed by the Bindery VS Code extension. Do not normalise them.', - '- Check `Notes/Details_Translation_notes.md` before using or translating world-specific terms.', - ); - if (ctx.audience) { - lines.push(`- Content targets ${ctx.audience}. Keep vocabulary accessible and themes appropriate.`); - } - - return lines.join('\n') + '\n'; -} - -// ─── .cursor/rules ──────────────────────────────────────────────────────────── - -function cursorRules(ctx: TemplateContext): string { - const { title, storyFolder, notesFolder, arcFolder } = ctx; - const lines: string[] = [ - `# Cursor rules — ${title}`, - '', - `Story folder: \`${storyFolder}/\``, - `Notes folder: \`${notesFolder}/\``, - `Arc folder: \`${arcFolder}/\` (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`, - '', - '## Context files to read', - `- \`${notesFolder}/Memories/global.md\` — cross-chapter decisions (read at start of session)`, - `- \`${arcFolder}/Overall.md\` — full story arc`, - `- \`${notesFolder}/Details_Characters.md\` — character profiles`, - `- \`${notesFolder}/Details_World_and_Magic.md\` — world rules`, - `- \`${notesFolder}/Details_Translation_notes.md\` — term translations`, - '', - '## Rules', - '- HTML comments `` in chapter files are writer notes. Treat as context, not story content.', - '- Do not normalise quotation marks or dashes — these are managed by the Bindery extension.', - '- Do not rewrite prose unless explicitly asked. Suggest edits only.', - ]; - if (ctx.audience) { - lines.push(`- Target audience is ${ctx.audience}. Flag content that is too complex or inappropriate.`); - } - - return lines.join('\n') + '\n'; -} - -// ─── AGENTS.md ──────────────────────────────────────────────────────────────── - -function agentsMd(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx; - const lines: string[] = [`# Agent Instructions — ${title}`, '']; - lines.push('## Project overview'); - if (genre) { lines.push(`${genre} novel.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push(languageSection(ctx), ''); - - lines.push( - '## Start of session', - `1. Read \`${notesFolder}/Memories/global.md\` for cross-chapter context.`, - `2. If working on a specific chapter, read \`${notesFolder}/Memories/chXX.md\` if it exists.`, - `3. Check \`${notesFolder}/Details_Translation_notes.md\` before using or translating world-specific terms.`, - '', - '## Story files', - `- Chapter files are \`.md\` files in \`${storyFolder}/\`, organised in act subfolders.`, - '- HTML comments `` are writer notes — treat as context only, not prose.', - '- Quotation marks and em-dashes are managed by the Bindery extension. Do not normalise them.', - '', - '## Writing guidelines', - '- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.', - ); - if (ctx.audience) { - lines.push(`- Audience is ${ctx.audience}. Keep vocabulary clear and themes age-appropriate.`); - } - - lines.push( - '', - '## Key reference files', - '| File | Contains |', - '|---|---|', - `| \`${arcFolder}/Overall.md\` | Full story arc |`, - `| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`, - `| \`${notesFolder}/Details_Characters.md\` | Character profiles |`, - `| \`${notesFolder}/Details_World_and_Magic.md\` | World rules |`, - `| \`${notesFolder}/Details_Translation_notes.md\` | EN ↔ translation term table |`, - `| \`${notesFolder}/Memories/global.md\` | Cross-session decisions |`, - ); - - return lines.join('\n') + '\n'; -} - -// ─── Skill templates ────────────────────────────────────────────────────────── - -function skillMd(skill: SkillTemplate, ctx: TemplateContext): string { - const { title, storyFolder, notesFolder, arcFolder, audience } = ctx; - const audienceStr = audience ? audience : 'the target audience'; - - switch (skill) { - case 'review': return `# Skill: /review - -Review a chapter of "${title}" and give structured feedback. - -## Trigger -User says \`/review\`, "review chapter X", "quick review", or "review my changes". - -## Clarify first -- Which chapter number? -- Type: **Full** (language + arc + age-appropriateness) or **Quick** (language and typos only)? - -## Tools -Use these Bindery MCP tools to gather context: -- \`get_review_text(language)\` — get the git diff of uncommitted changes (for "review my changes") -- \`get_chapter(chapterNumber, language)\` — read the full chapter text -- \`get_notes(category, name)\` — look up character profiles (\`category: "Characters"\`) or world rules -- \`retrieve_context(query, language)\` — find related passages across the book -- \`git_snapshot(message)\` — after a successful review, suggest saving a snapshot - -## Steps - -### 1. Load context -- Read \`${notesFolder}/Memories/global.md\` -- Use \`get_chapter\` to load the chapter -- For a Full review, read the relevant arc file: \`${arcFolder}/Act_I_[X].md\`, \`Act_II_[X].md\`, or \`Act_III_[X].md\` -- For "review my changes", use \`get_review_text\` to get the diff - -### 2. Perform the review - -**Quick** — language and typos only. - -**Full** — adds: -- Arc consistency with the arc file -- Age-appropriateness for ${audienceStr} -- Character consistency (use \`get_notes(category: "Characters")\`) - -### 3. Output format - -| Location | Before | Suggested | Reason | -|---|---|---|---| -| Line X | ...original... | ...suggestion... | reason | - -- Bold changed words -- Group by category for Full reviews -- End with a 2-3 sentence overall impression - -### 4. After review -If the review looks good, suggest: "Want me to save a snapshot?" (calls \`git_snapshot\`). - -## Rules -- Do not rewrite unless asked — suggest only -- Respond in English always -`; - - case 'brainstorm': return `# Skill: /brainstorm - -Brainstorm story ideas, character moments, or plot solutions for "${title}". - -## Trigger -User says \`/brainstorm\`, "I'm stuck", "help me think of ideas", or "Am I stuck?". - -## Clarify first -- Focus: plot beat / character moment / scene idea / chapter opening-closing? -- Which chapter or story point? -- Any constraints to respect? - -## Tools -Use these Bindery MCP tools to gather context: -- \`retrieve_context(query, language)\` — find thematic parallels and related moments across the book -- \`get_notes(category, name)\` — look up character profiles, world rules, or equipment details -- \`get_chapter(chapterNumber, language)\` — read a specific chapter for reference - -## Steps - -1. Read \`${notesFolder}/Memories/global.md\` and the relevant arc file from \`${arcFolder}/\`. -2. If character-focused, use \`get_notes(category: "Characters")\` for character profiles. -3. Use \`retrieve_context\` to find related moments or themes already in the book. -4. Generate 3–5 concrete ideas that fit the arc and feel true to the characters. - -## Output format - -**Option A — [short title]** -[3-5 sentence description] - -... - -End with a brief note on which options feel most aligned with the arc. - -## Rules -- Respect established world rules and character voices -- Keep ideas appropriate for ${audienceStr} -- Respond in English always -`; - - case 'memory': return `# Skill: /memory - -Update project memory files with decisions from the current session. - -## Trigger -User says \`/memory\`, "save this to memory", "update memories", or at session end. - -## Tools -Use these Bindery MCP tools: -- \`get_text(identifier)\` — read current memory files before appending (e.g. \`${notesFolder}/Memories/global.md\`) -- \`git_snapshot(message)\` — after updating memories, offer to save a snapshot - -## Steps - -1. Identify decisions/insights from the session. -2. Use \`get_text\` to read the current content of the target memory file. -3. Write to \`${notesFolder}/Memories/global.md\` (cross-chapter) or \`${notesFolder}/Memories/chXX.md\` (chapter-specific). -4. Append only — never edit existing entries. -5. Format: \`**[YYYY-MM-DD]:** - [decision]\` -6. If a file exceeds ~150 lines, offer to compact the oldest 50% into a summary block. -7. Offer to save a snapshot with \`git_snapshot\`. - -## Rules -- Append only -- Date every entry -- Compaction is always opt-in -- Respond in English always -`; - - case 'translate': return `# Skill: /translate - -Translate a chapter or passage into the target language. - -## Trigger -User says \`/translate\`, "translate chapter X", or "help me with the translation". - -## Clarify first -- Which chapter or passage? -- Full translation or spot-check an existing translation? - -## Tools -Use these Bindery MCP tools: -- \`get_chapter(chapterNumber, language)\` — read the source chapter -- \`get_notes(category: "Translation")\` — load the translation table (\`Details_Translation_notes.md\`) -- \`get_text(identifier)\` — read any specific file (e.g. an existing translation to spot-check) - -## Steps - -1. Use \`get_notes(category: "Translation")\` to load world-specific term translations. -2. Use \`get_chapter\` to read the source chapter. -3. Translate paragraph by paragraph, applying all terms from the translation table. -4. Output the translation in a fenced \`\`\`markdown code block for easy pasting. - -For spot-check mode, use a feedback table instead of a full translation. - -## Rules -- Always consult the translation table first — never invent translations for world-specific terms -- Flag uncertain terms rather than guessing -- Respond in English in explanations -`; - - case 'status': return `# Skill: /status - -Snapshot of the book's progress: what's done, in progress, and coming up. - -## Trigger -User says \`/status\`, "what's the book status", or "where are we". - -## Tools -Use these Bindery MCP tools: -- \`get_overview(language)\` — list all acts and chapters with titles -- \`get_text(identifier)\` — read COWORK.md, \`${notesFolder}/Chapter_Status.md\`, and \`${notesFolder}/Memories/global.md\` - -## Steps - -1. Use \`get_text\` to read COWORK.md (current focus), \`${notesFolder}/Chapter_Status.md\`, and \`${notesFolder}/Memories/global.md\`. -2. Use \`get_overview\` for the full chapter listing. -3. Check \`${arcFolder}/\` for what's planned vs written (Overall.md + the relevant act file). -4. Output: overall count / done / in-progress / coming up (next 2-3 chapters) / open questions. - -## Output -Keep it scannable — bold headers, short lines. This is a working tool, not a narrative summary. -`; - - case 'continuity': return `# Skill: /continuity - -Cross-check a chapter for consistency errors. - -## Trigger -User says \`/continuity\`, "check continuity", or "check chapter X for errors". - -## Clarify first -- Chapter number? -- Focus: All / Characters / World rules / Timeline? - -## Tools -Use these Bindery MCP tools: -- \`get_chapter(chapterNumber, language)\` — read the chapter (and previous chapter for timeline checks) -- \`get_notes(category, name)\` — look up character profiles or world rules -- \`retrieve_context(query, language)\` — find earlier mentions of a character detail or event -- \`search(query, language)\` — exact-match search for names, places, or specific terms - -## Steps - -1. Use \`get_chapter\` to read the chapter. -2. Load \`${notesFolder}/Memories/global.md\` and use \`get_notes(category: "Characters")\` for character profiles. -3. For world rules: use \`get_notes(category: "World")\`. -4. For timeline: also use \`get_chapter\` to read the previous chapter. -5. Use \`retrieve_context\` or \`search\` to verify specific details against earlier chapters. - -## Output format - -| Type | Location | Issue | Reference | -|---|---|---|---| -| Character | Line X | Description contradicts... | global.md | - -End with a one-line overall assessment. If no issues found, say so clearly. - -## Rules -- Flag issues only — do not suggest rewrites -- Phrase uncertain items as questions, not errors -- Respond in English always -`; - - case 'read_aloud': return `# Skill: /read-aloud - -Test how a chapter sounds when read aloud to ${audienceStr}. - -## Trigger -User says \`/read-aloud\`, "reading test", or "how does this sound". - -## Clarify first -- Whole chapter or specific passage? - -## Tools -Use these Bindery MCP tools: -- \`get_chapter(chapterNumber, language)\` — read the full chapter -- \`get_text(identifier, startLine, endLine)\` — read a specific passage by line range - -## What to check -- Sentences over ~30 words -- Sequences of 3+ short sentences (staccato) -- Vocabulary too complex for ${audienceStr} -- Said-bookisms in dialogue ("she exclaimed breathlessly" → prefer "said" or action beat) -- Paragraphs over 8 lines without a break -- Accidental word repetition within 2-3 sentences - -## Output format - -| Type | Location | Flagged text | Note | -|---|---|---|---| -| Long sentence | Para 3 | "..." (34 words) | Consider splitting | - -Brief overall impression (2-3 sentences) after the table. - -## Rules -- Focus on how it sounds when spoken — not a content review -- Suggestions are gentle ("consider", not "must change") -- Respond in English always -`; - } -} diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 50cb049..a7bcb9d 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -28,7 +28,7 @@ import { type WorkspaceSettings, type TranslationsFile, } from './workspace'; import { - setupAiFiles, ALL_SKILLS, + setupAiFiles, ALL_SKILLS, AI_SETUP_VERSION, readAiSetupVersion, type AiTarget, type SkillTemplate, } from './ai-setup'; import { registerLmTools, registerMcpCommand } from './mcp'; @@ -987,6 +987,23 @@ export function activate(context: vscode.ExtensionContext) { // LM tools (Copilot Chat) registerLmTools(context); + // AI setup version check — prompt if generated files are out of date + const root = getWorkspaceRoot(); + if (root && fs.existsSync(getSettingsPath(root))) { + const installedVersion = readAiSetupVersion(root); + if (installedVersion < AI_SETUP_VERSION) { + vscode.window.showInformationMessage( + 'Bindery: AI assistant files may be out of date (skill templates were updated).', + 'Update now', + 'Dismiss' + ).then(action => { + if (action === 'Update now') { + vscode.commands.executeCommand('bindery.setupAI'); + } + }); + } + } + // Status bar — shown when a markdown file is active const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBar.text = '$(book) Bindery'; diff --git a/vscode-ext/src/mcp.ts b/vscode-ext/src/mcp.ts index 71d9d3c..68769ff 100644 --- a/vscode-ext/src/mcp.ts +++ b/vscode-ext/src/mcp.ts @@ -24,6 +24,11 @@ interface FormatInput { filePath?: string; dryRun?: boolean; noRecurse?: boo interface GetReviewTextInput { language?: string; contextLines?: number; autoStage?: boolean } interface GitSnapshotInput { message?: string } interface AddTranslationInput { langKey: string; from: string; to: string } +interface GetTranslationInput { language: string; word?: string } +interface InitWorkspaceInput { bookTitle?: string; author?: string; storyFolder?: string; genre?: string; description?: string; targetAudience?: string } +interface SetupAiFilesInput { targets?: string[]; skills?: string[]; overwrite?: boolean } +interface MemoryAppendInput { file: string; title: string; content: string } +interface MemoryCompactInput { file: string; compacted_content: string } interface McpTools { toolHealth: (root: string) => string; @@ -39,6 +44,12 @@ interface McpTools { toolGetReviewText: (root: string, args: GetReviewTextInput) => string; toolGitSnapshot: (root: string, args: GitSnapshotInput) => string; toolAddTranslation: (root: string, args: AddTranslationInput) => string; + toolGetTranslation: (root: string, args: GetTranslationInput) => string; + toolInitWorkspace: (root: string, args: InitWorkspaceInput) => string; + toolSetupAiFiles: (root: string, args: SetupAiFilesInput) => string; + toolMemoryList: (root: string) => string; + toolMemoryAppend: (root: string, args: MemoryAppendInput) => string; + toolMemoryCompact: (root: string, args: MemoryCompactInput) => string; } /** @@ -124,6 +135,30 @@ export function registerLmTools(context: vscode.ExtensionContext): void { vscode.lm.registerTool('bindery_add_translation', { invoke: async (opts, _token) => ok(t.toolAddTranslation(requireRoot(), opts.input)), }), + + vscode.lm.registerTool('bindery_get_translation', { + invoke: async (opts, _token) => ok(t.toolGetTranslation(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_init_workspace', { + invoke: async (opts, _token) => ok(t.toolInitWorkspace(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_setup_ai_files', { + invoke: async (opts, _token) => ok(t.toolSetupAiFiles(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_memory_list', { + invoke: async (_opts, _token) => ok(t.toolMemoryList(requireRoot())), + }), + + vscode.lm.registerTool('bindery_memory_append', { + invoke: async (opts, _token) => ok(t.toolMemoryAppend(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_memory_compact', { + invoke: async (opts, _token) => ok(t.toolMemoryCompact(requireRoot(), opts.input)), + }), ); }