feat: update AI setup version to 5 and add chapter status tools

- Incremented AI_SETUP_VERSION in ai-setup.ts to 5 to reflect changes.
- Added new tools for chapter status management in index.ts:
  - chapter_status_get: retrieves current chapter progress from .bindery/chapter-status.json.
  - chapter_status_update: upserts chapter progress entries in .bindery/chapter-status.json.
- Updated templates.ts to include new tools in the status snapshot.
- Enhanced tools.ts with chapter status data structures and logic for managing chapter status.
- Updated manifest.json to include descriptions for new chapter status tools.
- Added launch and task configurations for vscode-ext to facilitate development.
- Updated package.json in vscode-ext to include tool reference names for new tools.
- Adjusted mcp.ts to integrate chapter status tools into the MCP interface.
This commit is contained in:
Erik van der Boom 2026-04-03 15:09:27 +02:00
parent 16eef1dd26
commit 0937d8c4b9
20 changed files with 2075 additions and 16 deletions

16
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/vscode-ext",
"--disable-extension=option-a.bindery"
],
"outFiles": ["${workspaceFolder}/vscode-ext/out/**/*.js"],
"preLaunchTask": "compile-vscode-ext"
}
]
}

14
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "compile-vscode-ext",
"type": "shell",
"command": "npx tsc -p ./",
"options": { "cwd": "${workspaceFolder}/vscode-ext" },
"group": "build",
"presentation": { "reveal": "silent" },
"problemMatcher": "$tsc"
}
]
}

128
mcp-ts/out/aisetup.js Normal file
View file

@ -0,0 +1,128 @@
"use strict";
/**
* 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.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.AI_SETUP_VERSION = exports.ALL_SKILLS = void 0;
exports.setupAiFiles = setupAiFiles;
const fs = __importStar(require("node:fs"));
const path = __importStar(require("node:path"));
const templates_js_1 = require("./templates.js");
exports.ALL_SKILLS = [
'review', 'brainstorm', 'memory', 'translate', 'status', 'continuity', 'read_aloud',
];
/**
* 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.
*/
exports.AI_SETUP_VERSION = 5;
// ─── Context builder ──────────────────────────────────────────────────────────
function buildContext(s) {
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 ──────────────────────────────────────────────────────────────
function setupAiFiles(options) {
const { root, targets, skills = exports.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 = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
const ctx = buildContext(settings);
const result = { created: [], skipped: [] };
for (const target of targets) {
switch (target) {
case 'claude':
writeFile(root, 'CLAUDE.md', (0, templates_js_1.renderTemplate)('claude', ctx), overwrite, result);
for (const skill of skills) {
writeFile(root, path.join('.claude', 'skills', skill, 'SKILL.md'), (0, templates_js_1.renderTemplate)(skill, ctx), overwrite, result);
}
break;
case 'copilot':
writeFile(root, path.join('.github', 'copilot-instructions.md'), (0, templates_js_1.renderTemplate)('copilot', ctx), overwrite, result);
break;
case 'cursor':
writeFile(root, path.join('.cursor', 'rules'), (0, templates_js_1.renderTemplate)('cursor', ctx), overwrite, result);
break;
case 'agents':
writeFile(root, 'AGENTS.md', (0, templates_js_1.renderTemplate)('agents', ctx), overwrite, result);
break;
}
}
stampAiVersion(root);
return result;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function writeFile(root, relPath, content, overwrite, result) {
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) {
const dir = path.join(root, '.bindery');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'ai-version.json'), JSON.stringify({ version: exports.AI_SETUP_VERSION }, null, 2) + '\n', 'utf-8');
}
//# sourceMappingURL=aisetup.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"aisetup.js","sourceRoot":"","sources":["../src/aisetup.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgFH,oCAkCC;AAhHD,4CAAgC;AAChC,gDAAkC;AAClC,iDAAsE;AAezD,QAAA,UAAU,GAAoB;IACvC,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY;CACtF,CAAC;AAcF;;;;GAIG;AACU,QAAA,gBAAgB,GAAG,CAAC,CAAC;AAclC,iFAAiF;AAEjF,SAAS,YAAY,CAAC,CAAW;IAC7B,MAAM,KAAK,GAAS,CAAC,OAAO,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,UAAU,CAAC;IAC9F,MAAM,MAAM,GAAQ,CAAC,CAAC,MAAM,IAAY,EAAE,CAAC;IAC3C,MAAM,WAAW,GAAG,CAAC,CAAC,WAAW,IAAO,EAAE,CAAC;IAC3C,MAAM,KAAK,GAAS,CAAC,CAAC,KAAK,IAAa,EAAE,CAAC;IAC3C,MAAM,QAAQ,GAAM,CAAC,CAAC,cAAc,IAAI,EAAE,CAAC;IAC3C,MAAM,WAAW,GAAG,CAAC,CAAC,WAAW,IAAO,OAAO,CAAC;IAChD,MAAM,SAAS,GAAK,CAAC,CAAC,SAAS,IAAS,EAAE,CAAC;IAE3C,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,GAAG,CAAC;QACjC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,gBAAgB,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;QAChG,CAAC,CAAC,aAAa,CAAC;IAEpB,OAAO;QACH,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,QAAQ;QAC3C,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK;QACnD,cAAc,EAAE,mBAAmB;QACnC,SAAS,EAAE,QAAQ;QACnB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,CAAC;KACrC,CAAC;AACN,CAAC;AAED,iFAAiF;AAEjF,SAAgB,YAAY,CAAC,OAAuB;IAChD,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,kBAAU,EAAE,SAAS,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC;IAE1E,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,eAAe,CAAC,CAAC;IAClE,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;IAC3E,CAAC;IACD,MAAM,QAAQ,GAAa,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,OAAO,CAAC,CAAa,CAAC;IAC1F,MAAM,GAAG,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;IAEnC,MAAM,MAAM,GAAkB,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;IAE3D,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC3B,QAAQ,MAAM,EAAE,CAAC;YACb,KAAK,QAAQ;gBACT,SAAS,CAAC,IAAI,EAAE,WAAW,EAAE,IAAA,6BAAc,EAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;gBAC/E,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;oBACzB,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,UAAU,CAAC,EAAE,IAAA,6BAAc,EAAC,KAAK,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;gBACtH,CAAC;gBACD,MAAM;YACV,KAAK,SAAS;gBACV,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,yBAAyB,CAAC,EAAE,IAAA,6BAAc,EAAC,SAAS,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;gBACpH,MAAM;YACV,KAAK,QAAQ;gBACT,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,IAAA,6BAAc,EAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;gBACjG,MAAM;YACV,KAAK,QAAQ;gBACT,SAAS,CAAC,IAAI,EAAE,WAAW,EAAE,IAAA,6BAAc,EAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;gBAC/E,MAAM;QACd,CAAC;IACL,CAAC;IAED,cAAc,CAAC,IAAI,CAAC,CAAC;IACrB,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,iFAAiF;AAEjF,SAAS,SAAS,CAAC,IAAY,EAAE,OAAe,EAAE,OAAe,EAAE,SAAkB,EAAE,MAAqB;IACxG,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACtC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;QACpC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC7B,OAAO;IACX,CAAC;IACD,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtD,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IACzC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACjC,CAAC;AAED,SAAS,cAAc,CAAC,IAAY;IAChC,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACxC,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,EAAE,CAAC,aAAa,CACZ,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,EACjC,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,wBAAgB,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EAC7D,OAAO,CACV,CAAC;AACN,CAAC"}

View file

@ -24,8 +24,10 @@ function ok(text) { return { content: [{ type: 'text', text }] }; }
function err(e) { return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true }; }
// ─── Tools ────────────────────────────────────────────────────────────────────
server.registerTool('list_books', {
title: 'List Books',
description: 'List all books registered via --book args in the MCP server config. Call this first to discover available book names.',
inputSchema: {},
annotations: { readOnlyHint: true },
}, async () => {
const books = (0, registry_js_1.listBooks)();
if (books.length === 0) {
@ -36,6 +38,7 @@ server.registerTool('list_books', {
return ok(books.map(b => `${b.name}${b.path}`).join('\n'));
});
server.registerTool('identify_book', {
title: 'Identify Book',
description: 'Identify which book matches the directory you are working in. ' +
'Pass your current working directory (e.g. /home/user/Me/MyNovel) and the server ' +
'will match it against registered books by folder name or .bindery/settings.json. ' +
@ -43,6 +46,7 @@ server.registerTool('identify_book', {
inputSchema: {
workingDirectory: zod_1.z.string().describe('The absolute path of your current working directory or project root.'),
},
annotations: { readOnlyHint: true },
}, async ({ workingDirectory }) => {
try {
const match = (0, registry_js_1.findBookByPath)(workingDirectory);
@ -60,8 +64,10 @@ server.registerTool('identify_book', {
}
});
server.registerTool('health', {
title: 'Health Check',
description: 'Check server status: active book, settings, index, and embedding backend.',
inputSchema: { book: bookSchema },
annotations: { readOnlyHint: true },
}, async ({ book }) => {
try {
return ok((0, tools_js_1.toolHealth)((0, registry_js_1.resolveBook)(book).root));
@ -71,8 +77,10 @@ server.registerTool('health', {
}
});
server.registerTool('index_build', {
title: 'Build Index',
description: 'Build or rebuild the search index for a book. Run after adding/editing chapters.',
inputSchema: { book: bookSchema },
annotations: { destructiveHint: true },
}, async ({ book }) => {
try {
return ok((0, tools_js_1.toolIndexBuild)((0, registry_js_1.resolveBook)(book).root));
@ -82,8 +90,10 @@ server.registerTool('index_build', {
}
});
server.registerTool('index_status', {
title: 'Index Status',
description: 'Show current index metadata: chunk count and build time.',
inputSchema: { book: bookSchema },
annotations: { readOnlyHint: true },
}, async ({ book }) => {
try {
return ok((0, tools_js_1.toolIndexStatus)((0, registry_js_1.resolveBook)(book).root));
@ -93,6 +103,7 @@ server.registerTool('index_status', {
}
});
server.registerTool('get_text', {
title: 'Get Text',
description: 'Read a source file by relative path, optionally restricted to a line range.',
inputSchema: {
book: bookSchema,
@ -100,6 +111,7 @@ server.registerTool('get_text', {
startLine: zod_1.z.number().optional().describe('1-based start line (optional)'),
endLine: zod_1.z.number().optional().describe('1-based end line inclusive (optional)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, identifier, startLine, endLine }) => {
try {
return ok((0, tools_js_1.toolGetText)((0, registry_js_1.resolveBook)(book).root, { identifier, startLine, endLine }));
@ -109,12 +121,14 @@ server.registerTool('get_text', {
}
});
server.registerTool('get_chapter', {
title: 'Get Chapter',
description: 'Fetch the full content of a chapter by number and language.',
inputSchema: {
book: bookSchema,
chapterNumber: zod_1.z.number().describe('Chapter number (1-based)'),
language: zod_1.z.string().describe('Language code, e.g. EN or NL'),
},
annotations: { readOnlyHint: true },
}, async ({ book, chapterNumber, language }) => {
try {
return ok((0, tools_js_1.toolGetChapter)((0, registry_js_1.resolveBook)(book).root, { chapterNumber, language }));
@ -124,12 +138,14 @@ server.registerTool('get_chapter', {
}
});
server.registerTool('get_overview', {
title: 'Get Overview',
description: 'List the chapter structure (acts, chapters, titles) for one or all languages.',
inputSchema: {
book: bookSchema,
language: zod_1.z.string().optional().describe('Language code or ALL (default: ALL)'),
act: zod_1.z.number().optional().describe('Filter to act number 1, 2, or 3 (optional)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, language, act }) => {
try {
return ok((0, tools_js_1.toolGetOverview)((0, registry_js_1.resolveBook)(book).root, { language, act }));
@ -139,12 +155,14 @@ server.registerTool('get_overview', {
}
});
server.registerTool('get_notes', {
title: 'Get Notes',
description: 'Read from Notes/ and Details_*.md files, optionally filtered by category name or character/place name.',
inputSchema: {
book: bookSchema,
category: zod_1.z.string().optional().describe('Filter by file/category name substring'),
name: zod_1.z.string().optional().describe('Filter sections containing this name'),
},
annotations: { readOnlyHint: true },
}, async ({ book, category, name }) => {
try {
return ok((0, tools_js_1.toolGetNotes)((0, registry_js_1.resolveBook)(book).root, { category, name }));
@ -154,6 +172,7 @@ server.registerTool('get_notes', {
}
});
server.registerTool('search', {
title: 'Search',
description: 'Full-text BM25 search across all story and notes files. Returns ranked snippets.',
inputSchema: {
book: bookSchema,
@ -161,6 +180,7 @@ server.registerTool('search', {
language: zod_1.z.string().optional().describe('Language filter: EN, NL, or ALL'),
maxResults: zod_1.z.number().optional().describe('Max results to return (default 10)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, query, language, maxResults }) => {
try {
return ok(await (0, tools_js_1.toolSearch)((0, registry_js_1.resolveBook)(book).root, { query, language, maxResults }));
@ -170,6 +190,7 @@ server.registerTool('search', {
}
});
server.registerTool('retrieve_context', {
title: 'Retrieve Context',
description: 'Retrieve the most relevant passages for a query. Best for "where did X happen" or "what did character Y say about Z".',
inputSchema: {
book: bookSchema,
@ -177,6 +198,7 @@ server.registerTool('retrieve_context', {
language: zod_1.z.string().optional().describe('Language filter: EN, NL, or ALL'),
topK: zod_1.z.number().optional().describe('Number of results (default 6)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, query, language, topK }) => {
try {
return ok(await (0, tools_js_1.toolRetrieveContext)((0, registry_js_1.resolveBook)(book).root, { query, language, topK }));
@ -186,6 +208,7 @@ server.registerTool('retrieve_context', {
}
});
server.registerTool('format', {
title: 'Format Typography',
description: 'Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder.',
inputSchema: {
book: bookSchema,
@ -193,6 +216,7 @@ server.registerTool('format', {
dryRun: zod_1.z.boolean().optional().describe('Preview changes without writing (default false)'),
noRecurse: zod_1.z.boolean().optional().describe('Do not recurse into subdirectories (default false)'),
},
annotations: { destructiveHint: true },
}, async ({ book, filePath, dryRun, noRecurse }) => {
try {
return ok((0, tools_js_1.toolFormat)((0, registry_js_1.resolveBook)(book).root, { filePath, dryRun, noRecurse }));
@ -201,6 +225,286 @@ server.registerTool('format', {
return err(e);
}
});
server.registerTool('get_review_text', {
title: 'Review Text',
description: 'Structured git diff of uncommitted changes with context lines. ' +
'Filter by language folder (EN, NL, or ALL). Ignores CR-at-EOL to avoid CRLF noise. ' +
'Set autoStage to true to stage reviewed files so the next call only shows new changes.',
inputSchema: {
book: bookSchema,
language: zod_1.z.string().optional().describe('Language filter: EN, NL, or ALL (default ALL)'),
contextLines: zod_1.z.number().optional().describe('Context lines around each change (default 3)'),
autoStage: zod_1.z.boolean().optional().describe('Stage reviewed files after producing diff (default false)'),
},
annotations: { destructiveHint: true },
}, async ({ book, language, contextLines, autoStage }) => {
try {
return ok((0, tools_js_1.toolGetReviewText)((0, registry_js_1.resolveBook)(book).root, { language, contextLines, autoStage }));
}
catch (e) {
return err(e);
}
});
server.registerTool('git_snapshot', {
title: 'Git Snapshot',
description: 'Save a snapshot (git commit) of all changes in story, notes, and arc folders. ' +
'Provides an optional commit message — defaults to a timestamp. ' +
'Use this to create save points after writing sessions or successful reviews.',
inputSchema: {
book: bookSchema,
message: zod_1.z.string().optional().describe('Snapshot message (default: timestamp)'),
},
annotations: { destructiveHint: true },
}, async ({ book, message }) => {
try {
return ok((0, tools_js_1.toolGitSnapshot)((0, registry_js_1.resolveBook)(book).root, { message }));
}
catch (e) {
return err(e);
}
});
server.registerTool('get_translation', {
title: 'Get Translation',
description: 'Look up glossary entries in .bindery/translations.json. ' +
'Without a word, lists all entries for the language. ' +
'With a word, does a forgiving case-insensitive lookup including plural and inflected forms. ' +
'For dialect substitution rules, use get_dialect instead.',
inputSchema: {
book: bookSchema,
language: zod_1.z.string().describe('Language code or label (e.g. "nl", "fr", "Dutch")'),
word: zod_1.z.string().optional().describe('Word or term to look up (optional — omit to list all)'),
type: zod_1.z.enum(['glossary', 'substitution']).optional().describe('Entry type filter — defaults to "glossary"'),
},
annotations: { readOnlyHint: true },
}, async ({ book, language, word, type }) => {
try {
return ok((0, tools_js_1.toolGetTranslation)((0, registry_js_1.resolveBook)(book).root, { language, word, type }));
}
catch (e) {
return err(e);
}
});
server.registerTool('add_translation', {
title: 'Add Translation',
description: 'Add or update a glossary entry in .bindery/translations.json for agent reference. ' +
'Glossaries are cross-language term pairs (source → target language) used by agents for consistency, ' +
'not auto-applied during export. For dialect substitution rules (e.g. US→UK spelling), use add_dialect.',
inputSchema: {
book: bookSchema,
targetLangCode: zod_1.z.string().describe('Target language code (e.g. "nl", "fr")'),
from: zod_1.z.string().describe('Source term (e.g. "FluxCore")'),
to: zod_1.z.string().describe('Target term (e.g. "FluxKern")'),
},
annotations: { destructiveHint: true },
}, async ({ book, targetLangCode, from, to }) => {
try {
return ok((0, tools_js_1.toolAddTranslation)((0, registry_js_1.resolveBook)(book).root, { targetLangCode, from, to }));
}
catch (e) {
return err(e);
}
});
server.registerTool('add_dialect', {
title: 'Add Dialect Rule',
description: 'Add or update a dialect substitution rule in .bindery/translations.json. ' +
'Substitution rules are auto-applied during export (e.g. US→UK spelling: color→colour). ' +
'For cross-language glossary entries, use add_translation.',
inputSchema: {
book: bookSchema,
dialectCode: zod_1.z.string().describe('Dialect code used as key, e.g. "en-gb"'),
from: zod_1.z.string().describe('Source word (e.g. "color")'),
to: zod_1.z.string().describe('Target word (e.g. "colour")'),
},
annotations: { destructiveHint: true },
}, async ({ book, dialectCode, from, to }) => {
try {
return ok((0, tools_js_1.toolAddDialect)((0, registry_js_1.resolveBook)(book).root, { dialectCode, from, to }));
}
catch (e) {
return err(e);
}
});
server.registerTool('get_dialect', {
title: 'Get Dialect Rules',
description: 'Look up dialect substitution rules in .bindery/translations.json. ' +
'Without a word, lists all rules for the dialect. ' +
'With a word, does a forgiving case-insensitive lookup. ' +
'For cross-language glossary entries, use get_translation.',
inputSchema: {
book: bookSchema,
dialectCode: zod_1.z.string().describe('Dialect code, e.g. "en-gb"'),
word: zod_1.z.string().optional().describe('Word to look up (optional — omit to list all rules)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, dialectCode, word }) => {
try {
return ok((0, tools_js_1.toolGetDialect)((0, registry_js_1.resolveBook)(book).root, { dialectCode, word }));
}
catch (e) {
return err(e);
}
});
server.registerTool('add_language', {
title: 'Add Language',
description: 'Add a new language to .bindery/settings.json and optionally scaffold ' +
'its story folder with stub files mirroring the default language structure.',
inputSchema: {
book: bookSchema,
code: zod_1.z.string().describe('Language code, e.g. "NL", "FR", "DE"'),
folderName: zod_1.z.string().optional().describe('Story subfolder name (defaults to code)'),
chapterWord: zod_1.z.string().optional().describe('Word for "Chapter" in this language'),
actPrefix: zod_1.z.string().optional().describe('Word for "Act" prefix in this language'),
prologueLabel: zod_1.z.string().optional().describe('Word for "Prologue" in this language'),
epilogueLabel: zod_1.z.string().optional().describe('Word for "Epilogue" in this language'),
createStubs: zod_1.z.boolean().optional().describe('Mirror source language folder with stub files (default true)'),
},
annotations: { destructiveHint: true },
}, async ({ book, code, folderName, chapterWord, actPrefix, prologueLabel, epilogueLabel, createStubs }) => {
try {
return ok((0, tools_js_1.toolAddLanguage)((0, registry_js_1.resolveBook)(book).root, { code, folderName, chapterWord, actPrefix, prologueLabel, epilogueLabel, createStubs }));
}
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: zod_1.z.string().optional().describe('Book title (defaults to folder name)'),
author: zod_1.z.string().optional().describe('Author name'),
storyFolder: zod_1.z.string().optional().describe('Story folder name relative to root (default: Story)'),
genre: zod_1.z.string().optional().describe('Genre, e.g. sci-fi/fantasy'),
description: zod_1.z.string().optional().describe('One-line description used in AI instruction files'),
targetAudience: zod_1.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((0, tools_js_1.toolInitWorkspace)((0, registry_js_1.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: zod_1.z.array(zod_1.z.string()).optional().describe('Which files to generate: claude, copilot, cursor, agents. Default: all.'),
skills: zod_1.z.array(zod_1.z.string()).optional().describe('Which Claude skills to generate: review, brainstorm, memory, translate, status, continuity, read_aloud. Default: all.'),
overwrite: zod_1.z.boolean().optional().describe('Overwrite existing files? Default false (skip existing).'),
},
annotations: { destructiveHint: true },
}, async ({ book, targets, skills, overwrite }) => {
try {
return ok((0, tools_js_1.toolSetupAiFiles)((0, registry_js_1.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((0, tools_js_1.toolMemoryList)((0, registry_js_1.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: zod_1.z.string().describe('Filename within .bindery/memories/, e.g. global.md or ch10.md'),
title: zod_1.z.string().describe('Short session title describing the topic'),
content: zod_1.z.string().describe('Text to record under this session entry'),
},
annotations: { destructiveHint: true },
}, async ({ book, file, title, content }) => {
try {
return ok((0, tools_js_1.toolMemoryAppend)((0, registry_js_1.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: zod_1.z.string().describe('Filename within .bindery/memories/, e.g. global.md'),
compacted_content: zod_1.z.string().describe('Full replacement content (model-supplied summary)'),
},
annotations: { destructiveHint: true },
}, async ({ book, file, compacted_content }) => {
try {
return ok((0, tools_js_1.toolMemoryCompact)((0, registry_js_1.resolveBook)(book).root, { file, compacted_content }));
}
catch (e) {
return err(e);
}
});
server.registerTool('chapter_status_get', {
title: 'Chapter Status Get',
description: 'Read the current chapter progress tracker from .bindery/chapter-status.json. ' +
'Returns a formatted summary grouped by status (done, in-progress, draft, planned, needs-review). ' +
'Returns a clear empty-state message if no status has been recorded yet.',
inputSchema: { book: bookSchema },
annotations: { readOnlyHint: true },
}, async ({ book }) => {
try {
return ok((0, tools_js_1.toolChapterStatusGet)((0, registry_js_1.resolveBook)(book).root));
}
catch (e) {
return err(e);
}
});
server.registerTool('chapter_status_update', {
title: 'Chapter Status Update',
description: 'Upsert chapter progress entries in .bindery/chapter-status.json. ' +
'Send only the chapters that changed — existing entries not in the payload are preserved. ' +
'Creates the file if it does not exist. ' +
'Each entry requires: number (int), title (string), language (e.g. EN), status (done | in-progress | draft | planned | needs-review). ' +
'Optional: wordCount (int), notes (string).',
inputSchema: {
book: bookSchema,
chapters: zod_1.z.array(zod_1.z.object({
number: zod_1.z.number().int().describe('Chapter number'),
title: zod_1.z.string().describe('Chapter title'),
language: zod_1.z.string().describe('Language code, e.g. EN or NL'),
status: zod_1.z.enum(['done', 'in-progress', 'draft', 'planned', 'needs-review']),
wordCount: zod_1.z.number().int().optional().describe('Approximate word count'),
notes: zod_1.z.string().optional().describe('Short agent note about this chapter'),
})).describe('Chapter entries to upsert (existing entries not listed are preserved)'),
},
annotations: { destructiveHint: true },
}, async ({ book, chapters }) => {
try {
return ok((0, tools_js_1.toolChapterStatusUpdate)((0, registry_js_1.resolveBook)(book).root, { chapters }));
}
catch (e) {
return err(e);
}
});
// ─── Start ────────────────────────────────────────────────────────────────────
async function main() {
const transport = new stdio_js_1.StdioServerTransport();

File diff suppressed because one or more lines are too long

489
mcp-ts/out/templates.js Normal file
View file

@ -0,0 +1,489 @@
"use strict";
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderTemplate = renderTemplate;
// ─── 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'
*/
function renderTemplate(name, ctx) {
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) {
return ctx.audience ? `Target audience: ${ctx.audience}.` : '';
}
function languageSection(ctx) {
if (!ctx.hasMultiLang) {
return '';
}
return `\nLanguages: ${ctx.langList}.\n`;
}
// ─── Top-level templates ──────────────────────────────────────────────────────
function claudeMd(ctx) {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx;
const lines = [
`# 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.`, '', '## Memory system', '1. When the user ask or otherwise indicates the end of a session: use /memory to save decisions.', `2. When switching to another chapter read ${memoriesFolder}/chXX.md if it exists. for that chapter`, '', '## Repo layout', '```', `${arcFolder}/ ← story arc files (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`, `${notesFolder}/ ← story bible (characters, world)`, `${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('', '## 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 |', '', '## MCP server (bindery-mcp)', '', 'All tools require a `book` argument. Use `list_books` to discover available names.', 'Prefer these tools over Read/Bash when they apply.', '', '| 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 glossary entries for a language, or look up a specific term (forgiving) |', '| `add_translation` | Add or update a cross-language glossary entry (agent reference, not auto-applied) |', '| `get_dialect` | List dialect substitution rules, or look up a specific word |', '| `add_dialect` | Add or update a dialect substitution rule (auto-applied at export, e.g. US→UK) |', '| `add_language` | Add a language to settings.json and scaffold its story folder with stubs |', '| `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) |');
return lines.filter(l => l !== '\n').join('\n') + '\n';
}
function copilotMd(ctx) {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const lines = [`# 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) {
const { title, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx;
const lines = [
`# 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) {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder, memoriesFolder } = ctx;
const lines = [`# 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) {
const { title, arcFolder, memoriesFolder } = ctx;
const audienceStr = ctx.audience || 'the target audience';
return `---
name: Review
description: Review a chapter of "${title}" for language, arc consistency, and age-appropriateness. Use for /review, "review chapter X", "quick review", or "review my changes".
---
# 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) {
const { title, arcFolder, memoriesFolder } = ctx;
const audienceStr = ctx.audience || 'the target audience';
return `---
name: Brainstorm
description: Brainstorm story ideas, plot beats, character moments, or scene concepts for "${title}". Use for /brainstorm, "I'm stuck", "help me think of ideas", or "Am I stuck?".
---
# 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) {
return `---
name: Memory
description: Save session decisions to persistent memory files using Bindery MCP tools. Use for /memory, "save this to memory", "update memories", or at end of session.
---
# 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) {
return `---
name: Translate
description: Translate a chapter or spot-check an existing translation using the Bindery translation table. Use for /translate, "translate chapter X", or "help me with the translation".
---
# 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 glossary entries for a target language (e.g. \`"nl"\`)
- \`get_translation(language, word)\` — look up a specific term; forgiving: case-insensitive, handles plurals and inflected forms
- \`get_dialect(dialectCode)\` — list dialect substitution rules (e.g. \`"en-gb"\`)
- \`search(query, language)\` — verify how a term was rendered in other translated chapters
- \`add_translation(targetLangCode, from, to)\` — save a new glossary term pair when the user confirms a translation choice
- \`add_dialect(dialectCode, from, to)\` — save a spelling substitution rule (e.g. US→UK) applied automatically at export
## Steps
### 1. Load the translation table
Call \`get_translation(language)\` to load all known glossary 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 as a glossary entry. For spelling variant rules (dialect substitutions applied at export), use \`add_dialect\` instead.
## Rules
- Always load the translation table first never invent translations for world-specific terms
- Flag uncertain terms rather than guessing
`;
}
function skillStatus(ctx) {
const { arcFolder, memoriesFolder } = ctx;
return `---
name: Status
description: Give a book progress snapshot chapters done, in progress, and coming up. Use for /status, "what's the book status", or "where are we".
---
# 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:
- \`chapter_status_get(book)\` — read the structured progress tracker from \`.bindery/chapter-status.json\`
- \`chapter_status_update(book, chapters)\` — upsert chapter progress entries (send only changed chapters)
- \`get_overview(language)\` — list all acts and chapters with titles
- \`get_text(identifier)\` — read COWORK.md and \`${memoriesFolder}/global.md\`
- \`memory_list\` — discover which chapter memory files exist (\`chXX.md\`)
## Steps
1. Use \`chapter_status_get\` to read the current tracker. Use \`memory_list\` to check available memory files. Use \`get_text\` to read COWORK.md (current focus) and \`${memoriesFolder}/global.md\`.
2. Use \`get_overview\` for the full chapter listing if the tracker is empty or incomplete.
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.
5. If the tracker is out of date or missing entries, update it with \`chapter_status_update\` (upsert only the changed chapters).
## Output
Keep it scannable bold headers, short lines. This is a working tool, not a narrative summary.
`;
}
function skillContinuity(ctx) {
const { memoriesFolder } = ctx;
return `---
name: Continuity
description: Cross-check a chapter for consistency errors in characters, world rules, or timeline. Use for /continuity, "check continuity", or "check chapter X for errors".
---
# 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) {
const audienceStr = ctx.audience || 'the target audience';
return `---
name: Read Aloud
description: Test how a chapter or passage sounds when read aloud flags long sentences, staccato rhythm, complex vocabulary, and said-bookisms. Use for /read-aloud, "reading test", or "how does this sound".
---
# 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")
`;
}
//# sourceMappingURL=templates.js.map

File diff suppressed because one or more lines are too long

View file

@ -50,10 +50,26 @@ exports.toolGetNotes = toolGetNotes;
exports.toolSearch = toolSearch;
exports.toolRetrieveContext = toolRetrieveContext;
exports.toolFormat = toolFormat;
exports.toolGetReviewText = toolGetReviewText;
exports.toolGitSnapshot = toolGitSnapshot;
exports.toolGetTranslation = toolGetTranslation;
exports.toolAddTranslation = toolAddTranslation;
exports.toolAddDialect = toolAddDialect;
exports.toolGetDialect = toolGetDialect;
exports.toolAddLanguage = toolAddLanguage;
exports.toolInitWorkspace = toolInitWorkspace;
exports.toolSetupAiFiles = toolSetupAiFiles;
exports.toolMemoryList = toolMemoryList;
exports.toolMemoryAppend = toolMemoryAppend;
exports.toolMemoryCompact = toolMemoryCompact;
exports.toolChapterStatusGet = toolChapterStatusGet;
exports.toolChapterStatusUpdate = toolChapterStatusUpdate;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const child_process_1 = require("child_process");
const format_js_1 = require("./format.js");
const search_js_1 = require("./search.js");
const aisetup_js_1 = require("./aisetup.js");
// ─── Shared helpers ───────────────────────────────────────────────────────────
function readJson(filePath) {
if (!fs.existsSync(filePath)) {
@ -76,7 +92,17 @@ function storyFolder(root) {
function toolHealth(root) {
const lines = [`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 = (0, search_js_1.indexPath)(root);
if (fs.existsSync(idxPath)) {
const raw = readJson(idxPath);
@ -381,6 +407,717 @@ function processFormatFile(filePath, dry) {
}
return true;
}
function toolGetReviewText(root, args) {
const contextLines = args.contextLines ?? 3;
const language = (args.language ?? 'ALL').toUpperCase();
let raw;
try {
raw = (0, child_process_1.execSync)(`git diff --ignore-cr-at-eol -U${contextLines}`, { cwd: root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
}
catch {
return 'Failed to run git diff. Is this a git repository?';
}
if (!raw.trim()) {
return 'No uncommitted changes.';
}
const files = parseUnifiedDiff(raw);
const filtered = language === 'ALL'
? files
: files.filter(f => {
const upper = f.file.toUpperCase().replace(/\\/g, '/');
return upper.includes(`/${language}/`);
});
if (filtered.length === 0) {
return language === 'ALL'
? 'No uncommitted changes.'
: `No uncommitted changes in ${language} files.`;
}
const result = formatReviewFiles(filtered);
// Stage reviewed files so the next review only shows new changes
if (args.autoStage) {
const contentDirs = contentFolders(root);
try {
(0, child_process_1.execSync)(`git add ${contentDirs.map(d => `"${d}"`).join(' ')}`, { cwd: root, encoding: 'utf-8' });
}
catch { /* best effort — staging failure shouldn't break the review */ }
}
return result;
}
/** Content folders that git operations should scope to. */
function contentFolders(root) {
const story = storyFolder(root);
return [story, 'Notes', 'Arc'].filter(d => fs.existsSync(path.join(root, d)));
}
function toolGitSnapshot(root, args) {
const dirs = contentFolders(root);
if (dirs.length === 0) {
return 'No content folders found to snapshot.';
}
// Stage content folders
try {
(0, child_process_1.execSync)(`git add ${dirs.map(d => `"${d}"`).join(' ')}`, { cwd: root, encoding: 'utf-8' });
}
catch {
return 'Failed to stage files. Is this a git repository?';
}
// Check if there is anything staged
let staged;
try {
staged = (0, child_process_1.execSync)('git diff --cached --name-only', { cwd: root, encoding: 'utf-8' });
}
catch {
return 'Failed to check staged files.';
}
if (!staged.trim()) {
return 'Nothing to snapshot — no changes in content folders.';
}
const fileCount = staged.trim().split('\n').length;
const msg = args.message ?? `Snapshot ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
try {
(0, child_process_1.execSync)(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: root, encoding: 'utf-8' });
}
catch (e) {
return `Failed to commit: ${e instanceof Error ? e.message : String(e)}`;
}
return `Snapshot saved: "${msg}" (${fileCount} file${fileCount === 1 ? '' : 's'})`;
}
function toolGetTranslation(root, args) {
const filePath = path.join(root, '.bindery', 'translations.json');
if (!fs.existsSync(filePath)) {
return 'No translations.json found. Run "init_workspace" or "add_translation" first.';
}
let translations;
try {
translations = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return 'Error: failed to parse .bindery/translations.json';
}
const entryType = args.type ?? 'glossary';
const langLower = args.language.toLowerCase();
// Resolve key — case-insensitive, accept code or label
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)
.filter(([, e]) => e.type === entryType || args.type === undefined)
.map(([k, e]) => `${k}${e.label ? ` (${e.label})` : ''}`)
.join(', ');
return `No translation entry found for "${args.language}". Available: ${available || 'none'}`;
}
const entry = translations[matchedKey];
if (entry.type !== entryType) {
return `Entry "${matchedKey}" is type "${entry.type}", not "${entryType}". Use get_dialect for substitution rules.`;
}
const rules = entry.rules ?? [];
if (!args.word) {
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');
}
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) {
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);
}
// ─── Built-in en-gb substitution rules (US → British English) ────────────────
const BUILTIN_EN_GB_RULES = [
{ from: 'analyze', to: 'analyse' },
{ from: 'analyzes', to: 'analyses' },
{ from: 'analyzed', to: 'analysed' },
{ from: 'analyzing', to: 'analysing' },
{ from: 'canceled', to: 'cancelled' },
{ from: 'canceling', to: 'cancelling' },
{ from: 'center', to: 'centre' },
{ from: 'centers', to: 'centres' },
{ from: 'centered', to: 'centred' },
{ from: 'centering', to: 'centring' },
{ from: 'color', to: 'colour' },
{ from: 'colors', to: 'colours' },
{ from: 'colored', to: 'coloured' },
{ from: 'coloring', to: 'colouring' },
{ from: 'defense', to: 'defence' },
{ from: 'destabilize', to: 'destabilise' },
{ from: 'destabilizes', to: 'destabilises' },
{ from: 'destabilized', to: 'destabilised' },
{ from: 'destabilizing', to: 'destabilising' },
{ from: 'equalize', to: 'equalise' },
{ from: 'equalizes', to: 'equalises' },
{ from: 'equalized', to: 'equalised' },
{ from: 'equalizing', to: 'equalising' },
{ from: 'favor', to: 'favour' },
{ from: 'favors', to: 'favours' },
{ from: 'favored', to: 'favoured' },
{ from: 'favoring', to: 'favouring' },
{ from: 'favorite', to: 'favourite' },
{ from: 'favorites', to: 'favourites' },
{ from: 'fiber', to: 'fibre' },
{ from: 'gray', to: 'grey' },
{ from: 'initialize', to: 'initialise' },
{ from: 'initializes', to: 'initialises' },
{ from: 'initialized', to: 'initialised' },
{ from: 'initializing', to: 'initialising' },
{ from: 'mesmerize', to: 'mesmerise' },
{ from: 'mesmerizes', to: 'mesmerises' },
{ from: 'mesmerized', to: 'mesmerised' },
{ from: 'mesmerizing', to: 'mesmerising' },
{ from: 'mom', to: 'mum' },
{ from: 'offense', to: 'offence' },
{ from: 'organize', to: 'organise' },
{ from: 'organizes', to: 'organises' },
{ from: 'organized', to: 'organised' },
{ from: 'organizing', to: 'organising' },
{ from: 'organization', to: 'organisation' },
{ from: 'realize', to: 'realise' },
{ from: 'realizes', to: 'realises' },
{ from: 'realized', to: 'realised' },
{ from: 'realizing', to: 'realising' },
{ from: 'realization', to: 'realisation' },
{ from: 'recognize', to: 'recognise' },
{ from: 'recognizes', to: 'recognises' },
{ from: 'recognized', to: 'recognised' },
{ from: 'recognizing', to: 'recognising' },
{ from: 'specialize', to: 'specialise' },
{ from: 'specializes', to: 'specialises' },
{ from: 'specialized', to: 'specialised' },
{ from: 'specializing', to: 'specialising' },
{ from: 'theater', to: 'theatre' },
{ from: 'theaters', to: 'theatres' },
{ from: 'traveler', to: 'traveller' },
{ from: 'travelers', to: 'travellers' },
{ from: 'traveled', to: 'travelled' },
{ from: 'traveling', to: 'travelling' },
];
function toolAddTranslation(root, args) {
const { targetLangCode, from, to } = args;
if (!from.trim() || !to.trim()) {
return 'Error: both "from" and "to" must be non-empty.';
}
const filePath = path.join(root, '.bindery', 'translations.json');
let translations = {};
if (fs.existsSync(filePath)) {
try {
translations = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return 'Error: failed to parse .bindery/translations.json';
}
}
// Default source: EN (from settings) or 'en'
let sourceLanguage = 'en';
const settings = readSettings(root);
const defaultLang = (settings?.languages ?? []).find(l => l.isDefault) ?? settings?.languages?.[0];
if (defaultLang) {
sourceLanguage = defaultLang.code.toLowerCase();
}
const key = targetLangCode.toLowerCase();
if (!translations[key]) {
translations[key] = { type: 'glossary', sourceLanguage, rules: [], ignoredWords: [] };
}
const entry = translations[key];
const rules = entry.rules ?? [];
const idx = rules.findIndex(r => r.from.toLowerCase() === from.toLowerCase());
const isUpdate = idx >= 0;
if (isUpdate) {
rules[idx] = { from, to };
}
else {
rules.push({ from, to });
rules.sort((a, b) => a.from.localeCompare(b.from));
}
entry.rules = rules;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2) + '\n', 'utf-8');
return `${isUpdate ? 'Updated' : 'Added'} glossary: ${from}${to} (${key})`;
}
function toolAddDialect(root, args) {
const { dialectCode, from, to } = args;
if (!from.trim() || !to.trim()) {
return 'Error: both "from" and "to" must be non-empty.';
}
const filePath = path.join(root, '.bindery', 'translations.json');
let translations = {};
if (fs.existsSync(filePath)) {
try {
translations = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return 'Error: failed to parse .bindery/translations.json';
}
}
const key = dialectCode.toLowerCase();
if (!translations[key]) {
translations[key] = { type: 'substitution', sourceLanguage: 'en', rules: [], ignoredWords: [] };
}
const entry = translations[key];
if (entry.type !== 'substitution') {
return `Error: entry '${key}' has type '${entry.type}', expected 'substitution'. Use add_translation for glossary entries.`;
}
const rules = entry.rules ?? [];
const fromLower = from.toLowerCase();
const idx = rules.findIndex(r => r.from.toLowerCase() === fromLower);
const isUpdate = idx >= 0;
if (isUpdate) {
rules[idx] = { from: fromLower, to };
}
else {
rules.push({ from: fromLower, to });
rules.sort((a, b) => a.from.localeCompare(b.from));
}
entry.rules = rules;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2) + '\n', 'utf-8');
return `${isUpdate ? 'Updated' : 'Added'} dialect rule: ${fromLower}${to} (${key})`;
}
function toolGetDialect(root, args) {
const filePath = path.join(root, '.bindery', 'translations.json');
if (!fs.existsSync(filePath)) {
return 'No translations.json found. Run "init_workspace" or "add_dialect" first.';
}
let translations;
try {
translations = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return 'Error: failed to parse .bindery/translations.json';
}
const key = Object.keys(translations).find(k => k.toLowerCase() === args.dialectCode.toLowerCase());
if (!key) {
const available = Object.entries(translations)
.filter(([, e]) => e.type === 'substitution')
.map(([k]) => k).join(', ');
return `No dialect entry "${args.dialectCode}". Available: ${available || 'none'}`;
}
const entry = translations[key];
if (entry.type !== 'substitution') {
return `Entry "${key}" is type "${entry.type}", not "substitution". Use get_translation for glossary entries.`;
}
const rules = entry.rules ?? [];
if (!args.word) {
if (rules.length === 0) {
return `No dialect rules defined for "${key}" yet.`;
}
const header = `${key}${entry.label ? `${entry.label}` : ''} (${rules.length} substitution rules):`;
return [header, ...rules.map(r => ` ${r.from}${r.to}`)].join('\n');
}
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 dialect "${key}".`;
}
return matches.map(r => `${r.from}${r.to} [${key}]`).join('\n');
}
function toolAddLanguage(root, args) {
const settingsPath = path.join(root, '.bindery', 'settings.json');
let existing = {};
try {
existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
}
catch {
return 'Error: .bindery/settings.json not found. Run init_workspace first.';
}
const upper = args.code.trim().toUpperCase();
const newLang = {
code: upper,
folderName: args.folderName?.trim() ?? upper,
chapterWord: args.chapterWord?.trim() ?? 'Chapter',
actPrefix: args.actPrefix?.trim() ?? 'Act',
prologueLabel: args.prologueLabel?.trim() ?? 'Prologue',
epilogueLabel: args.epilogueLabel?.trim() ?? 'Epilogue',
};
const languages = (existing['languages'] ?? []);
const dupIdx = languages.findIndex(l => l.code.toUpperCase() === upper);
if (dupIdx >= 0) {
languages[dupIdx] = newLang;
}
else {
languages.push(newLang);
}
existing['languages'] = languages;
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
// Create stub files mirroring source language (default: true)
const createStubs = args.createStubs !== false;
const storyFolderName = existing['storyFolder'] ?? 'Story';
const sourceLang = languages.find((l) => l.isDefault) ?? languages[0];
let stubCount = 0;
if (createStubs && sourceLang && sourceLang.code !== upper) {
const sourceDir = path.join(root, storyFolderName, sourceLang.folderName);
const targetDir = path.join(root, storyFolderName, newLang.folderName);
fs.mkdirSync(targetDir, { recursive: true });
if (fs.existsSync(sourceDir)) {
const createStubsIn = (srcDir, dstDir) => {
fs.mkdirSync(dstDir, { recursive: true });
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
createStubsIn(srcPath, dstPath);
}
else if (entry.isFile() && entry.name.endsWith('.md')) {
if (!fs.existsSync(dstPath)) {
const src = fs.readFileSync(srcPath, 'utf-8');
const h1 = /^#\s+(.+)/m.exec(src);
const title = h1 ? h1[1].trim() : path.basename(entry.name, '.md');
fs.writeFileSync(dstPath, `# [Untranslated] ${title}\n`, 'utf-8');
stubCount++;
}
}
}
};
createStubsIn(sourceDir, targetDir);
}
}
return `Added language ${upper} to settings.json. Story/${newLang.folderName}/ created with ${stubCount} stub file(s).`;
}
// ─── diff helpers ─────────────────────────────────────────────────────────────
function parseUnifiedDiff(raw) {
const files = [];
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
for (const chunk of fileChunks) {
const nameMatch = /^a\/(.+?)\s+b\/(.+)/m.exec(chunk);
if (!nameMatch) {
continue;
}
const fileName = nameMatch[2];
const hunks = [];
const hunkParts = chunk.split(/^@@\s+/m).slice(1);
for (const hunkPart of hunkParts) {
const headerMatch = /^-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)/.exec(hunkPart);
if (!headerMatch) {
continue;
}
const beforeStart = parseInt(headerMatch[1], 10);
const beforeCount = headerMatch[2] !== undefined ? parseInt(headerMatch[2], 10) : 1;
const afterStart = parseInt(headerMatch[3], 10);
const afterCount = headerMatch[4] !== undefined ? parseInt(headerMatch[4], 10) : 1;
const body = headerMatch[5] + '\n' + hunkPart.slice(headerMatch[0].length);
const bodyLines = body.split('\n');
const lines = [];
let oldLine = beforeStart;
let newLine = afterStart;
for (const line of bodyLines) {
if (line.startsWith('+')) {
lines.push({ type: 'insert', text: line.slice(1), newLine: newLine });
newLine++;
}
else if (line.startsWith('-')) {
lines.push({ type: 'delete', text: line.slice(1), oldLine: oldLine });
oldLine++;
}
else if (line.startsWith(' ') || line === '') {
// context line — but skip trailing empty from split
if (line.startsWith(' ')) {
lines.push({ type: 'context', text: line.slice(1), oldLine: oldLine, newLine: newLine });
oldLine++;
newLine++;
}
}
}
hunks.push({ beforeStart, beforeCount, afterStart, afterCount, lines });
}
files.push({ file: fileName, hunks });
}
return files;
}
function formatReviewFiles(files) {
const parts = [];
for (const file of files) {
const lines = [`## ${file.file}`];
for (const hunk of file.hunks) {
lines.push(`\n@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`);
for (const l of hunk.lines) {
const prefix = l.type === 'insert' ? '+' : l.type === 'delete' ? '-' : ' ';
lines.push(`${prefix} ${l.text}`);
}
}
parts.push(lines.join('\n'));
}
return parts.join('\n\n---\n\n');
}
function toolInitWorkspace(root, args) {
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 = {};
const isNew = !fs.existsSync(settingsPath);
if (!isNew) {
try {
existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
}
catch { /* corrupt — treat as new */ }
}
const storyFolderName = args.storyFolder ?? existing['storyFolder'] ?? 'Story';
const bookTitle = args.bookTitle ?? existing['bookTitle'] ?? path.basename(root);
// Detect language folders from the story directory
const storyPath = path.join(root, storyFolderName);
const detectedLangs = [];
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' });
}
}
}
// Merge detected langs with existing to preserve custom properties (dialects, isDefault, labels)
const existingLangs = (existing['languages'] ?? []);
const baseLangs = detectedLangs.length > 0
? detectedLangs
: [{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' }];
const languages = baseLangs.map(dl => {
const el = existingLangs.find(l => l['code']?.toUpperCase() === dl.code);
return el ? { ...el, code: dl.code, folderName: dl.folderName } : dl;
});
const slug = bookTitle.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book';
const settings = {
...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'] ?? 'Merged',
mergeFilePrefix: existing['mergeFilePrefix'] ?? slug,
formatOnSave: existing['formatOnSave'] ?? false,
languages,
};
fs.mkdirSync(path.join(root, '.bindery'), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
const created = ['.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 engbDeclared = languages.some((l) => l.dialects?.some(d => d.code?.toLowerCase() === 'en-gb'));
let engbSeeded = false;
if (engbDeclared) {
let trans = {};
if (fs.existsSync(translationsPath)) {
try {
trans = JSON.parse(fs.readFileSync(translationsPath, 'utf-8'));
}
catch { /* ignore */ }
}
if (!trans['en-gb']?.rules?.length) {
trans['en-gb'] = { label: 'British English', type: 'substitution', sourceLanguage: 'en', rules: BUILTIN_EN_GB_RULES, ignoredWords: [] };
fs.writeFileSync(translationsPath, JSON.stringify(trans, null, 2) + '\n', 'utf-8');
engbSeeded = true;
}
}
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.'
: '';
const engbNote = engbSeeded ? ' en-gb dialect seeded (75 rules).' : '';
return `${action}: ${created.join(', ')}. Book: "${bookTitle}", story folder: ${storyFolderName}/, languages: ${langNote}.${engbNote}${hint}`;
}
function toolSetupAiFiles(root, args) {
const validTargets = ['claude', 'copilot', 'cursor', 'agents'];
const validSkills = new Set(aisetup_js_1.ALL_SKILLS);
const targets = (args.targets ?? validTargets)
.filter((t) => validTargets.includes(t));
const skills = args.skills
? args.skills.filter((s) => validSkills.has(s))
: aisetup_js_1.ALL_SKILLS;
if (targets.length === 0) {
return `No valid targets specified. Valid targets: ${validTargets.join(', ')}`;
}
let result;
try {
result = (0, aisetup_js_1.setupAiFiles)({ root, targets, skills, overwrite: args.overwrite ?? false });
}
catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`;
}
const lines = [];
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 ─────────────────────────────────────────────────────────────
function toolMemoryList(root) {
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');
}
function toolMemoryAppend(root, args) {
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.`;
}
function toolMemoryCompact(root, args) {
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}.`;
}
const STATUS_ORDER = ['done', 'in-progress', 'needs-review', 'draft', 'planned'];
const STATUS_LABELS = {
'done': 'Done',
'in-progress': 'In Progress',
'needs-review': 'Needs Review',
'draft': 'Draft',
'planned': 'Planned',
};
function toolChapterStatusGet(root) {
const filePath = path.join(root, '.bindery', 'chapter-status.json');
if (!fs.existsSync(filePath)) {
return 'No chapter status on record. Use chapter_status_update to record progress.';
}
let data;
try {
data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return 'Error: .bindery/chapter-status.json is present but cannot be parsed.';
}
const chapters = (data.chapters ?? []).slice().sort((a, b) => a.number - b.number);
if (chapters.length === 0) {
return 'No chapters recorded. Use chapter_status_update to record progress.';
}
const byStatus = new Map();
for (const ch of chapters) {
const list = byStatus.get(ch.status) ?? [];
list.push(ch);
byStatus.set(ch.status, list);
}
const lines = [`Chapter status — updated ${data.updatedAt}, ${chapters.length} chapter(s)`];
for (const status of STATUS_ORDER) {
const group = byStatus.get(status);
if (!group || group.length === 0) {
continue;
}
lines.push(`\n${STATUS_LABELS[status]} (${group.length})`);
for (const ch of group) {
const meta = [];
if (ch.language !== 'EN') {
meta.push(ch.language);
}
if (ch.wordCount) {
meta.push(`~${ch.wordCount}w`);
}
const suffix = meta.length ? ` [${meta.join(', ')}]` : '';
lines.push(` Ch ${ch.number}${ch.title}${suffix}`);
if (ch.notes) {
lines.push(` ${ch.notes}`);
}
}
}
return lines.join('\n');
}
function toolChapterStatusUpdate(root, args) {
if (!args.chapters || args.chapters.length === 0) {
return 'Error: chapters array must not be empty.';
}
const filePath = path.join(root, '.bindery', 'chapter-status.json');
let data = { schemaVersion: 1, updatedAt: '', chapters: [] };
if (fs.existsSync(filePath)) {
try {
data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch { /* corrupt — start fresh */ }
}
const chapters = data.chapters ?? [];
let added = 0, updated = 0;
for (const incoming of args.chapters) {
const lang = (incoming.language ?? 'EN').toUpperCase();
const entry = { ...incoming, language: lang };
const idx = chapters.findIndex(c => c.number === entry.number && c.language === lang);
if (idx >= 0) {
chapters[idx] = entry;
updated++;
}
else {
chapters.push(entry);
added++;
}
}
chapters.sort((a, b) => a.language.localeCompare(b.language) || a.number - b.number);
const out = {
schemaVersion: 1,
updatedAt: new Date().toISOString().slice(0, 10),
chapters,
};
fs.mkdirSync(path.join(root, '.bindery'), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(out, null, 2) + '\n', 'utf-8');
return `Chapter status updated: ${added} added, ${updated} updated. Total: ${chapters.length} chapters.`;
}
// ─── Shared formatter ─────────────────────────────────────────────────────────
function formatResult(r, idx) {
const snippetMax = parseInt(process.env['BINDERY_SNIPPET_MAX_CHARS'] ?? '1600', 10);

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@ export interface AiSetupResult {
* 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;
export const AI_SETUP_VERSION = 5;
// ─── Settings types ───────────────────────────────────────────────────────────

View file

@ -37,6 +37,8 @@ import {
toolMemoryList,
toolMemoryAppend,
toolMemoryCompact,
toolChapterStatusGet,
toolChapterStatusUpdate,
} from './tools.js';
import { resolveBook, listBooks, findBookByPath } from './registry.js';
@ -427,6 +429,42 @@ server.registerTool('memory_compact', {
try { return ok(toolMemoryCompact(resolveBook(book).root, { file, compacted_content })); } catch (e) { return err(e); }
});
server.registerTool('chapter_status_get', {
title: 'Chapter Status Get',
description:
'Read the current chapter progress tracker from .bindery/chapter-status.json. ' +
'Returns a formatted summary grouped by status (done, in-progress, draft, planned, needs-review). ' +
'Returns a clear empty-state message if no status has been recorded yet.',
inputSchema: { book: bookSchema },
annotations: { readOnlyHint: true },
}, async ({ book }) => {
try { return ok(toolChapterStatusGet(resolveBook(book).root)); } catch (e) { return err(e); }
});
server.registerTool('chapter_status_update', {
title: 'Chapter Status Update',
description:
'Upsert chapter progress entries in .bindery/chapter-status.json. ' +
'Send only the chapters that changed — existing entries not in the payload are preserved. ' +
'Creates the file if it does not exist. ' +
'Each entry requires: number (int), title (string), language (e.g. EN), status (done | in-progress | draft | planned | needs-review). ' +
'Optional: wordCount (int), notes (string).',
inputSchema: {
book: bookSchema,
chapters: z.array(z.object({
number: z.number().int().describe('Chapter number'),
title: z.string().describe('Chapter title'),
language: z.string().describe('Language code, e.g. EN or NL'),
status: z.enum(['done', 'in-progress', 'draft', 'planned', 'needs-review']),
wordCount: z.number().int().optional().describe('Approximate word count'),
notes: z.string().optional().describe('Short agent note about this chapter'),
})).describe('Chapter entries to upsert (existing entries not listed are preserved)'),
},
annotations: { destructiveHint: true },
}, async ({ book, chapters }) => {
try { return ok(toolChapterStatusUpdate(resolveBook(book).root, { chapters })); } catch (e) { return err(e); }
});
// ─── Start ────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {

View file

@ -470,7 +470,7 @@ When the user confirms a new or corrected term translation, call \`add_translati
}
function skillStatus(ctx: TemplateContext): string {
const { notesFolder, arcFolder, memoriesFolder } = ctx;
const { arcFolder, memoriesFolder } = ctx;
return `---
name: Status
description: Give a book progress snapshot chapters done, in progress, and coming up. Use for /status, "what's the book status", or "where are we".
@ -485,16 +485,19 @@ User says \`/status\`, "what's the book status", or "where are we".
## Tools
Use these Bindery MCP tools:
- \`chapter_status_get(book)\` — read the structured progress tracker from \`.bindery/chapter-status.json\`
- \`chapter_status_update(book, chapters)\` — upsert chapter progress entries (send only changed chapters)
- \`get_overview(language)\` — list all acts and chapters with titles
- \`get_text(identifier)\` — read COWORK.md, \`${notesFolder}/Chapter_Status.md\`, and \`${memoriesFolder}/global.md\`
- \`get_text(identifier)\` — read COWORK.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.
1. Use \`chapter_status_get\` to read the current tracker. Use \`memory_list\` to check available memory files. Use \`get_text\` to read COWORK.md (current focus) and \`${memoriesFolder}/global.md\`.
2. Use \`get_overview\` for the full chapter listing if the tracker is empty or incomplete.
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.
5. If the tracker is out of date or missing entries, update it with \`chapter_status_update\` (upsert only the changed chapters).
## Output
Keep it scannable bold headers, short lines. This is a working tool, not a narrative summary.

View file

@ -624,6 +624,76 @@ interface TranslationRule { from: string; to: string }
interface TranslationEntry { label?: string; type: string; sourceLanguage?: string; rules?: TranslationRule[]; ignoredWords?: string[] }
type TranslationsFile = Record<string, TranslationEntry>;
// ─── Built-in en-gb substitution rules (US → British English) ────────────────
const BUILTIN_EN_GB_RULES: TranslationRule[] = [
{ from: 'analyze', to: 'analyse' },
{ from: 'analyzes', to: 'analyses' },
{ from: 'analyzed', to: 'analysed' },
{ from: 'analyzing', to: 'analysing' },
{ from: 'canceled', to: 'cancelled' },
{ from: 'canceling', to: 'cancelling' },
{ from: 'center', to: 'centre' },
{ from: 'centers', to: 'centres' },
{ from: 'centered', to: 'centred' },
{ from: 'centering', to: 'centring' },
{ from: 'color', to: 'colour' },
{ from: 'colors', to: 'colours' },
{ from: 'colored', to: 'coloured' },
{ from: 'coloring', to: 'colouring' },
{ from: 'defense', to: 'defence' },
{ from: 'destabilize', to: 'destabilise' },
{ from: 'destabilizes', to: 'destabilises' },
{ from: 'destabilized', to: 'destabilised' },
{ from: 'destabilizing', to: 'destabilising' },
{ from: 'equalize', to: 'equalise' },
{ from: 'equalizes', to: 'equalises' },
{ from: 'equalized', to: 'equalised' },
{ from: 'equalizing', to: 'equalising' },
{ from: 'favor', to: 'favour' },
{ from: 'favors', to: 'favours' },
{ from: 'favored', to: 'favoured' },
{ from: 'favoring', to: 'favouring' },
{ from: 'favorite', to: 'favourite' },
{ from: 'favorites', to: 'favourites' },
{ from: 'fiber', to: 'fibre' },
{ from: 'gray', to: 'grey' },
{ from: 'initialize', to: 'initialise' },
{ from: 'initializes', to: 'initialises' },
{ from: 'initialized', to: 'initialised' },
{ from: 'initializing', to: 'initialising' },
{ from: 'mesmerize', to: 'mesmerise' },
{ from: 'mesmerizes', to: 'mesmerises' },
{ from: 'mesmerized', to: 'mesmerised' },
{ from: 'mesmerizing', to: 'mesmerising' },
{ from: 'mom', to: 'mum' },
{ from: 'offense', to: 'offence' },
{ from: 'organize', to: 'organise' },
{ from: 'organizes', to: 'organises' },
{ from: 'organized', to: 'organised' },
{ from: 'organizing', to: 'organising' },
{ from: 'organization', to: 'organisation' },
{ from: 'realize', to: 'realise' },
{ from: 'realizes', to: 'realises' },
{ from: 'realized', to: 'realised' },
{ from: 'realizing', to: 'realising' },
{ from: 'realization', to: 'realisation' },
{ from: 'recognize', to: 'recognise' },
{ from: 'recognizes', to: 'recognises' },
{ from: 'recognized', to: 'recognised' },
{ from: 'recognizing', to: 'recognising' },
{ from: 'specialize', to: 'specialise' },
{ from: 'specializes', to: 'specialises' },
{ from: 'specialized', to: 'specialised' },
{ from: 'specializing', to: 'specialising' },
{ from: 'theater', to: 'theatre' },
{ from: 'theaters', to: 'theatres' },
{ from: 'traveler', to: 'traveller' },
{ from: 'travelers', to: 'travellers' },
{ from: 'traveled', to: 'travelled' },
{ from: 'traveling', to: 'travelling' },
];
export function toolAddTranslation(root: string, args: AddTranslationArgs): string {
const { targetLangCode, from, to } = args;
if (!from.trim() || !to.trim()) { return 'Error: both "from" and "to" must be non-empty.'; }
@ -934,9 +1004,15 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
}
}
}
const languages = detectedLangs.length > 0
// Merge detected langs with existing to preserve custom properties (dialects, isDefault, labels)
const existingLangs = ((existing['languages'] as unknown[] | undefined) ?? []) as Array<Record<string, unknown>>;
const baseLangs = detectedLangs.length > 0
? detectedLangs
: [{ code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' }];
const languages: Array<Record<string, unknown>> = baseLangs.map(dl => {
const el = existingLangs.find(l => (l['code'] as string | undefined)?.toUpperCase() === dl.code);
return el ? { ...el, code: dl.code, folderName: dl.folderName } : (dl as unknown as Record<string, unknown>);
});
const slug = bookTitle.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book';
const settings: Record<string, unknown> = {
@ -966,12 +1042,31 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
created.push('.bindery/translations.json');
}
// Seed en-gb rules if any language declares it as a dialect and it isn't already populated
type LangWithDialects = { dialects?: Array<{ code: string }> };
const engbDeclared = languages.some((l: unknown) =>
(l as LangWithDialects).dialects?.some(d => d.code?.toLowerCase() === 'en-gb')
);
let engbSeeded = false;
if (engbDeclared) {
let trans: TranslationsFile = {};
if (fs.existsSync(translationsPath)) {
try { trans = JSON.parse(fs.readFileSync(translationsPath, 'utf-8')) as TranslationsFile; } catch { /* ignore */ }
}
if (!trans['en-gb']?.rules?.length) {
trans['en-gb'] = { label: 'British English', type: 'substitution', sourceLanguage: 'en', rules: BUILTIN_EN_GB_RULES, ignoredWords: [] };
fs.writeFileSync(translationsPath, JSON.stringify(trans, null, 2) + '\n', 'utf-8');
engbSeeded = true;
}
}
const action = isNew ? 'Initialised' : 'Updated';
const langNote = languages.map(l => l.code).join(', ');
const langNote = languages.map(l => (l as { code: string }).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}`;
const engbNote = engbSeeded ? ' en-gb dialect seeded (75 rules).' : '';
return `${action}: ${created.join(', ')}. Book: "${bookTitle}", story folder: ${storyFolderName}/, languages: ${langNote}.${engbNote}${hint}`;
}
// ─── setup_ai_files ──────────────────────────────────────────────────────────
@ -1094,6 +1189,109 @@ export function toolMemoryCompact(root: string, args: MemoryCompactArgs): string
return `Compacted ${args.file}: backup → ${relBackup}, old lines: ${oldLineCount}, new lines: ${newLineCount}.`;
}
// ─── chapter_status_get / chapter_status_update ───────────────────────────────
export interface ChapterStatusEntry {
number: number;
title: string;
language: string;
status: 'done' | 'in-progress' | 'draft' | 'planned' | 'needs-review';
wordCount?: number;
notes?: string;
}
interface ChapterStatus {
schemaVersion: 1;
updatedAt: string;
chapters: ChapterStatusEntry[];
}
const STATUS_ORDER = ['done', 'in-progress', 'needs-review', 'draft', 'planned'] as const;
const STATUS_LABELS: Record<string, string> = {
'done': 'Done',
'in-progress': 'In Progress',
'needs-review': 'Needs Review',
'draft': 'Draft',
'planned': 'Planned',
};
export function toolChapterStatusGet(root: string): string {
const filePath = path.join(root, '.bindery', 'chapter-status.json');
if (!fs.existsSync(filePath)) {
return 'No chapter status on record. Use chapter_status_update to record progress.';
}
let data: ChapterStatus;
try { data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as ChapterStatus; }
catch { return 'Error: .bindery/chapter-status.json is present but cannot be parsed.'; }
const chapters = (data.chapters ?? []).slice().sort((a, b) => a.number - b.number);
if (chapters.length === 0) {
return 'No chapters recorded. Use chapter_status_update to record progress.';
}
const byStatus = new Map<string, ChapterStatusEntry[]>();
for (const ch of chapters) {
const list = byStatus.get(ch.status) ?? [];
list.push(ch);
byStatus.set(ch.status, list);
}
const lines: string[] = [`Chapter status — updated ${data.updatedAt}, ${chapters.length} chapter(s)`];
for (const status of STATUS_ORDER) {
const group = byStatus.get(status);
if (!group || group.length === 0) { continue; }
lines.push(`\n${STATUS_LABELS[status]} (${group.length})`);
for (const ch of group) {
const meta: string[] = [];
if (ch.language !== 'EN') { meta.push(ch.language); }
if (ch.wordCount) { meta.push(`~${ch.wordCount}w`); }
const suffix = meta.length ? ` [${meta.join(', ')}]` : '';
lines.push(` Ch ${ch.number}${ch.title}${suffix}`);
if (ch.notes) { lines.push(` ${ch.notes}`); }
}
}
return lines.join('\n');
}
export interface ChapterStatusUpdateArgs {
chapters: ChapterStatusEntry[];
}
export function toolChapterStatusUpdate(root: string, args: ChapterStatusUpdateArgs): string {
if (!args.chapters || args.chapters.length === 0) {
return 'Error: chapters array must not be empty.';
}
const filePath = path.join(root, '.bindery', 'chapter-status.json');
let data: ChapterStatus = { schemaVersion: 1, updatedAt: '', chapters: [] };
if (fs.existsSync(filePath)) {
try { data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as ChapterStatus; }
catch { /* corrupt — start fresh */ }
}
const chapters = data.chapters ?? [];
let added = 0, updated = 0;
for (const incoming of args.chapters) {
const lang = (incoming.language ?? 'EN').toUpperCase();
const entry = { ...incoming, language: lang };
const idx = chapters.findIndex(c => c.number === entry.number && c.language === lang);
if (idx >= 0) { chapters[idx] = entry; updated++; }
else { chapters.push(entry); added++; }
}
chapters.sort((a, b) => a.language.localeCompare(b.language) || a.number - b.number);
const out: ChapterStatus = {
schemaVersion: 1,
updatedAt: new Date().toISOString().slice(0, 10),
chapters,
};
fs.mkdirSync(path.join(root, '.bindery'), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(out, null, 2) + '\n', 'utf-8');
return `Chapter status updated: ${added} added, ${updated} updated. Total: ${chapters.length} chapters.`;
}
// ─── Shared formatter ─────────────────────────────────────────────────────────
function formatResult(r: SearchResult, idx: number): string {

View file

@ -63,8 +63,10 @@
{ "name": "add_dialect", "description": "Add or update a dialect substitution rule in .bindery/translations.json (e.g. US→UK spelling: color→colour). Applied automatically during export." },
{ "name": "get_dialect", "description": "Look up dialect substitution rules from .bindery/translations.json. List all rules for a dialect or look up a specific word." },
{ "name": "add_language", "description": "Add a new language to .bindery/settings.json and scaffold its story folder with stub files mirroring the default language structure." },
{ "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." }
{ "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." },
{ "name": "chapter_status_get", "description": "Read the current chapter progress tracker from .bindery/chapter-status.json. Returns a grouped summary by status (done, in-progress, draft, planned, needs-review). Returns a clear empty-state message if nothing has been recorded yet." },
{ "name": "chapter_status_update", "description": "Upsert chapter progress entries in .bindery/chapter-status.json. Send only the chapters that changed — existing entries not in the payload are preserved. Creates the file if it does not exist." }
]
}

16
vscode-ext/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extension=option-a.bindery"
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: compile"
}
]
}

12
vscode-ext/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "compile",
"group": "build",
"presentation": { "reveal": "silent" },
"problemMatcher": "$tsc"
}
]
}

View file

@ -205,6 +205,8 @@
"name": "bindery_health",
"tags": ["bindery"],
"displayName": "Bindery: Health",
"toolReferenceName": "binderyHealth",
"canBeReferencedInPrompt": true,
"modelDescription": "Check Bindery workspace status: settings, index, embedding backend.",
"inputSchema": { "type": "object", "properties": {} }
},
@ -212,6 +214,8 @@
"name": "bindery_index_build",
"tags": ["bindery"],
"displayName": "Bindery: Build Index",
"toolReferenceName": "binderyIndexBuild",
"canBeReferencedInPrompt": true,
"modelDescription": "Build or rebuild the full-text search index for the book workspace.",
"inputSchema": { "type": "object", "properties": {} }
},
@ -219,6 +223,8 @@
"name": "bindery_index_status",
"tags": ["bindery"],
"displayName": "Bindery: Index Status",
"toolReferenceName": "binderyIndexStatus",
"canBeReferencedInPrompt": true,
"modelDescription": "Show current index metadata: chunk count and build time.",
"inputSchema": { "type": "object", "properties": {} }
},
@ -226,6 +232,8 @@
"name": "bindery_get_text",
"tags": ["bindery"],
"displayName": "Bindery: Get Text",
"toolReferenceName": "binderyGetText",
"canBeReferencedInPrompt": true,
"modelDescription": "Read a source file by relative path, optionally restricted to a line range.",
"inputSchema": {
"type": "object",
@ -241,6 +249,8 @@
"name": "bindery_get_chapter",
"tags": ["bindery"],
"displayName": "Bindery: Get Chapter",
"toolReferenceName": "binderyGetChapter",
"canBeReferencedInPrompt": true,
"modelDescription": "Fetch the full content of a chapter by number and language code (e.g. EN or NL).",
"inputSchema": {
"type": "object",
@ -255,6 +265,8 @@
"name": "bindery_get_overview",
"tags": ["bindery"],
"displayName": "Bindery: Get Overview",
"toolReferenceName": "binderyGetOverview",
"canBeReferencedInPrompt": true,
"modelDescription": "List the chapter structure (acts, chapters, titles) for one or all languages.",
"inputSchema": {
"type": "object",
@ -268,6 +280,8 @@
"name": "bindery_get_notes",
"tags": ["bindery"],
"displayName": "Bindery: Get Notes",
"toolReferenceName": "binderyGetNotes",
"canBeReferencedInPrompt": true,
"modelDescription": "Read from Notes/ and Details_*.md, optionally filtered by category or character name.",
"inputSchema": {
"type": "object",
@ -281,6 +295,8 @@
"name": "bindery_search",
"tags": ["bindery"],
"displayName": "Bindery: Search",
"toolReferenceName": "binderySearch",
"canBeReferencedInPrompt": true,
"modelDescription": "Full-text BM25 search across all story and notes files. Returns ranked snippets with file path and line numbers.",
"inputSchema": {
"type": "object",
@ -296,6 +312,8 @@
"name": "bindery_retrieve_context",
"tags": ["bindery"],
"displayName": "Bindery: Retrieve Context",
"toolReferenceName": "binderyRetrieveContext",
"canBeReferencedInPrompt": true,
"modelDescription": "Retrieve the most relevant passages for a query. Best for 'where did X happen' or 'what did character Y say about Z'.",
"inputSchema": {
"type": "object",
@ -311,6 +329,8 @@
"name": "bindery_format",
"tags": ["bindery"],
"displayName": "Bindery: Format",
"toolReferenceName": "binderyFormat",
"canBeReferencedInPrompt": true,
"modelDescription": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder.",
"inputSchema": {
"type": "object",
@ -325,6 +345,8 @@
"name": "bindery_get_review_text",
"tags": ["bindery"],
"displayName": "Bindery: Review Text",
"toolReferenceName": "binderyGetReviewText",
"canBeReferencedInPrompt": true,
"modelDescription": "Structured git diff of uncommitted changes with context lines. Filter by language (EN, NL, ALL). Set autoStage to true to stage reviewed files so the next call only shows new changes.",
"inputSchema": {
"type": "object",
@ -339,6 +361,8 @@
"name": "bindery_git_snapshot",
"tags": ["bindery"],
"displayName": "Bindery: Git Snapshot",
"toolReferenceName": "binderyGitSnapshot",
"canBeReferencedInPrompt": true,
"modelDescription": "Save a snapshot (git commit) of all changes in story, notes, and arc folders. Use after writing sessions or successful reviews.",
"inputSchema": {
"type": "object",
@ -351,6 +375,8 @@
"name": "bindery_add_translation",
"tags": ["bindery"],
"displayName": "Bindery: Add Translation",
"toolReferenceName": "binderyAddTranslation",
"canBeReferencedInPrompt": true,
"modelDescription": "Add or update a substitution rule in .bindery/translations.json. Used for dialect conversion (e.g. US→UK spelling).",
"inputSchema": {
"type": "object",
@ -365,6 +391,8 @@
{ "name": "bindery_get_translation",
"tags": ["bindery"],
"displayName": "Bindery: Get Translation",
"toolReferenceName": "binderyGetTranslation",
"canBeReferencedInPrompt": true,
"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",
@ -379,6 +407,8 @@
"name": "bindery_setup_ai_files",
"tags": ["bindery"],
"displayName": "Bindery: Setup AI Files",
"toolReferenceName": "binderySetupAiFiles",
"canBeReferencedInPrompt": true,
"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",
@ -393,6 +423,8 @@
"name": "bindery_init_workspace",
"tags": ["bindery"],
"displayName": "Bindery: Init Workspace",
"toolReferenceName": "binderyInitWorkspace",
"canBeReferencedInPrompt": true,
"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",
@ -409,6 +441,8 @@
{ "name": "bindery_add_dialect",
"tags": ["bindery"],
"displayName": "Bindery: Add Dialect Rule",
"toolReferenceName": "binderyAddDialect",
"canBeReferencedInPrompt": true,
"modelDescription": "Add or update a dialect substitution rule in .bindery/translations.json (e.g. EN→en-gb spelling). Source is auto-detected from the active file's language folder.",
"inputSchema": {
"type": "object",
@ -423,6 +457,8 @@
{ "name": "bindery_get_dialect",
"tags": ["bindery"],
"displayName": "Bindery: Get Dialect Rules",
"toolReferenceName": "binderyGetDialect",
"canBeReferencedInPrompt": true,
"modelDescription": "List or look up dialect substitution rules from .bindery/translations.json. Without a word, lists all rules for the dialect. With a word, does a forgiving case-insensitive lookup including inflected forms.",
"inputSchema": {
"type": "object",
@ -436,6 +472,8 @@
{ "name": "bindery_add_language",
"tags": ["bindery"],
"displayName": "Bindery: Add Language",
"toolReferenceName": "binderyAddLanguage",
"canBeReferencedInPrompt": true,
"modelDescription": "Add a new main language to settings.json and create its folder structure under Story/ with stub .md files mirroring the default language. Use for manually translated editions (e.g. French, Spanish).",
"inputSchema": {
"type": "object",
@ -454,6 +492,8 @@
{ "name": "bindery_memory_list",
"tags": ["bindery"],
"displayName": "Bindery: Memory List",
"toolReferenceName": "binderyMemoryList",
"canBeReferencedInPrompt": true,
"modelDescription": "List all session memory files in .bindery/memories/ with their line counts.",
"inputSchema": { "type": "object", "properties": {} }
},
@ -461,6 +501,8 @@
"name": "bindery_memory_append",
"tags": ["bindery"],
"displayName": "Bindery: Memory Append",
"toolReferenceName": "binderyMemoryAppend",
"canBeReferencedInPrompt": true,
"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",
@ -476,6 +518,8 @@
"name": "bindery_memory_compact",
"tags": ["bindery"],
"displayName": "Bindery: Memory Compact",
"toolReferenceName": "binderyMemoryCompact",
"canBeReferencedInPrompt": true,
"modelDescription": "Overwrite a memory file with a compacted summary. The original is backed up to .bindery/memories/archive/ first.",
"inputSchema": {
"type": "object",
@ -485,6 +529,44 @@
},
"required": ["file", "compacted_content"]
}
},
{
"name": "bindery_chapter_status_get",
"tags": ["bindery"],
"displayName": "Bindery: Chapter Status",
"toolReferenceName": "binderyChapterStatusGet",
"canBeReferencedInPrompt": true,
"modelDescription": "Read the current chapter progress tracker from .bindery/chapter-status.json. Returns a grouped summary (done, in-progress, draft, planned, needs-review). Returns a clear empty-state message if no status has been recorded yet.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "bindery_chapter_status_update",
"tags": ["bindery"],
"displayName": "Bindery: Chapter Status Update",
"toolReferenceName": "binderyChapterStatusUpdate",
"canBeReferencedInPrompt": true,
"modelDescription": "Upsert chapter progress entries in .bindery/chapter-status.json. Send only the chapters that changed — existing entries are preserved. status must be one of: done, in-progress, draft, planned, needs-review.",
"inputSchema": {
"type": "object",
"properties": {
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": { "type": "number", "description": "Chapter number" },
"title": { "type": "string", "description": "Chapter title" },
"language": { "type": "string", "description": "Language code, e.g. EN or NL" },
"status": { "type": "string", "enum": ["done", "in-progress", "draft", "planned", "needs-review"] },
"wordCount": { "type": "number", "description": "Approximate word count" },
"notes": { "type": "string", "description": "Short agent note about this chapter" }
},
"required": ["number", "title", "language", "status"]
}
}
},
"required": ["chapters"]
}
}
],
"menus": {

View file

@ -58,7 +58,7 @@ export const ALL_SKILLS: SkillTemplate[] = [
* 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;
export const AI_SETUP_VERSION = 5;
// ─── Entry point ──────────────────────────────────────────────────────────────

View file

@ -32,6 +32,7 @@ interface InitWorkspaceInput { bookTitle?: string; author?: string; storyFolder
interface SetupAiFilesInput { targets?: string[]; skills?: string[]; overwrite?: boolean }
interface MemoryAppendInput { file: string; title: string; content: string }
interface MemoryCompactInput { file: string; compacted_content: string }
interface ChapterStatusUpdateInput { chapters: Array<{ number: number; title: string; language: string; status: 'done' | 'in-progress' | 'draft' | 'planned' | 'needs-review'; wordCount?: number; notes?: string }> }
interface McpTools {
toolHealth: (root: string) => string;
@ -56,15 +57,24 @@ interface McpTools {
toolMemoryList: (root: string) => string;
toolMemoryAppend: (root: string, args: MemoryAppendInput) => string;
toolMemoryCompact: (root: string, args: MemoryCompactInput) => string;
toolChapterStatusGet: (root: string) => string;
toolChapterStatusUpdate: (root: string, args: ChapterStatusUpdateInput) => string;
}
/**
* Lazily load the compiled mcp-ts tools at runtime from the extension's
* bundled output directory. This avoids a cross-project TypeScript import.
*
* In production (VSIX), mcp-ts is bundled inside the extension at mcp-ts/out/.
* In development (F5), mcp-ts is a sibling of vscode-ext/, so we fall back one
* level up when the bundled copy isn't present.
*/
function loadMcpTools(extensionPath: string): McpTools {
const bundledPath = path.join(extensionPath, 'mcp-ts', 'out', 'tools');
const devPath = path.join(extensionPath, '..', 'mcp-ts', 'out', 'tools');
const modulePath = fs.existsSync(bundledPath + '.js') ? bundledPath : devPath;
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(path.join(extensionPath, 'mcp-ts', 'out', 'tools')) as McpTools;
return require(modulePath) as McpTools;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -177,6 +187,14 @@ export function registerLmTools(context: vscode.ExtensionContext): void {
vscode.lm.registerTool<MemoryCompactInput>('bindery_memory_compact', {
invoke: async (opts, _token) => ok(t.toolMemoryCompact(requireRoot(), opts.input)),
}),
vscode.lm.registerTool('bindery_chapter_status_get', {
invoke: async (_opts, _token) => ok(t.toolChapterStatusGet(requireRoot())),
}),
vscode.lm.registerTool<ChapterStatusUpdateInput>('bindery_chapter_status_update', {
invoke: async (opts, _token) => ok(t.toolChapterStatusUpdate(requireRoot(), opts.input)),
}),
);
}