From a0d77832ac22a11a9b8f49d1c879754015d83d35 Mon Sep 17 00:00:00 2001 From: Erik van der Boom Date: Thu, 9 Apr 2026 21:56:12 +0200 Subject: [PATCH] Refactor: Replace `replace` with `replaceAll` for consistent string replacements - Updated `format.ts`, `index.ts`, `registry.ts`, `search.ts`, `templates.ts`, `tools.ts`, and `vscode-ext` files to use `replaceAll` instead of `replace` for string manipulations. - Changed import statements in various files to use `node:` prefix for built-in modules. - Adjusted test files to reflect changes in string replacement methods and updated descriptions for clarity. - Modified README and manifest files to correct descriptions related to notes file handling. --- .github/copilot-instructions.md | 2 +- README.md | 5 +- mcp-ts/src/format.ts | 24 ++--- mcp-ts/src/index.ts | 4 +- mcp-ts/src/registry.ts | 4 +- mcp-ts/src/search.ts | 27 +++-- mcp-ts/src/templates.ts | 17 +-- mcp-ts/src/tools.ts | 4 +- mcp-ts/test/aisetup.test.ts | 6 +- mcp-ts/test/content-tools.test.ts | 17 +-- mcp-ts/test/git-tools.test.ts | 8 +- mcp-ts/test/integration-stdio.test.ts | 8 +- mcp-ts/test/search-edgecases.test.ts | 103 ++++++++++++++----- mcp-ts/test/translations-tools.test.ts | 6 +- mcp-ts/test/workspace-tools.test.ts | 24 ++--- mcpb/README.md | 2 +- mcpb/manifest.json | 2 +- vscode-ext/src/extension.ts | 40 +++---- vscode-ext/src/format.ts | 24 ++--- vscode-ext/src/merge.ts | 49 ++++----- vscode-ext/src/workspace.ts | 4 +- vscode-ext/test/integration-commands.test.ts | 6 +- vscode-ext/test/mcp.test.ts | 6 +- vscode-ext/test/merge.test.ts | 6 +- vscode-ext/test/workspace.test.ts | 6 +- 25 files changed, 220 insertions(+), 184 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8fdae2b..cdf814b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,7 +38,7 @@ mcpb/ ← Claude Desktop extension package (mcpb manifest + bundled The MCP server is published to the Anthropic MCPB directory. These rules are hard requirements to stay publishable. ### Every tool MUST have the correct annotations -Readonly/destructive are mutaully exclusive categories that determine how the tool is presented to users and whether it can be called by read-only agents. Every tool MUST have exactly one of these annotations: +Readonly/destructive are mutually exclusive categories that determine how the tool is presented to users and whether it can be called by read-only agents. Every tool MUST have exactly one of these annotations: ```typescript annotations: { readOnlyHint: true } // reads only — never writes files annotations: { destructiveHint: true } // writes, creates, or modifies files diff --git a/README.md b/README.md index d8693b0..2e387b2 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ Packages the MCP server as a `.mcpb` file for one-click installation in Claude D 3. Fill in the **Books** field with semicolon-separated `Name=path` pairs: `ScaryBook=C:\Users\My\Projects\ScaryBook;MyNovel=D:\Writing\MyNovel` 4. Optionally set the **Ollama URL** if you want semantic reranking -5. Optionally enable the semantic index and choose a default search mode if you want `full_semantic` search with rebuild warnings when the embedding index becomes stale -5. Tools are now available — the agent calls `list_books` to discover book names +5. Optionally enable the semantic index and choose a default search mode if you want `full_semantic` search with rebuild warnings when the embedding index becomes stale. + - Note full embedding can be a heavy operation, depending on your hardware when running a local Ollama instance. +6. Tools are now available — the agent calls `list_books` to discover book names ### Formatting & Export only (no MCP) diff --git a/mcp-ts/src/format.ts b/mcp-ts/src/format.ts index f7382a2..2034b85 100644 --- a/mcp-ts/src/format.ts +++ b/mcp-ts/src/format.ts @@ -18,36 +18,36 @@ const CLOSE_DOUBLE_AFTER_EM_DASH_RE = /—"([\s)\].,;:!?]|$)/gm; export function updateTypography(text: string): string { let result = text; - result = result.replace(/\.\.\./g, ELLIPSIS); + result = result.replaceAll(/\.\.\./g, ELLIPSIS); const protectedComments: string[] = []; - result = result.replace(COMMENT_RE, (match) => { + result = result.replaceAll(COMMENT_RE, (match) => { const placeholder = `\x00COMMENT${protectedComments.length}\x00`; protectedComments.push(match); return placeholder; }); const protectedTriple = '\x00TRIPLE\x00'; - result = result.replace(/---/g, protectedTriple); - result = result.replace(/--/g, EM_DASH); - result = result.replace(new RegExp(escapeRegex(protectedTriple), 'g'), '---'); + result = result.replaceAll(/---/g, protectedTriple); + result = result.replaceAll(/--/g, EM_DASH); + result = result.replaceAll(new RegExp(escapeRegex(protectedTriple), 'g'), '---'); for (let i = 0; i < protectedComments.length; i++) { - result = result.replace(`\x00COMMENT${i}\x00`, protectedComments[i]); + result = result.replaceAll(`\x00COMMENT${i}\x00`, protectedComments[i]); } - result = result.replace(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => { + result = result.replaceAll(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => { return `${EM_DASH}${CLOSE_DOUBLE}${after}`; }); - result = result.replace(OPEN_DOUBLE_RE, (_match, before) => `${before}${OPEN_DOUBLE}`); - result = result.replace(/"/g, CLOSE_DOUBLE); - result = result.replace(OPEN_SINGLE_RE, (_match, before) => `${before}${OPEN_SINGLE}`); - result = result.replace(/'/g, CLOSE_SINGLE); + result = result.replaceAll(OPEN_DOUBLE_RE, (_match, before) => `${before}${OPEN_DOUBLE}`); + result = result.replaceAll(/"/g, CLOSE_DOUBLE); + result = result.replaceAll(OPEN_SINGLE_RE, (_match, before) => `${before}${OPEN_SINGLE}`); + result = result.replaceAll(/'/g, CLOSE_SINGLE); return result; } function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/mcp-ts/src/index.ts b/mcp-ts/src/index.ts index e3f101e..4e4e08f 100644 --- a/mcp-ts/src/index.ts +++ b/mcp-ts/src/index.ts @@ -67,7 +67,7 @@ server.registerTool('list_books', { }, async () => { const books = listBooks(); if (books.length === 0) { - return ok( + return ok( 'No books configured.\n\n' + 'Add --book args to the MCP server in your claude_desktop_config.json:\n\n' + ' "args": ["dist/index.js", "--book", "MyNovel=/path/to/project"]' @@ -174,7 +174,7 @@ 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.', + description: 'Read from Notes/ files, optionally filtered by category name or character/place name.', inputSchema: { book: bookSchema, category: z.string().optional().describe('Filter by file/category name substring'), diff --git a/mcp-ts/src/registry.ts b/mcp-ts/src/registry.ts index 159fc28..4198e32 100644 --- a/mcp-ts/src/registry.ts +++ b/mcp-ts/src/registry.ts @@ -14,8 +14,8 @@ * discover available names). */ -import * as path from 'path'; -import * as fs from 'fs'; +import * as path from 'node:path' +import * as fs from 'node:fs'; // ─── Startup configuration ──────────────────────────────────────────────────── diff --git a/mcp-ts/src/search.ts b/mcp-ts/src/search.ts index 871981e..1d064ab 100644 --- a/mcp-ts/src/search.ts +++ b/mcp-ts/src/search.ts @@ -4,9 +4,9 @@ * index. */ -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import MiniSearch from 'minisearch'; import { chunkWorkspace, type Chunk } from './docstore.js'; @@ -382,6 +382,18 @@ export function semanticDiscoverOptions() { return { includeArc: true } as const; } +function embeddingMaxChars(): number { + const raw = Number.parseInt(process.env['BINDERY_EMBEDDING_MAX_CHARS'] ?? '4000', 10); + if (Number.isNaN(raw)) { return 4000; } + return raw > 0 ? raw : 4000; +} + +function normalizeEmbeddingInput(text: string): string { + const compact = text.replaceAll(/\s+/g, ' ').trim(); + const maxChars = embeddingMaxChars(); + return compact.length <= maxChars ? compact : compact.slice(0, maxChars); +} + function ollamaConfig(): { url: string | null; model: string } { return { url: process.env['BINDERY_OLLAMA_URL']?.trim() || null, @@ -415,7 +427,7 @@ function chapterStatusSignature(root: string): string { const interesting = (parsed.chapters ?? []) .filter(chapter => chapter.status === 'done' || chapter.status === 'needs-review') .map(chapter => `${(chapter.language ?? 'EN').toUpperCase()}:${chapter.number}:${chapter.status}`) - .sort() + .sort((a, b) => a.localeCompare(b)) .join('|'); return interesting || 'none'; } catch { @@ -428,11 +440,12 @@ async function fetchEmbedding( model: string, text: string, ): Promise { - const url = baseUrl.replace(/\/$/, '') + '/api/embeddings'; + const url = baseUrl.replaceAll(/\/$/, '') + '/api/embeddings'; + const prompt = normalizeEmbeddingInput(text); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, prompt: text }), + body: JSON.stringify({ model, prompt }), signal: AbortSignal.timeout(10000), }); if (!res.ok) { return null; } @@ -441,7 +454,7 @@ async function fetchEmbedding( } async function validateOllamaEndpoint(baseUrl: string): Promise { - const url = baseUrl.replace(/\/$/, '') + '/api/tags'; + const url = baseUrl.replaceAll(/\/$/, '') + '/api/tags'; try { const res = await fetch(url, { method: 'GET', diff --git a/mcp-ts/src/templates.ts b/mcp-ts/src/templates.ts index ac3bd14..002b6ca 100644 --- a/mcp-ts/src/templates.ts +++ b/mcp-ts/src/templates.ts @@ -155,7 +155,7 @@ function claudeMd(ctx: TemplateContext): string { '| `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 |', + '| `get_notes` | Notes/ files, filterable by category or name |', '| `search` | BM25 full-text search with ranked snippets, optional semantic ranking |', '| `format` | Apply typography formatting to a file or folder |', '| `get_review_text` | Structured git diff with optional auto-staging |', @@ -197,7 +197,6 @@ function copilotMd(ctx: TemplateContext): string { '## 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 normalize 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.`); @@ -216,10 +215,8 @@ function cursorRules(ctx: TemplateContext): string { '', '## 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`, + `- \`${arcFolder}/\` — story arc files for overall and per-act structure and beats`, + `- \`${notesFolder}/\` — story notes, like character profiles and world rules`, '', '## Rules', '- HTML comments `` in chapter files are writer notes. Treat as context, not story content.', @@ -246,7 +243,6 @@ function agentsMd(ctx: TemplateContext): string { '## 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}/\`, organized in act subfolders.`, @@ -264,11 +260,8 @@ function agentsMd(ctx: TemplateContext): string { '## 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 |`, + `| \`${arcFolder}/\` | Story arc files for overall and per-act structure and beats |`, + `| \`${notesFolder}/\` | Story notes, like character profiles and world rules |`, `| \`${memoriesFolder}/global.md\` | Cross-session decisions |`, ); return lines.join('\n') + '\n'; diff --git a/mcp-ts/src/tools.ts b/mcp-ts/src/tools.ts index a7cf0a0..1c90a72 100644 --- a/mcp-ts/src/tools.ts +++ b/mcp-ts/src/tools.ts @@ -697,7 +697,7 @@ export function toolGitSnapshot(root: string, args: GitSnapshotArgs): string { 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', ' ')}`; + const msg = args.message ?? `Snapshot ${new Date().toISOString().slice(0, 16).replaceAll('T', ' ')}`; try { const result = spawnSync('git', ['commit', '-m', msg], { cwd: root, encoding: 'utf-8' }); @@ -1202,7 +1202,7 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string const existingLangs = ((existing['languages'] as unknown[] | undefined) ?? []) as Array>; const languages = detectWorkspaceLangs(path.join(root, storyFolderName), existingLangs); - const slug = bookTitle.replaceAll(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book'; + const slug = bookTitle.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/, '') || 'Book'; const settings: Record = { ...existing, bookTitle, diff --git a/mcp-ts/test/aisetup.test.ts b/mcp-ts/test/aisetup.test.ts index 68b20db..ca83cf9 100644 --- a/mcp-ts/test/aisetup.test.ts +++ b/mcp-ts/test/aisetup.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest'; const tempRoots: string[] = []; diff --git a/mcp-ts/test/content-tools.test.ts b/mcp-ts/test/content-tools.test.ts index 19547db..8e820cb 100644 --- a/mcp-ts/test/content-tools.test.ts +++ b/mcp-ts/test/content-tools.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -131,16 +131,7 @@ describe('toolGetNotes', () => { expect(result).not.toContain('Bob'); }); - it('includes Details_*.md files at root', () => { - const root = makeRoot(); - write(path.join(root, 'Details_Setting.md'), '# Setting\nA fantasy world.\n'); - - const result = toolGetNotes(root, {}); - expect(result).toContain('Details_Setting.md'); - expect(result).toContain('A fantasy world.'); - }); - - it('returns no-files message when Notes directory is missing and no Details_* files', () => { + it('returns no-files message when Notes directory is missing', () => { const root = makeRoot(); const result = toolGetNotes(root, {}); expect(result).toContain('No notes files found.'); diff --git a/mcp-ts/test/git-tools.test.ts b/mcp-ts/test/git-tools.test.ts index e3e3012..04187f7 100644 --- a/mcp-ts/test/git-tools.test.ts +++ b/mcp-ts/test/git-tools.test.ts @@ -1,7 +1,7 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { spawnSync } from 'child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' +import { spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest'; import { diff --git a/mcp-ts/test/integration-stdio.test.ts b/mcp-ts/test/integration-stdio.test.ts index efca9f8..bc35294 100644 --- a/mcp-ts/test/integration-stdio.test.ts +++ b/mcp-ts/test/integration-stdio.test.ts @@ -7,10 +7,10 @@ * Prerequisites: `npm run build` must have been run so `out/index.js` exists. */ -import * as child_process from 'child_process'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as child_process from 'node:child_process' +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest'; // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/mcp-ts/test/search-edgecases.test.ts b/mcp-ts/test/search-edgecases.test.ts index cfbb476..f52a2b0 100644 --- a/mcp-ts/test/search-edgecases.test.ts +++ b/mcp-ts/test/search-edgecases.test.ts @@ -24,6 +24,7 @@ function write(filePath: string, content: string): void { afterEach(() => { delete process.env['BINDERY_MAX_RESPONSE_BYTES']; + delete process.env['BINDERY_EMBEDDING_MAX_CHARS']; for (const root of tempRoots.splice(0, tempRoots.length)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -180,22 +181,6 @@ describe('Arc folder in lexical index', () => { describe('mocked Ollama — semantic_rerank', () => { const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10); - function ollamaFetchMock() { - return vi.fn().mockImplementation(async (input: unknown) => { - const url = String(input); - if (url.endsWith('/api/tags')) { - return { - ok: true, - json: async () => ({ models: [{ name: 'nomic-embed-text' }] }), - }; - } - return { - ok: true, - json: async () => ({ embedding: mockEmbedding }), - }; - }); - } - afterEach(() => { vi.restoreAllMocks(); delete process.env['BINDERY_OLLAMA_URL']; @@ -208,7 +193,7 @@ describe('mocked Ollama — semantic_rerank', () => { process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434'; - vi.stubGlobal('fetch', ollamaFetchMock()); + vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding)); const result = await toolSearch(root, { query: 'captain sailed', mode: 'semantic_rerank' }); expect(result).not.toContain('Warning:'); @@ -257,15 +242,18 @@ describe('mocked Ollama — semantic_rerank', () => { expect(result).toContain('Warning: semantic_rerank requested but BINDERY_OLLAMA_URL is not an Ollama endpoint; using lexical results.'); expect(result).toContain('/api/tags response did not contain a models array'); }); -}); -// ─── Mocked Ollama — buildSemanticIndex + full_semantic ────────────────────── + it('truncates rerank embedding prompts to BINDERY_EMBEDDING_MAX_CHARS', async () => { + const root = makeRoot(); + const longText = 'Alpha '.repeat(200); + write(path.join(root, 'Story', 'EN', 'Act I', 'Chapter 1.md'), `# Long\n${longText}\n`); + await toolIndexBuild(root); -describe('mocked Ollama — full_semantic', () => { - const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10); + process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434'; + process.env['BINDERY_EMBEDDING_MAX_CHARS'] = '64'; - function ollamaFetchMock() { - return vi.fn().mockImplementation(async (input: unknown) => { + const prompts: string[] = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (input: unknown, init?: RequestInit) => { const url = String(input); if (url.endsWith('/api/tags')) { return { @@ -273,12 +261,24 @@ describe('mocked Ollama — full_semantic', () => { json: async () => ({ models: [{ name: 'nomic-embed-text' }] }), }; } + const body = typeof init?.body === 'string' ? JSON.parse(init.body) as { prompt?: string } : {}; + if (typeof body.prompt === 'string') { prompts.push(body.prompt); } return { ok: true, json: async () => ({ embedding: mockEmbedding }), }; - }); - } + })); + + await toolSearch(root, { query: 'Alpha', mode: 'semantic_rerank' }); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts.every(p => p.length <= 64)).toBe(true); + }); +}); + +// ─── Mocked Ollama — buildSemanticIndex + full_semantic ────────────────────── + +describe('mocked Ollama — full_semantic', () => { + const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10); afterEach(() => { vi.restoreAllMocks(); @@ -293,7 +293,7 @@ describe('mocked Ollama — full_semantic', () => { process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434'; process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true'; - vi.stubGlobal('fetch', ollamaFetchMock()); + vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding)); const result = await toolIndexBuild(root); expect(result).toContain('Lexical index built:'); @@ -308,7 +308,7 @@ describe('mocked Ollama — full_semantic', () => { process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434'; process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true'; - vi.stubGlobal('fetch', ollamaFetchMock()); + vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding)); // Build the semantic index await toolIndexBuild(root); @@ -327,7 +327,7 @@ describe('mocked Ollama — full_semantic', () => { process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true'; // Build succeeds - vi.stubGlobal('fetch', ollamaFetchMock()); + vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding)); await toolIndexBuild(root); // Query fails @@ -363,4 +363,51 @@ describe('mocked Ollama — full_semantic', () => { expect(result).toContain('Semantic index failed: Semantic indexing requires an Ollama server.'); expect(result).toContain('/api/tags response did not contain a models array'); }); + + it('truncates semantic index embedding prompts to BINDERY_EMBEDDING_MAX_CHARS', async () => { + const root = makeRoot(); + const longText = 'Forest '.repeat(220); + write(path.join(root, 'Story', 'EN', 'Act I', 'Chapter 1.md'), `# Forest\n${longText}\n`); + + process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434'; + process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true'; + process.env['BINDERY_EMBEDDING_MAX_CHARS'] = '80'; + + const prompts: string[] = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/tags')) { + return { + ok: true, + json: async () => ({ models: [{ name: 'nomic-embed-text' }] }), + }; + } + const body = typeof init?.body === 'string' ? JSON.parse(init.body) as { prompt?: string } : {}; + if (typeof body.prompt === 'string') { prompts.push(body.prompt); } + return { + ok: true, + json: async () => ({ embedding: mockEmbedding }), + }; + })); + + await toolIndexBuild(root); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts.every(p => p.length <= 80)).toBe(true); + }); }); + +function ollamaFetchMock(mockEmbedding: number[]) { + return vi.fn().mockImplementation(async (input: unknown) => { + const url = String(input); + if (url.endsWith('/api/tags')) { + return { + ok: true, + json: async () => ({ models: [{ name: 'nomic-embed-text' }] }), + }; + } + return { + ok: true, + json: async () => ({ embedding: mockEmbedding }), + }; + }); +} diff --git a/mcp-ts/test/translations-tools.test.ts b/mcp-ts/test/translations-tools.test.ts index 668115c..ef0fc11 100644 --- a/mcp-ts/test/translations-tools.test.ts +++ b/mcp-ts/test/translations-tools.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest'; import { diff --git a/mcp-ts/test/workspace-tools.test.ts b/mcp-ts/test/workspace-tools.test.ts index 5cc164a..7d530c9 100644 --- a/mcp-ts/test/workspace-tools.test.ts +++ b/mcp-ts/test/workspace-tools.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest'; import { @@ -222,15 +222,6 @@ describe('toolAddLanguage', () => { // ─── toolSetupAiFiles ───────────────────────────────────────────────────────── describe('toolSetupAiFiles', () => { - function makeSettingsRoot(): string { - const root = makeRoot(); - write( - path.join(root, '.bindery', 'settings.json'), - JSON.stringify({ bookTitle: 'Test Book', storyFolder: 'Story', languages: [{ code: 'EN', folderName: 'EN' }] }) + '\n' - ); - return root; - } - it('creates CLAUDE.md when claude target requested', () => { const root = makeSettingsRoot(); toolSetupAiFiles(root, { targets: ['claude'], skills: [], overwrite: true }); @@ -462,3 +453,12 @@ describe('toolChapterStatusGet', () => { expect(result).toContain('Needs polish'); }); }); + +function makeSettingsRoot(): string { + const root = makeRoot(); + write( + path.join(root, '.bindery', 'settings.json'), + JSON.stringify({ bookTitle: 'Test Book', storyFolder: 'Story', languages: [{ code: 'EN', folderName: 'EN' }] }) + '\n' + ); + return root; +} diff --git a/mcpb/README.md b/mcpb/README.md index 2b9b29f..504881c 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -132,7 +132,7 @@ profiles and world rules, then uses `search` to verify specific details against | `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 | +| `get_notes` | Notes/ files, filterable by category or name | | `search` | Search in lexical, semantic-rerank, or full-semantic mode; semantic modes fall back to lexical with warnings | | `format` | Apply typography formatting to a file or folder | | `get_review_text` | Structured git diff with optional auto-staging | diff --git a/mcpb/manifest.json b/mcpb/manifest.json index fd57de1..e3e8130 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -69,7 +69,7 @@ { "name": "get_text", "description": "Read a source file by relative path, optionally restricted to a line range." }, { "name": "get_chapter", "description": "Fetch the full content of a chapter by number and language code (e.g. EN or NL)." }, { "name": "get_overview", "description": "List the chapter structure (acts, chapters, titles) for one or all languages." }, - { "name": "get_notes", "description": "Read from Notes/ and Details_*.md, optionally filtered by category or character name." }, + { "name": "get_notes", "description": "Read from Notes/, optionally filtered by category or character name." }, { "name": "format", "description": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder." }, { "name": "get_review_text", "description": "Structured git diff of uncommitted changes. Filter by language (EN, NL, ALL). Set autoStage to stage reviewed files." }, { "name": "git_snapshot", "description": "Save a snapshot (git commit) of all changes in story, notes, and arc folders. Use after writing sessions or reviews." }, diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 2689d23..a7102f1 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -12,21 +12,21 @@ */ import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync } from 'child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path' +import { execSync } from 'node:child_process' import { updateTypography } from './format'; import { mergeBook, checkPandoc, getBuiltInUkReplacements, type LanguageConfig, type DialectConfig, type OutputType, type MergeOptions, type UkReplacement, } from './merge'; import { - readWorkspaceSettings, readTranslations, writeTranslations, + readWorkspaceSettings, readTranslations, getBinderyFolder, getSettingsPath, getTranslationsPath, getBookTitleForLang, getSubstitutionRules, getIgnoredWords, upsertSubstitutionRule, upsertGlossaryRule, addIgnoredWords, - getDefaultLanguage, getDialectsForLanguage, getGlossaryRules, - type WorkspaceSettings, type TranslationsFile, type TranslationEntry, + getDefaultLanguage, getDialectsForLanguage, + type WorkspaceSettings, type TranslationsFile, } from './workspace'; import { ALL_SKILLS, @@ -112,7 +112,7 @@ function getEffectiveConfig(wsSettings: WorkspaceSettings | null): EffectiveConf /** True if filePath is inside //. */ function isInsideStoryFolder(filePath: string, root: string, storyFolder: string): boolean { // Normalize separators so Windows paths compare correctly - const norm = (p: string) => p.replace(/\\/g, '/'); + const norm = (p: string) => p.replaceAll(/\\/g, '/'); const story = norm(path.join(root, storyFolder)); const file = norm(filePath); return file.startsWith(story + '/'); @@ -194,16 +194,16 @@ function suggestUkSpelling(usWord: string): string | undefined { const lower = usWord.toLowerCase(); const builtInMap = new Map(getBuiltInUkReplacements().map(r => [r.us.toLowerCase(), r.uk])); if (builtInMap.has(lower)) { return builtInMap.get(lower); } - if (lower.endsWith('izations')) { return lower.replace(/izations$/, 'isations'); } - if (lower.endsWith('ization')) { return lower.replace(/ization$/, 'isation'); } - if (lower.endsWith('izing')) { return lower.replace(/izing$/, 'ising'); } - if (lower.endsWith('izes')) { return lower.replace(/izes$/, 'ises'); } - if (lower.endsWith('ized')) { return lower.replace(/ized$/, 'ised'); } - if (lower.endsWith('ize')) { return lower.replace(/ize$/, 'ise'); } - if (lower.endsWith('yzing')) { return lower.replace(/yzing$/, 'ysing'); } - if (lower.endsWith('yzes')) { return lower.replace(/yzes$/, 'yses'); } - if (lower.endsWith('yzed')) { return lower.replace(/yzed$/, 'ysed'); } - if (lower.endsWith('yze')) { return lower.replace(/yze$/, 'yse'); } + if (lower.endsWith('izations')) { return lower.replaceAll(/izations$/, 'isations'); } + if (lower.endsWith('ization')) { return lower.replaceAll(/ization$/, 'isation'); } + if (lower.endsWith('izing')) { return lower.replaceAll(/izing$/, 'ising'); } + if (lower.endsWith('izes')) { return lower.replaceAll(/izes$/, 'ises'); } + if (lower.endsWith('ized')) { return lower.replaceAll(/ized$/, 'ised'); } + if (lower.endsWith('ize')) { return lower.replaceAll(/ize$/, 'ise'); } + if (lower.endsWith('yzing')) { return lower.replaceAll(/yzing$/, 'ysing'); } + if (lower.endsWith('yzes')) { return lower.replaceAll(/yzes$/, 'yses'); } + if (lower.endsWith('yzed')) { return lower.replaceAll(/yzed$/, 'ysed'); } + if (lower.endsWith('yze')) { return lower.replaceAll(/yze$/, 'yse'); } return undefined; } @@ -293,7 +293,7 @@ async function initWorkspaceCommand() { const detectedLangs = detectLanguageFolders(path.join(root, storyFolder)); const languages = detectedLangs.length > 0 ? detectedLangs : [DEFAULT_LANGUAGE]; - const slug: string = title.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book'; + const slug: string = title.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/, '') || 'Book'; const settings: WorkspaceSettings = { ...(title ? { bookTitle: title } : {}), @@ -441,8 +441,8 @@ async function addDialectCommand() { let sourceLang: LanguageConfig | undefined; if (editor) { const sf = wsSettings?.storyFolder ?? 'Story'; - const file = editor.document.uri.fsPath.replace(/\\/g, '/'); - const base = path.join(root, sf).replace(/\\/g, '/'); + const file = editor.document.uri.fsPath.replaceAll(/\\/g, '/'); + const base = path.join(root, sf).replaceAll(/\\/g, '/'); if (file.startsWith(base + '/')) { const folderName = file.slice(base.length + 1).split('/')[0]; sourceLang = wsSettings?.languages?.find( diff --git a/vscode-ext/src/format.ts b/vscode-ext/src/format.ts index bd6f1c4..3de9515 100644 --- a/vscode-ext/src/format.ts +++ b/vscode-ext/src/format.ts @@ -44,11 +44,11 @@ export function updateTypography(text: string): string { let result = text; // Step 1: Convert ... to ellipsis (must happen before quote processing) - result = result.replace(/\.\.\./g, ELLIPSIS); + result = result.replaceAll(/\.\.\./g, ELLIPSIS); // Step 2: Protect HTML comments from em-dash conversion const protectedComments: string[] = []; - result = result.replace(COMMENT_RE, (match) => { + result = result.replaceAll(COMMENT_RE, (match) => { const placeholder = `\x00COMMENT${protectedComments.length}\x00`; protectedComments.push(match); return placeholder; @@ -56,39 +56,39 @@ export function updateTypography(text: string): string { // Step 3: Convert -- to em-dash (but preserve --- for markdown HR) const protectedTriple = '\x00TRIPLE\x00'; - result = result.replace(/---/g, protectedTriple); - result = result.replace(/--/g, EM_DASH); - result = result.replace(new RegExp(escapeRegex(protectedTriple), 'g'), '---'); + result = result.replaceAll(/---/g, protectedTriple); + result = result.replaceAll(/--/g, EM_DASH); + result = result.replaceAll(new RegExp(escapeRegex(protectedTriple), 'g'), '---'); // Step 4: Restore HTML comments for (let i = 0; i < protectedComments.length; i++) { - result = result.replace(`\x00COMMENT${i}\x00`, protectedComments[i]); + result = result.replaceAll(`\x00COMMENT${i}\x00`, protectedComments[i]); } // Step 4b: Fix closing quotes after em-dash introduced from -- - result = result.replace(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => { + result = result.replaceAll(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => { return `${EM_DASH}${CLOSE_DOUBLE}${after}`; }); // Step 5: Convert double quotes // Opening: after whitespace, start of line, or opening brackets - result = result.replace(OPEN_DOUBLE_RE, (_match, before) => { + result = result.replaceAll(OPEN_DOUBLE_RE, (_match, before) => { return `${before}${OPEN_DOUBLE}`; }); // Closing: all remaining straight double quotes - result = result.replace(/"/g, CLOSE_DOUBLE); + result = result.replaceAll(/"/g, CLOSE_DOUBLE); // Step 6: Convert single quotes // Opening: after whitespace, start of line, or opening brackets - result = result.replace(OPEN_SINGLE_RE, (_match, before) => { + result = result.replaceAll(OPEN_SINGLE_RE, (_match, before) => { return `${before}${OPEN_SINGLE}`; }); // Closing/apostrophe: all remaining straight single quotes - result = result.replace(/'/g, CLOSE_SINGLE); + result = result.replaceAll(/'/g, CLOSE_SINGLE); return result; } function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/vscode-ext/src/merge.ts b/vscode-ext/src/merge.ts index ea76ba5..703896f 100644 --- a/vscode-ext/src/merge.ts +++ b/vscode-ext/src/merge.ts @@ -4,9 +4,9 @@ * Ported from mcp-rust/src/merge.rs */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as cp from 'child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as cp from 'node:child_process'; import { updateTypography } from './format'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -14,6 +14,8 @@ import { updateTypography } from './format'; export interface LanguageConfig { code: string; folderName: string; + /** Optional per-language export title from settings.json languages[]. */ + bookTitle?: string; chapterWord: string; actPrefix: string; prologueLabel: string; @@ -118,9 +120,6 @@ const FIRST_H1_RE = /^(\s*)#\s+/m; /** Matches heading line for image insertion */ const HEADING_LINE_RE = /^#[^\n]*\n/m; -/** Matches book title row in translation notes */ -const BOOK_TITLE_RE = /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*Name of the book\s*\|\s*$/m; - // ─── UK Conversion (US → UK) ─────────────────────────────────────────────── export interface UkReplacement { @@ -209,7 +208,7 @@ function applyCasing(source: string, target: string): string { } function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function getBuiltInUkReplacements(): UkReplacement[] { @@ -266,7 +265,7 @@ function convertUsToUkText(text: string, customReplacements: UkReplacement[] = [ continue; } - const converted = line.replace(replacementData.pattern, (match) => { + const converted = line.replaceAll(replacementData.pattern, (match) => { const replacement = replacementData.map.get(match.toLowerCase()); if (!replacement) { return match; @@ -366,24 +365,24 @@ function parseActFolder(name: string): ActInfo | undefined { function extractChapterNum(filename: string): number | undefined { const m = CHAPTER_NUM_RE.exec(filename); - return m ? parseInt(m[1], 10) : undefined; + return m ? Number.parseInt(m[1], 10) : undefined; } function generateSlug(text: string): string { return text .toLowerCase() - .replace(SLUG_CLEAN_RE, '') + .replaceAll(SLUG_CLEAN_RE, '') .trim() .split(/\s+/) .join('-'); } function demoteH1ToH2(text: string): string { - return text.replace(FIRST_H1_RE, '$1## '); + return text.replaceAll(FIRST_H1_RE, '$1## '); } function collapseBlankLines(text: string): string { - return text.replace(BLANK_LINES_RE, '\n\n'); + return text.replaceAll(BLANK_LINES_RE, '\n\n'); } function formatActTitle(act: ActInfo, lang: LanguageConfig): string { @@ -577,7 +576,7 @@ function imageMarkdownFor(file: OrderedFile, options: MergeOptions): string | un const imagePath = path.join(options.root, 'images', imageName); if (!fs.existsSync(imagePath)) { return undefined; } - return `![](${imagePath.replace(/\\/g, '/')})\n\n`; + return `![](${imagePath.replaceAll(/\\/g, '/')})\n\n`; } function insertImageAfterHeading(content: string, imageMd: string): string { @@ -592,7 +591,7 @@ function insertImageAfterHeading(content: string, imageMd: string): string { function coverMarkdown(options: MergeOptions): string | undefined { const coverPath = path.join(options.root, options.storyFolder, options.language.folderName, 'cover.jpg'); if (!fs.existsSync(coverPath)) { return undefined; } - return `![](${coverPath.replace(/\\/g, '/')})\n\n`; + return `![](${coverPath.replaceAll(/\\/g, '/')})\n\n`; } function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputType: OutputType): string { @@ -647,20 +646,12 @@ function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputT // ─── Pandoc Invocation ────────────────────────────────────────────────────── -function getBookTitle(root: string, lang: LanguageConfig): string { - const notesPath = path.join(root, 'Notes', 'Details_Translation_notes.md'); - if (!fs.existsSync(notesPath)) { return 'Book'; } +function resolveBookTitle(options: MergeOptions): string { + const fromOptions = options.bookTitle?.trim(); + if (fromOptions) { return fromOptions; } - try { - const content = fs.readFileSync(notesPath, 'utf-8'); - const m = BOOK_TITLE_RE.exec(content); - if (m) { - const useEnglishTitle = lang.code.toUpperCase() === 'EN' || lang.code.toUpperCase() === 'UK'; - const idx = useEnglishTitle ? 1 : 2; - const title = m[idx]?.trim(); - if (title) { return title; } - } - } catch { /* ignore */ } + const fromLanguage = options.language.bookTitle?.trim(); + if (fromLanguage) { return fromLanguage; } return 'Book'; } @@ -874,8 +865,8 @@ export async function mergeBook(options: MergeOptions): Promise { await checkPandoc(options.pandocPath); } - // Determine book title - const title = options.bookTitle || getBookTitle(options.root, options.language); + // Determine book title from settings-driven options only + const title = resolveBookTitle(options); for (const outputType of options.outputTypes) { const outputPath = path.join(outputDir, `${baseName}.${outputType}`); diff --git a/vscode-ext/src/workspace.ts b/vscode-ext/src/workspace.ts index f3b1972..4670534 100644 --- a/vscode-ext/src/workspace.ts +++ b/vscode-ext/src/workspace.ts @@ -8,8 +8,8 @@ * Machine-specific paths (pandoc, LibreOffice) always come from VS Code settings. */ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as path from 'node:path' import type { LanguageConfig, DialectConfig, UkReplacement } from './merge'; export const BINDERY_FOLDER = '.bindery'; diff --git a/vscode-ext/test/integration-commands.test.ts b/vscode-ext/test/integration-commands.test.ts index 1b2a8ec..3528f92 100644 --- a/vscode-ext/test/integration-commands.test.ts +++ b/vscode-ext/test/integration-commands.test.ts @@ -11,9 +11,9 @@ * without an extension host and complete quickly. */ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest'; // ─── Mock vscode ────────────────────────────────────────────────────────────── diff --git a/vscode-ext/test/mcp.test.ts b/vscode-ext/test/mcp.test.ts index e0a1596..3b52189 100644 --- a/vscode-ext/test/mcp.test.ts +++ b/vscode-ext/test/mcp.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('vscode', () => { diff --git a/vscode-ext/test/merge.test.ts b/vscode-ext/test/merge.test.ts index b839eaf..93aefe0 100644 --- a/vscode-ext/test/merge.test.ts +++ b/vscode-ext/test/merge.test.ts @@ -28,9 +28,9 @@ vi.mock('vscode', () => ({ // ─── Imports ────────────────────────────────────────────────────────────────── -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest'; import { getBuiltInUkReplacements, diff --git a/vscode-ext/test/workspace.test.ts b/vscode-ext/test/workspace.test.ts index ce8ae0d..f57febc 100644 --- a/vscode-ext/test/workspace.test.ts +++ b/vscode-ext/test/workspace.test.ts @@ -1,6 +1,6 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os' +import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest'; import {