feat: Enhance Bindery MCP with new translation and memory management tools

- Updated README.md to reflect new features including session memory and enhanced translation management.
- Modified manifest.json to include new tools: init_workspace, setup_ai_files, get_translation, memory_list, memory_append, and memory_compact.
- Added activation event for VS Code extension on startup.
- Implemented new AI setup features in ai-setup.ts, including versioning for AI files and template rendering.
- Updated extension.ts to check for AI setup version and prompt users to update if necessary.
- Expanded mcp.ts to register new tools for translation and memory management, enhancing the overall functionality of the Bindery MCP.
This commit is contained in:
Erik van der Boom 2026-04-03 00:15:02 +02:00
parent 00c71050b4
commit eb73ef7e5c
13 changed files with 1518 additions and 475 deletions

176
.github/copilot-instructions.md vendored Normal file
View file

@ -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>/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/`.

View file

@ -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}"

4
.gitignore vendored
View file

@ -26,3 +26,7 @@ Thumbs.db
*.swp
*.swo
*~
# misc
.FluxCoreInfo/
ai-setup-templates.ts

147
mcp-ts/src/aisetup.ts Normal file
View file

@ -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>/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<string, string>;
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'
);
}

View file

@ -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<void> {

533
mcp-ts/src/templates.ts Normal file
View file

@ -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")
`;
}

View file

@ -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<string>([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<string, unknown> = {};
const isNew = !fs.existsSync(settingsPath);
if (!isNew) {
try { existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>; }
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<string, unknown> = {
...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 {

View file

@ -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

View file

@ -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." }
]
}

View file

@ -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": {

View file

@ -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 35 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
`;
}
}

View file

@ -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';

View file

@ -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<AddTranslationInput>('bindery_add_translation', {
invoke: async (opts, _token) => ok(t.toolAddTranslation(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<GetTranslationInput>('bindery_get_translation', {
invoke: async (opts, _token) => ok(t.toolGetTranslation(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<InitWorkspaceInput>('bindery_init_workspace', {
invoke: async (opts, _token) => ok(t.toolInitWorkspace(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<SetupAiFilesInput>('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<MemoryAppendInput>('bindery_memory_append', {
invoke: async (opts, _token) => ok(t.toolMemoryAppend(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<MemoryCompactInput>('bindery_memory_compact', {
invoke: async (opts, _token) => ok(t.toolMemoryCompact(requireRoot(), opts.input)),
}),
);
}