initial commit in won repo

This commit is contained in:
Erik van der Boom 2026-03-31 13:27:21 +02:00
commit 629f9c94e8
76 changed files with 14395 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build outputs
vscode-ext/out/
mcp-rust/target/
# Packaged extensions
*.vsix
*.mcpb
# Environment (contains personal paths)
.env
# Python
__pycache__/
*.pyc
.venv/
onnx/
# OS
.DS_Store
Thumbs.db
# Editor
*.swp
*.swo
*~

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Erik van der Boom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

85
README.md Normal file
View file

@ -0,0 +1,85 @@
# Bindery
Markdown book authoring toolkit: a **VS Code extension** for typography formatting and multi-format export, paired with an **MCP server** for hybrid semantic search and retrieval.
## Components
### [vscode-ext/](vscode-ext/) — VS Code Extension
The **Bindery** extension provides:
- **Typography formatting** — curly quotes, em-dashes, ellipses, smart apostrophes
- **Chapter merge & export** — Markdown, DOCX, EPUB, PDF output via Pandoc + LibreOffice
- **Dialect conversion** — US→UK spelling with extensible substitution rules
- **Multi-language support** — configurable per-language chapter labelling and folder structure
- **Workspace config**`.bindery/settings.json` and `.bindery/translations.json` for project-level settings
Install from the VS Code Marketplace or build from source:
```bash
cd vscode-ext
npm install
npm run compile
npx @vscode/vsce package
```
See [vscode-ext/README.md](vscode-ext/README.md) for full documentation.
### [mcp-rust/](mcp-rust/) — MCP Server (Rust)
A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes your book project to AI assistants. Built in Rust for fast hybrid retrieval:
- **BM25 + HNSW vector search** — hybrid retrieval across all chapters and notes
- **Embedding backends** — Ollama (local) or ONNX (Windows GPU via DirectML)
- **Tools**`retrieve_context`, `get_chapter`, `get_overview`, `get_notes`, `search`, `get_review_text`, `merge`, `format`, and more
- **WSL-optimised** — source on Windows mount, indices on ext4 for performance
```bash
# In WSL:
cd mcp-rust
cp .env.example .env # edit with your paths
cargo build —release
```
See [mcp-rust/README.md](mcp-rust/README.md) for setup instructions.
### [scripts/](scripts/) — ONNX Embedding Server
Optional GPU-accelerated embedding server (Python + DirectML) that runs on Windows and serves embeddings to the MCP server over HTTP.
See [SETUP_ONNX_SERVER.md](SETUP_ONNX_SERVER.md) for standalone installation.
### [mcpb/](mcpb/) — Claude Desktop Extension
Packages the MCP server as a `.mcpb` desktop extension for one-click installation in Claude Desktop / Cowork.
See [SETUP_MCP_SERVER.md](SETUP_MCP_SERVER.md) for build and install instructions.
## Quick Start
1. **Install the VS Code extension** — provides formatting and export without any server setup
2. **Optionally set up the MCP server** — adds semantic search and AI assistant integration
3. **Optionally set up ONNX embeddings** — enables GPU-accelerated vector search
## Project Structure
```
├── vscode-ext/ VS Code extension (TypeScript)
│ ├── src/ Extension source
│ ├── package.json Extension manifest
│ └── README.md Extension docs
├── mcp-rust/ MCP server (Rust)
│ ├── src/ Server source
│ ├── Cargo.toml Rust manifest
│ ├── .env.example Configuration template
│ └── README.md Server docs
├── scripts/ ONNX embedding server (Python)
├── mcpb/ Claude Desktop extension package
├── SETUP_MCP_SERVER.md MCP server setup guide
├── SETUP_ONNX_SERVER.md ONNX server setup guide
└── LICENSE MIT
```
## License
MIT — see [LICENSE](LICENSE).

283
SETUP_MCP_SERVER.md Normal file
View file

@ -0,0 +1,283 @@
# Bindery MCP Server — Setup & Verification Guide
This guide covers building, configuring, and verifying the MCP server in WSL, including connecting it to the ONNX embedding server on Windows and to Cowork.
## Prerequisites
- WSL2 with Ubuntu (22.04+)
- Rust toolchain installed in WSL
- The ONNX embedding server running on Windows (see `SETUP_ONNX_SERVER.md`), OR Ollama in WSL
- Pandoc installed in WSL (for DOCX/EPUB/PDF export via `merge` tool)
- LibreOffice installed in WSL (for PDF export via `merge` tool — see below)
## 1. Check if already installed
If youve previously set up the MCP server, check if the binary and workspace exist:
```bash
# Check for the binary
ls -la ~/bindery_source/target/release/bindery-mcp 2>/dev/null
# Check for workspace directories
ls -d ~/bindery_work ~/bindery_cache ~/bindery_source 2>/dev/null
# Check if .env exists
cat ~/bindery_source/.env 2>/dev/null
```
If all three exist, skip to **Step 4 (Verify)**.
## 2. First-time WSL setup
```bash
sudo apt update
sudo apt install -y build-essential pkg-config libssl-dev git jq ripgrep rsync pandoc libreoffice
# Install Rust (if not already present)
which cargo || (curl https://sh.rustup.rs -sSf | sh && source ~/.cargo/env)
```
Create the workspace directories:
```bash
mkdir -p ~/bindery_work
mkdir -p ~/bindery_cache/.bindery/index
mkdir -p ~/bindery_cache/.bindery/logs
mkdir -p ~/bindery_source
```
## 3. Build / Update the MCP server
Copy the source to ext4 (never build on `/mnt/c` — its 10-50x slower):
```bash
rsync -a —checksum —prune-empty-dirs \
—exclude target/ \
/mnt/c/<path-to-your-repo>/_src/mcp-rust/ \
~/bindery_source/
```
Build in release mode:
```bash
cd ~/bindery_source
cargo build —release
```
The binary is at `~/bindery_source/target/release/bindery-mcp`.
## 4. Configure the .env file
Create or edit `~/bindery_source/.env` (you can copy the `~/bindery_source/.env.example`):
```dotenv
# === Required ===
# Where the Bindery repo lives (Windows mount path as seen from WSL)
BINDERY_SOURCE_ROOT=/mnt/c/<path-to-your-repo>
# ext4 workspace for indexing (fast IO)
BINDERY_WORK_ROOT=/home/<user>/bindery_work
# Index storage
BINDERY_INDEX_DIR=/home/<user>/bindery_cache/.bindery/index
# === Embedding backend (pick one) ===
BINDERY_EMBEDDINGS_BACKEND=onnx
# — ONNX settings —
# BINDERY_ONNX_URL is optional: if omitted, the server auto-detects the Windows
# host IP at startup via `ip route` (the WSL default gateway). Set it explicitly
# only if auto-detection fails or you use a non-standard setup.
# BINDERY_ONNX_URL=http://<windows-host-ip>:11435
#
# BINDERY_ONNX_PORT is optional: port the ONNX server listens on (default: 11435).
# Combine with auto-detect: set only this if you changed the port but not the IP.
# BINDERY_ONNX_PORT=11435
BINDERY_ONNX_MODEL=bge-m3
# Standalone ONNX server directory (Windows path)
# If set, auto-start looks here instead of under SOURCE_ROOT
BINDERY_ONNX_SERVER_DIR=C:\Tools\onnx-embeddings
# — OR Ollama settings —
# BINDERY_EMBEDDINGS_BACKEND=ollama
# BINDERY_OLLAMA_URL=http://127.0.0.1:11434
# BINDERY_OLLAMA_MODEL=nomic-embed-text
# === PDF export ===
# LibreOffice is used to convert DOCX → PDF. The default works on WSL/Linux after:
# sudo apt install libreoffice
# On a Windows-native setup, set the full path to soffice.exe instead:
# BINDERY_LIBREOFFICE_PATH=C:\Program Files\LibreOffice\program\soffice.exe
BINDERY_LIBREOFFICE_PATH=libreoffice
# === Optional ===
BINDERY_MAX_RESPONSE_BYTES=60000
BINDERY_SNIPPET_MAX_CHARS=1600
BINDERY_DEFAULT_TOPK=6
BINDERY_AUTHOR=Erik
```
### Finding the Windows host IP
The ONNX server listens on `0.0.0.0:11435` on Windows. The MCP server auto-detects
the Windows host IP at startup by reading the WSL default gateway (`ip route`), so
you dont need to set `BINDERY_ONNX_URL` manually.
If auto-detection fails (e.g. unusual network config), you can override it explicitly:
```bash
ip route | awk /default/ {print $3; exit}
```
Then set `BINDERY_ONNX_URL=http://<that-ip>:11435` in your `.env`. If you only changed
the port, you can set just `BINDERY_ONNX_PORT=<port>` and let the IP auto-detect.
## 5. Verify the server works
### Quick smoke test (stdio mode)
```bash
cd ~/bindery_source
echo {“jsonrpc”:”2.0”,”id”:1,”method”:”initialize”,”params”:{“protocolVersion”:”2025-11-25”,”capabilities”:{},”clientInfo”:{“name”:”test”,”version”:”0.1”}}} | ./target/release/bindery-mcp 2>/dev/null | head -1
```
You should get a JSON response with `”serverInfo”`.
### Test the health tool
```bash
cd ~/bindery_source
(echo {“jsonrpc”:”2.0”,”id”:1,”method”:”initialize”,”params”:{“protocolVersion”:”2025-11-25”,”capabilities”:{},”clientInfo”:{“name”:”test”,”version”:”0.1”}}}; echo {“jsonrpc”:”2.0”,”id”:2,”method”:”tools/call”,”params”:{“name”:”health”,”arguments”:{}}}) | ./target/release/bindery-mcp 2>/dev/null
```
Look for `embeddings_backend: “onnx”` and `embeddings_available: true` in the response.
### Verify ONNX connectivity from WSL
```bash
ONNX_IP=$(ip route | awk /default/ {print $3; exit})
curl -s “http://${ONNX_IP}:11435/health”
```
Should return `{“ok”:true,…}`.
## 6. Connect to Claude Desktop / Cowork
The MCP server can be packaged as a **Desktop Extension** (`.mcpb`) for
one-click installation in Claude Desktop. The extension is a thin Node.js
wrapper that spawns the Rust binary inside WSL via `wsl.exe` and bridges
stdio — no tunnels, no extra services.
### Architecture
```
Claude Desktop <—> Node.js bridge (stdio) <—> wsl.exe <—> bindery-mcp (Rust, WSL)
```
### Prerequisites
- The Rust binary is built in WSL (steps 1-3 above)
- Node.js is installed on Windows (ships with Claude Desktop)
- `@anthropic-ai/mcpb` is installed globally: `npm install -g @anthropic-ai/mcpb`
### Packaging the extension
The extension source lives in `_src/mcpb/`. To build the `.mcpb` file:
```powershell
cd _src\mcpb
mcpb pack
```
This produces `bindery-mcp-1.0.0.mcpb`.
### Installing in Claude Desktop
1. Open Claude Desktop.
2. Go to **Settings > Extensions**.
3. Click **Install from file** (or drag-drop the `.mcpb` file).
4. Fill in the configuration when prompted:
- **WSL Binary Path**: `/home/erik/bindery_source/target/release/bindery-mcp`
- **Source Root (repo path)**: `C:\Users\YourUser\YourRepo`
- **Work Directory**: `/home/erik/bindery_work`
- **Index Directory**: `/home/erik/bindery_cache/.bindery/index`
- Leave other fields at their defaults unless you changed the ONNX setup.
5. Done — Bindery tools are now available in Claude Desktop and Cowork.
### How it works
The Node.js entry point (`server/index.js`) does the following:
1. Receives env vars from the manifests `user_config` (set during install).
2. Spawns `wsl.exe — bash -c “export …; cd <source_dir> && exec <binary>”`.
3. Pipes stdin/stdout between Claude Desktop and the Rust binary.
4. The Rust binary calls `dotenvy::dotenv()` which loads `.env` from the source dir;
manifest env vars are already exported and take precedence over `.env` for overlapping keys.
Env vars from the manifest supplement (and can override) what the `.env` file
provides, so the `.env` remains the single source of truth for most settings.
### Updating the extension
After code changes, rebuild the Rust binary (step 7 below), then re-pack
and reinstall the extension:
```powershell
cd _src\mcpb
mcpb pack
# Then reinstall the .mcpb in Claude Desktop
```
### Note on Cowork
Cowork runs in an isolated Linux VM that cannot access WSL paths or the local
network directly. The desktop extension approach works because Claude Desktop
(running on Windows) spawns the MCP server natively and proxies it to Coworks
VM. This avoids the need for HTTPS tunnels or cloudflared.
## 7. Rebuild after code changes
When you update the Rust source:
```bash
# Sync changes from Windows mount to ext4
rsync -a —checksum —prune-empty-dirs \
—exclude target/ \
/mnt/c/<path>/_src/mcp-rust/ \
~/bindery_source/
# Rebuild
cd ~/bindery_source
cargo build —release
```
The MCP server will pick up the new binary on next launch (Cowork restarts it per session).
## Troubleshooting
**”Missing required env: BINDERY_SOURCE_ROOT”**
- The `.env` file isnt being loaded. Make sure its in the same directory you run the binary from, or pass env vars explicitly.
**”ONNX server not running and neither BINDERY_ONNX_SERVER_DIR nor BINDERY_SOURCE_ROOT configured”**
- Set `BINDERY_ONNX_SERVER_DIR` to the Windows path of your standalone ONNX install.
**ONNX server unreachable from WSL**
- Check the Windows host IP: `ip route | awk /default/ {print $3; exit}`
- Make sure the ONNX server is actually running on Windows
- Check Windows Firewall isnt blocking port 11435
**Slow index_build**
- Make sure `BINDERY_WORK_ROOT` and `BINDERY_INDEX_DIR` point to ext4 paths (under `/home/`), NOT `/mnt/c/`
**Vector index skipped / backend “none”**
- The embedding server isnt reachable. Run `health` tool to diagnose.
**PDF export fails: “Failed to run LibreOffice”**
- LibreOffice is not installed or not in PATH. Run `sudo apt install libreoffice` in WSL.
- Verify with: `libreoffice —version`
- If using a custom install path, set `BINDERY_LIBREOFFICE_PATH` in your `.env` to the full binary path.
**PDF export fails: “LibreOffice PDF conversion failed”**
- LibreOffice ran but reported an error. Check that the intermediate DOCX was valid.
- Try running the conversion manually: `libreoffice —headless —convert-to pdf yourfile.docx —outdir /tmp`

185
SETUP_ONNX_SERVER.md Normal file
View file

@ -0,0 +1,185 @@
# ONNX Embedding Server — Standalone Installation
This guide sets up the ONNX embedding server in its own directory with its own Python environment, independent of the Bindery repository. The MCP server (running in WSL) will call this server over HTTP for GPU-accelerated embeddings.
## 1. Choose an install location (Windows)
Pick a directory on your Windows machine. Examples:
```
C:\Tools\onnx-embeddings
D:\Services\onnx-embeddings
%USERPROFILE%\onnx-embeddings
```
For this guide well use `<ONNX_DIR>` as a placeholder. Replace it with your chosen path.
```powershell
mkdir <ONNX_DIR>
cd <ONNX_DIR>
```
## 2. Copy the server files
From the Bindery repo, copy these files into `<ONNX_DIR>`:
```powershell
copy <Bindery_repo>\_src\scripts\onnx_embed_server.py .
copy <Bindery_repo>\_src\scripts\onnx_requirements.txt .
copy <Bindery_repo>\_src\scripts\onnx_export_requirements.txt .
copy <Bindery_repo>\_src\scripts\start_onnx_server_standalone.cmd .\start_onnx_server.cmd
copy <Bindery_repo>\_src\scripts\start_onnx_silent.ps1 .
```
Note: the standalone cmd file is renamed to `start_onnx_server.cmd` — this is the name the MCP server expects.
## 3. Create a Python virtual environment
Requires Python 3.10+ (python.org install recommended over Microsoft Store for services).
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
```
## 4. Export the ONNX model (one-time)
This step converts the PyTorch model to ONNX format. It needs `onnxruntime` (CPU) + `torch`, which conflict with the DirectML runtime, so we do this first and then swap.
```powershell
# Install export dependencies
pip install -r onnx_export_requirements.txt
# Export the model (creates onnx\bge-m3\ directory)
optimum-cli export onnx —model BAAI/bge-m3 —task feature-extraction onnx\bge-m3
```
This downloads ~2 GB and takes a few minutes.
## 5. Switch to runtime dependencies
```powershell
# Remove conflicting packages
pip uninstall onnxruntime onnxruntime-directml -y
# Install DirectML runtime
pip install -r onnx_requirements.txt
```
## 6. Verify it works
```powershell
# Set env vars for this session
$env:ONNX_MODEL_ID = “onnx\bge-m3”
$env:ONNX_EXPORT = “0”
# Start the server
python onnx_embed_server.py
```
You should see:
```
ONNX provider: DmlExecutionProvider (available: […])
INFO: Uvicorn running on http://0.0.0.0:11435
```
Test from another terminal:
```powershell
curl http://localhost:11435/health
```
Should return: `{“ok”:true,”model”:”onnx\\bge-m3”,”provider”:”DmlExecutionProvider”}`
Press Ctrl+C to stop.
## 7. Persist environment variables
So you dont need to set them every session:
```powershell
setx ONNX_MODEL_ID “onnx\bge-m3”
setx ONNX_EXPORT “0”
```
## 8. (Optional) Autostart with Task Scheduler
There are two variants depending on your machine setup:
### Option A — Personal machine (run whether logged on or not)
On a personal machine with a local account, Task Scheduler can run the server as a
background process even before you log in. No visible window, no wrapper script needed.
1. Open Task Scheduler (`taskschd.msc`)
2. **Create Task** (not Basic Task)
3. **General:** Name it `ONNX Embedding Server`, select **Run whether user is logged on or not**
4. **Trigger:** At startup
5. **Action:** Start a program
- Program: `<ONNX_DIR>\start_onnx_server.cmd`
- Start in: `<ONNX_DIR>`
6. **Conditions:** Uncheck “Start only if on AC power” (if on laptop)
7. **Settings:** Check “If the task fails, restart every 1 minute” (up to 3 times)
> **Note:** DirectML (GPU) requires a user session to initialize. If embeddings fail or fall back
> to CPU when no one is logged in, switch to Option B.
### Option B — Domain-joined machine, or when Option A has GPU issues (run at log on)
On domain-joined machines, “run whether logged on or not” often fails (requires domain
credentials and DirectML needs an interactive session). Instead, run at log on with a silent
launcher to keep the terminal hidden.
1. Open Task Scheduler (`taskschd.msc`)
2. **Create Task** (not Basic Task)
3. **General:** Name it `ONNX Embedding Server`, leave **Run only when user is logged on** selected
4. **Trigger:** At log on (for your user account)
5. **Action:** Start a program
- Program: `powershell.exe`
- Arguments: `-WindowStyle Hidden -ExecutionPolicy Bypass -NonInteractive -File “<ONNX_DIR>\start_onnx_silent.ps1”`
- Start in: `<ONNX_DIR>`
6. **Conditions:** Uncheck “Start only if on AC power”
7. **Settings:** Check “If the task fails, restart every 1 minute” (up to 3 times)
> **Note:** The `.ps1` wrapper launches `start_onnx_server.cmd` without a visible terminal.
> A `.vbs` wrapper (`start_onnx_silent.vbs`) exists for reference but VBScript is deprecated
> in Windows 11 24H2+ and will be disabled by default around 2027 — use the PS1 instead.
## 9. (Optional) Autostart as a Windows Service (NSSM)
```powershell
nssm install OnnxEmbeddings “<ONNX_DIR>\start_onnx_server.cmd”
nssm set OnnxEmbeddings AppDirectory “<ONNX_DIR>
nssm set OnnxEmbeddings DisplayName “ONNX Embedding Server”
nssm set OnnxEmbeddings Description “GPU-accelerated embedding server (DirectML)”
nssm set OnnxEmbeddings Start SERVICE_AUTO_START
nssm start OnnxEmbeddings
```
## Final directory layout
```
<ONNX_DIR>\
start_onnx_server.cmd # Launcher script
start_onnx_silent.ps1 # Silent launcher (no terminal window, for Task Scheduler)
onnx_embed_server.py # FastAPI server
onnx_requirements.txt # Runtime deps
onnx_export_requirements.txt # Export deps (keep for reference)
onnx\
bge-m3\ # Exported ONNX model files
.venv\ # Python virtual environment
```
## Telling the MCP server where to find it
In your MCP servers `.env` file, add:
```dotenv
BINDERY_ONNX_SERVER_DIR=<ONNX_DIR as Windows path>
```
For example:
```dotenv
BINDERY_ONNX_SERVER_DIR=C:\Tools\onnx-embeddings
```
If this variable is not set, the MCP server falls back to looking for `start_onnx_server.cmd` under `{BINDERY_SOURCE_ROOT}\_src\scripts\`.

82
mcp-rust/.env.example Normal file
View file

@ -0,0 +1,82 @@
# Bindery MCP Server — Environment Configuration
# Copy this file to .env and update the values for your machine.
#
# cp .env.example .env
#
# All paths under /mnt/ are WSL mount paths. Workspace/index paths should
# point to native ext4 (under /home/) for performance.
# ── Required ─────────────────────────────────────────────────────────────────
# Where the book repo lives on the Windows mount (read-only access)
BINDERY_SOURCE_ROOT=/mnt/c/Users/<user>/YourRepo
# ext4 workspace for synced content and indexing (fast IO)
BINDERY_WORK_ROOT=/home/<user>/bindery_work
# ── Index ────────────────────────────────────────────────────────────────────
# Where lexical + vector indices are stored (ext4 recommended)
# Default: {WORK_ROOT}/.bindery/index
BINDERY_INDEX_DIR=/home/<user>/bindery_cache/.bindery/index
# ── Embedding backend (pick one: onnx | ollama | none) ───────────────────────
BINDERY_EMBEDDINGS_BACKEND=onnx
# -- ONNX (Windows GPU via DirectML) --
# The ONNX server runs on Windows; WSL reaches it via the host IP.
#
# BINDERY_ONNX_URL — full URL override. If set, used as-is (overrides everything below).
# Leave commented out to use auto-detection.
# BINDERY_ONNX_URL=http://172.x.x.1:11435
#
# BINDERY_ONNX_PORT — port only. Auto-detection still resolves the IP.
# Only needed if you changed the port from the default (11435).
# BINDERY_ONNX_PORT=11435
BINDERY_ONNX_MODEL=bge-m3
# Standalone ONNX server install directory (Windows path).
# If set, auto-start looks here for start_onnx_server.cmd.
# If unset, falls back to {SOURCE_ROOT}/_src/scripts/.
# Use forward slashes to avoid dotenv escape issues (C:\t → tab).
# BINDERY_ONNX_SERVER_DIR=C:/Tools/onnx-embeddings
# -- Ollama (WSL-native, CPU or CUDA) --
# BINDERY_OLLAMA_URL=http://127.0.0.1:11434
# BINDERY_OLLAMA_MODEL=nomic-embed-text
# === PDF export ===
# LibreOffice is used to convert DOCX → PDF. The default works on WSL/Linux after:
# sudo apt install libreoffice
# On a Windows-native setup, set the full path to soffice.exe instead:
# BINDERY_LIBREOFFICE_PATH=C:\Program Files\LibreOffice\program\soffice.exe
BINDERY_LIBREOFFICE_PATH=libreoffice
# ── Optional: MCP source mirror ──────────────────────────────────────────────
# Mirror the mcp-rust source into ext4 for fast builds.
# Used by index_build when sync_paths includes "mcp-rust".
# BINDERY_MCP_MIRROR_ROOT=/home/<user>/src/bindery-mcp
# ── Tuning ───────────────────────────────────────────────────────────────────
# Delete files in WORK_ROOT that no longer exist in SOURCE_ROOT during sync
BINDERY_SYNC_DELETE=false
# Max bytes per tool response (protects against oversized payloads)
BINDERY_MAX_RESPONSE_BYTES=60000
# Max chars per snippet in retrieve_context results
BINDERY_SNIPPET_MAX_CHARS=1600
# Default number of results for retrieve_context
BINDERY_DEFAULT_TOPK=6
# Embedding batch size (higher = faster index_build, more memory)
# BINDERY_EMBED_BATCH_SIZE=32
# ── Metadata ─────────────────────────────────────────────────────────────────
# Author name used in EPUB/DOCX metadata when merging
BINDERY_AUTHOR=Your Name

64
mcp-rust/AGENTS.md Normal file
View file

@ -0,0 +1,64 @@
# Bindery MCP Server AGENTS
## Scope
- This file governs **tooling behavior only**.
- Do not apply story-writing or translation rules here.
## Server characteristics
- Runs locally via **stdio** in WSL.
- Operates on a **mirror workspace** in ext4.
- Source of truth remains on `/mnt/c`.
## Tool surface (v1)
- `health`
- `sync_workspace` (deprecated; use `index_build`)
- `index_build`
- `index_status`
- `retrieve_context`
- `get_text`
- `get_review_text`
- `search`
- `get_chapter`
- `get_overview`
- `get_notes`
- `merge`
- `format`
## Tooling rules
- Plain-text reads and searches must use **SOURCE_ROOT** (mount) so agents see the latest text:
- `get_text`, `get_review_text`, `search`, `get_chapter`, `get_overview`, `get_notes`
- Indexing and embeddings must use **WORK_ROOT** only:
- `index_build`, `retrieve_context`
- `merge` and `format` operate on **SOURCE_ROOT** (or explicit `root`/`path`).
- `merge` uses prologue/epilogue headings from the files (no generated duplicates).
- Prefer `retrieve_context`, `get_text`, `get_review_text` for context.
- Do **not** dump full book content into prompts.
- `get_review_text` ignores CR-at-EOL to avoid line-ending noise on Windows.
- When building the MCP server in WSL, **always** copy source to ext4 (e.g., `~/bindery_source`) and build there.
- Never build from `/mnt/c` (performance warning + slow IO).
- Optional: set `BINDERY_MCP_MIRROR_ROOT` and use `sync_workspace` with `”mcp-rust”` in `paths` to mirror the server source.
- If `sync_workspace` reports warnings, check `rsync_failures` for exit codes and stderr.
- If running the server directly with `cargo run`, rebuild after changes so the latest runtime-safe Ollama client is used.
- Embeddings backend can be `ollama`, `onnx`, or `none`.
## Indexing rules (critical)
Indexing and embeddings are **explicit maintenance operations**.
They MUST NOT be triggered implicitly.
### When to call `index_build`
Call `index_build` ONLY when:
1) The user explicitly asks to rebuild or update embeddings.
2) The user changed many chapters and asks for deep semantic recall.
3) Embedding backend/model/dim changed.
### When NOT to call `index_build`
- Reviewing recent edits.
- Reviewing a single chapter.
- Normal `retrieve_context` usage.
If results seem stale, warn the user and ask before rebuilding.
## Sync behavior
- `index_build` always syncs the index corpus from `/mnt/c``WORK_ROOT` before indexing.
- Minimum sync includes: `Story/EN`, `Story/NL`, `Story/AGENTS.md`, `Story/Details_*.md`, `Notes`, `AGENTS.md`, and any root-level `Details_*.md`.
- `sync_workspace` remains only for manual troubleshooting and is deprecated.

2863
mcp-rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

54
mcp-rust/Cargo.toml Normal file
View file

@ -0,0 +1,54 @@
[package]
name = "bindery-mcp"
version = "0.2.0"
edition = "2021"
description = "Bindery MCP Server (hybrid retrieval, Ollama/ONNX embeddings, multi-language book export)"
license = "MIT OR Apache-2.0"
[dependencies]
# MCP SDK
rust-mcp-sdk = { version = "0.8", default-features = false, features = ["server", "macros", "stdio"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Env
dotenvy = "0.15.7"
# Errors
anyhow = "1"
thiserror = "1"
# IO + search
walkdir = "2"
regex = "1"
once_cell = "1"
# Indexing
tantivy = "0.22"
hnsw_rs = "0.3"
sha2 = "0.10"
hex = "0.4"
# HTTP embeddings
ureq = { version = "2", features = ["json"] }
# Time + logging
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# UUID for task IDs
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = true
strip = true

289
mcp-rust/README.md Normal file
View file

@ -0,0 +1,289 @@
# Bindery MCP Server
Minimal MCP server focused on fast hybrid retrieval (BM25 + HNSW) with Ollama or ONNX embeddings. The source repo stays on the Windows mount (`/mnt/c`). All heavy IO happens on the WSL filesystem (ext4).
## WSL Setup (Ubuntu)
```bash
sudo apt update
sudo apt install -y build-essential pkg-config libssl-dev git jq ripgrep rsync python3 python3-venv
```
Install Rust:
```bash
curl https://sh.rustup.rs -sSf | sh
source ~/.cargo/env
rustup default stable
```
Create ext4 workspace:
```bash
mkdir -p ~/bindery_work
mkdir -p ~/bindery_cache/.bindery/index
mkdir -p ~/bindery_cache/.bindery/models
mkdir -p ~/bindery_cache/.bindery/logs
mkdir -p ~/bindery_source
```
## Choose Embedding provider
### Ollama (Embeddings Only)
Install Ollama inside WSL (Linux):
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
Start the service (choose one):
```bash
# foreground
ollama serve
# or, if systemd is enabled in your WSL distro
sudo systemctl enable —now ollama
```
```bash
ollama serve
ollama pull nomic-embed-text
ollama list
```
### ONNX (Windows GPU via DirectML)
Optional: run a simple ONNX embedding server on Windows (GPU via DirectML) and point WSL to it.
#### First-time setup (model export)
The ONNX model must be exported once from PyTorch. This requires `onnxruntime` (CPU), which conflicts with `onnxruntime-directml` at runtime.
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
# Install export dependencies
pip install -r scripts\onnx_export_requirements.txt
# Export the model
optimum-cli export onnx —model BAAI/bge-m3 —task feature-extraction onnx\bge-m3
# Swap to runtime dependencies (removes onnxruntime, installs onnxruntime-directml)
pip uninstall onnxruntime onnxruntime-directml -y
pip install -r scripts\onnx_requirements.txt
# Set env vars (setx persists for future shells; $env: applies to current session)
setx ONNX_MODEL_ID “onnx/bge-m3”
setx ONNX_EXPORT “0”
$env:ONNX_MODEL_ID = “onnx/bge-m3”
$env:ONNX_EXPORT = “0”
```
#### Start the ONNX server
```powershell
.\.venv\Scripts\Activate.ps1
python scripts\onnx_embed_server.py
```
The server auto-detects the best provider (DirectML → CUDA → CPU). To force a specific provider:
```powershell
$env:ONNX_PROVIDER = “CPUExecutionProvider”
python scripts\onnx_embed_server.py
```
### Autostart the ONNX server
#### Option A: Task Scheduler (simplest)
Use the provided batch file `scripts\start_onnx_server.cmd`. Edit the path inside if your repo is not at `C:\repo\Bindery`.
1. Open Task Scheduler (`taskschd.msc`)
2. Create Task (not Basic Task)
3. **General:** Name it `Bindery ONNX Server`, check “Run whether user is logged on or not”
4. **Trigger:** At startup (or at log on)
5. **Action:** Start a program
- Program: `C:\repo\Bindery\scripts\start_onnx_server.cmd`
- Start in: `C:\repo\Bindery`
6. **Conditions:** Uncheck “Start only if on AC power” if on laptop
7. **Settings:** Check “If the task fails, restart every 1 minute” (up to 3 times)
#### Option B: Windows Service (with NSSM)
[NSSM](https://nssm.cc/) wraps any executable as a Windows service with automatic restart.
1. Download NSSM and add to PATH (or use full path)
2. Install the service:
```powershell
nssm install BinderyONNX “C:\repo\Bindery\scripts\start_onnx_server.cmd”
nssm set BinderyONNX AppDirectory “C:\repo\Bindery”
nssm set BinderyONNX DisplayName “Bindery ONNX Embedding Server”
nssm set BinderyONNX Description “GPU-accelerated embedding server for Bindery”
nssm set BinderyONNX Start SERVICE_AUTO_START
```
3. Start the service:
```powershell
nssm start BinderyONNX
```
4. Manage the service:
```powershell
nssm status BinderyONNX # check status
nssm restart BinderyONNX # restart
nssm stop BinderyONNX # stop
nssm remove BinderyONNX # uninstall (prompts for confirmation)
```
> **Note:** NSSM services run as `LocalSystem` by default, which cannot access Microsoft Store Python. Either install Python from python.org for all users, or switch the service to run as your user account via `nssm edit BinderyONNX` → Log on tab.
#### Option C: On-demand auto-start (no setup required)
The MCP server can automatically spawn the ONNX server on first embedding request. This adds ~10-30s latency on cold start but requires no Task Scheduler or service configuration.
The server derives the script path from `BINDERY_SOURCE_ROOT` in `.env`:
```dotenv
BINDERY_SOURCE_ROOT=/mnt/d/Source/Bindery
BINDERY_EMBEDDINGS_BACKEND=onnx
BINDERY_ONNX_URL=http://<windows-host-ip>:11435
```
When an embedding is requested and the ONNX server isnt running, the MCP server will:
1. Convert the WSL path to Windows path (`/mnt/d/…` → `D:\…`)
2. Spawn `scripts\start_onnx_server.cmd` via `cmd.exe` (WSL interop)
3. Poll until healthy (up to 60s timeout)
4. Proceed with the embedding request
#### Point WSL to the Windows ONNX server
Get the Windows host IP from inside WSL:
```bash
ip route | awk /default/ {print $3; exit}
```
Then set in your `.env`:
```dotenv
BINDERY_EMBEDDINGS_BACKEND=onnx
BINDERY_ONNX_URL=http://<windows-host-ip>:11435
BINDERY_ONNX_MODEL=bge-m3
```
## Configuration (.env)
Create a `.env` file inside `mcp-rust/` (see `.env.example`):
```dotenv
BINDERY_SOURCE_ROOT=/mnt/c/…/YourRepo
BINDERY_WORK_ROOT=/home/<user>/bindery_work
BINDERY_INDEX_DIR=/home/<user>/bindery_cache/.bindery/index
BINDERY_EMBEDDINGS_BACKEND=ollama
BINDERY_OLLAMA_URL=http://127.0.0.1:11434
BINDERY_OLLAMA_MODEL=nomic-embed-text
BINDERY_ONNX_URL=http://127.0.0.1:11435
BINDERY_ONNX_MODEL=bge-m3
# optional: mirror the MCP source into WSL ext4 for fast builds
BINDERY_MCP_MIRROR_ROOT=/home/<user>/src/bindery-mcp
BINDERY_SYNC_DELETE=false
BINDERY_MAX_RESPONSE_BYTES=60000
BINDERY_SNIPPET_MAX_CHARS=1600
BINDERY_DEFAULT_TOPK=6
# Author name for EPUB/DOCX metadata (optional)
BINDERY_AUTHOR=Your Name
```
## Build & Run (WSL)
**Important:** build and run from the WSL ext4 mirror, not from `/mnt/c`. Building on the mount is slow and causes performance warnings.
```bash
# one-time: copy source to WSL mirror
rsync -a —checksum —prune-empty-dirs /mnt/c/repo/YourRepo/mcp-rust/ ~/bindery_source/
cd ~/bindery_source
cargo build —release
./target/release/bindery-mcp
```
## Usage Flow
1) `index_build` (syncs `/mnt/c` → ext4 mirror, then builds lexical + vector indices)
- To sync MCP source too, include `”mcp-rust”` in `sync_paths` and set `BINDERY_MCP_MIRROR_ROOT`
2) `retrieve_context` (fast hybrid retrieval)
## Tools
- `sync_workspace` (deprecated; use `index_build`)
- `index_build` (syncs the index corpus, then builds lexical + vector indices; set `background: true` to run async)
- `task_status` (check status of background tasks by `task_id`)
- `retrieve_context` (hybrid search: BM25 + vector; filter by language EN/NL/ALL)
- `get_review_text` (structured git diff with context; EN/NL/ALL filters; ignores CR-at-EOL to avoid CRLF noise)
- `get_chapter`
- `get_overview`
- `get_notes`
- `merge` (publish/export)
- `format`
### Multi-language Indexing
`index_build` always indexes **all languages** (EN + NL) into a single combined index:
```json
{“background”: true}
```
When retrieving, use `language` to filter results:
- `language: “EN”` — returns only English content (plus language-neutral files like Notes/ and Story/Details_*)
- `language: “NL”` — returns only Dutch content (plus language-neutral files)
- `language: “ALL”` (default) — returns all content
### Mount-first read tools
These tools read directly from `SOURCE_ROOT` so agents always see the latest text:
- `get_text` (identifier-based lookup like `chapter8`, `act2 chapter9`, `details_overall`, or a relative path)
- `search` (lexical search under `Story`, `Notes`, root `AGENTS.md`, and root `Details_*.md`)
- `get_review_text` (git diff from the mount repo)
### Background Tasks
For long-running operations like full `index_build`, use `background: true`:
```json
{“language”: “EN”, “background”: true}
```
Returns immediately with a `task_id`. Poll with `task_status`:
```json
{“task_id”: “<uuid>”}
```
Response includes `status` (`running`, `completed`, `failed`), `progress`, and `result` when done.
Indexing tools (`index_build`, `retrieve_context`) operate on `WORK_ROOT` only. Plain-text read/search tools (`get_text`, `search`, `get_review_text`, `get_chapter`, `get_overview`, `get_notes`) operate on `SOURCE_ROOT` (mount). `merge` and `format` operate on `SOURCE_ROOT` (or an explicit `root`/`path` argument). DOCX output uses `reference.docx` when present. DOCX/EPUB outputs do not include a TOC and will embed chapter images from `images/` (e.g., `chapter1.jpg`). DOCX also inserts `Story/<lang>/cover.jpg` as a cover page if present, and EPUB splits per chapter. Prologue/Epilogue headings come from the chapter files (no duplicate generated headings).
## Troubleshooting
- **Vector skipped / backend none**: `.env` not loaded or Ollama unreachable.
- **Slow responses**: you are reading from `/mnt/c` or indexes are on `/mnt/c`. Run `health` to confirm.
- **Stale mirror warning**: run `index_build` to resync and rebuild indices.
- **Sync warnings**: check the `rsync_failures` array for exit codes and stderr (from `index_build` or `sync_workspace`).
- **Tokio runtime drop panic on `cargo run`**: rebuild the latest binary; Ollama calls use a non-async HTTP client to avoid this.
- **Manually require Copy**: `rsync -a —checksum —delete —exclude target/ /mnt/c/repo/YourRepo/mcp-rust/ ~/bindery_source/`

View file

@ -0,0 +1,179 @@
# Bindery MCP Server — Walkthrough
This document explains the vNext MCP server layout and core flows.
## Architecture
```
src/
main.rs
config.rs
format.rs
merge.rs
tools/
health.rs
sync_workspace.rs
index_build.rs
index_status.rs
retrieve_context.rs
get_text.rs
get_review_text.rs
get_chapter.rs
get_overview.rs
get_notes.rs
search.rs
format.rs
merge.rs
tasks.rs
docstore/
discover.rs
chunk.rs
read.rs
index/
lexical.rs
vector.rs
meta.rs
embeddings/
provider.rs
ollama.rs
onnx.rs
none.rs
retrieve/
hybrid.rs
normalize.rs
```
```mermaid
graph TD
main[main.rs] —> config[config.rs]
main —> tools[tools/*]
main —> format[format.rs]
main —> merge[merge.rs]
main —> embeddings[embeddings/*]
tools —> docstore[docstore/*]
tools —> index[index/*]
tools —> retrieve[retrieve/*]
embeddings —> ollama[ollama.rs]
embeddings —> onnx[onnx.rs]
embeddings —> none[none.rs]
```
## Embeddings Module
The `embeddings/` module provides a trait-based abstraction for embedding providers:
- **`provider.rs`**: Defines the `EmbeddingProvider` trait with `embed()`, `is_available()`, `model()`, and `backend()` methods.
- **`ollama.rs`**: Connects to a local Ollama server for embeddings.
- **`onnx.rs`**: Connects to a Windows ONNX embedding server (GPU via DirectML). Includes auto-start capability.
- **`none.rs`**: Fallback when no embeddings are configured.
### ONNX Auto-Start
The `OnnxProvider` can automatically spawn the Windows ONNX server from WSL if its not running:
1. On first embedding request, checks `/health` endpoint
2. If unreachable, converts `BINDERY_SOURCE_ROOT` from WSL path (`/mnt/d/…`) to Windows path (`D:\…`)
3. Spawns `scripts\start_onnx_server.cmd` via `cmd.exe` (WSL interop)
4. Polls until healthy (60s timeout)
5. Proceeds with embedding request
This adds ~10-30s cold start latency but requires no Task Scheduler or service setup.
## Startup
- `dotenvy::dotenv().ok()` is called at the top of `main()`.
- `Config::from_env()` resolves `SOURCE_ROOT`, `WORK_ROOT`, and `INDEX_DIR`.
- A single-line startup summary is written to stderr.
## Build Location (WSL)
- The source of truth lives on `/mnt/c`.
- **Always** copy `mcp-rust/` to WSL ext4 (e.g., `~/src/bindery-mcp`) and build there.
- Building on `/mnt/c` is slow and triggers performance warnings.
Optional automation:
- Set `BINDERY_MCP_MIRROR_ROOT`.
- Call `sync_workspace` with `paths` including `”mcp-rust”` to mirror the server source.
## Tools
### `health`
Returns diagnostics:
- current working directory
- resolved roots
- last sync timestamp (if manifest exists)
- embeddings backend + reachability
- index presence
### `sync_workspace`
Deprecated. Uses `rsync` (or a fallback copy) to mirror `/mnt/c` content into `WORK_ROOT`, then writes:
`.bindery/work_manifest.json` under the mirror. Use `index_build` instead, which
syncs automatically before indexing.
### `index_build`
- Syncs the index corpus from `/mnt/c``WORK_ROOT` (default: `Story`, `Notes`)
- Discovers all chapter files (EN + NL) plus story-level docs and notes under `WORK_ROOT`
- Chunks markdown by paragraph
- Builds Tantivy BM25 index (lexical)
- Builds HNSW vector store when embeddings are reachable (Ollama/ONNX calls use a blocking HTTP client safe for Tokio)
- Writes `meta.json` with schema version and build details
### `index_status`
Reads `meta.json` and reports index presence.
### `retrieve_context`
Hybrid retrieval:
1) BM25 top candidates
2) HNSW candidates (if available)
3) Merge + normalize scores
4) Filter by language (if specified):
- `EN` → only paths containing `/EN/` plus language-neutral files (Notes/, Story/Details_*)
- `NL` → only paths containing `/NL/` plus language-neutral files
- `ALL` (default) → all indexed content
5) Rerank and return capped snippets
### `get_text`
Reads by identifier from `SOURCE_ROOT` (mount). Accepts relative paths or shorthand like `chapter8`, `act2 chapter9`, `details_overall`, `agents`.
### `get_review_text`
Returns a structured `git diff` with per-file hunks and filled context lines. Supports `EN`/`NL`/`ALL` filters and ignores CR-at-EOL to prevent line-ending noise on Windows.
### `get_chapter`
Returns a full chapter by number and language from `SOURCE_ROOT`.
### `get_overview`
Returns an act/chapter overview parsed from Story arc files on `SOURCE_ROOT`.
### `get_notes`
Returns a named entry from `Notes/Details_Notes.md` on `SOURCE_ROOT`.
### `search`
Literal/regex search under `SOURCE_ROOT/Story`, `SOURCE_ROOT/Notes`, plus root `AGENTS.md` and `Details_*.md`.
### `format`
Applies typography formatting (curly quotes, ellipsis, em-dash) to markdown files under `SOURCE_ROOT` (or explicit `path`).
### `merge`
Merges chapters into a single markdown and optionally exports DOCX/EPUB under `SOURCE_ROOT` (or explicit `root`). Requires Pandoc for DOCX/EPUB. DOCX export uses `reference.docx` when present. DOCX/EPUB outputs omit a TOC and embed chapter images from `images/` (e.g., `chapter1.jpg`). DOCX inserts `Story/<lang>/cover.jpg` as a cover page when present and EPUB splits per chapter. Prologue/Epilogue headings are taken from file content (no duplicate generated headings).
**EPUB/DOCX Metadata:** The following metadata is embedded:
- `title` Book title from `Details_Translation_notes.md`
- `author` From `BINDERY_AUTHOR` env var (optional)
- `lang` ISO 639-1 code (`en` or `nl`) based on language
- `date` Current date in `YYYY-MM-DD` format
### `task_status`
Returns status of background tasks. Pass `task_id` to query a specific task, or omit to list all.
## Background Tasks
The `TaskManager` in `tools/tasks.rs` provides a simple in-memory task tracking system:
- `create_task(type)` → creates a task with status `running`, returns UUID
- `update_progress(id, current, total, message)` → updates progress
- `complete_task(id, result)` → marks completed with JSON result
- `fail_task(id, error)` → marks failed with error message
- `cleanup_old_tasks(max_age_hours)` → removes old completed tasks
Tools like `index_build` support `background: true` to spawn work in a thread and return immediately with a task_id. The caller can poll `task_status` to check completion.

126
mcp-rust/src/config.rs Normal file
View file

@ -0,0 +1,126 @@
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{env, path::{Path, PathBuf}, process::Command};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub source_root: PathBuf,
pub work_root: PathBuf,
pub index_dir: PathBuf,
pub mcp_mirror_root: Option<PathBuf>,
pub embeddings_backend: String,
pub ollama_url: String,
pub ollama_model: String,
pub onnx_url: String,
pub onnx_model: String,
/// Optional: standalone directory where the ONNX server lives (Windows path).
/// Falls back to {source_root}/_src/scripts if not set.
pub onnx_server_dir: Option<String>,
pub embed_batch_size: usize,
pub sync_delete_default: bool,
pub max_response_bytes: usize,
pub snippet_max_chars: usize,
pub default_topk: usize,
/// Author name for EPUB/DOCX metadata
pub author: Option<String>,
/// Path to LibreOffice executable for PDF export.
/// Defaults to "libreoffice" (Linux/WSL). On Windows, set to full path of soffice.exe.
pub libreoffice_path: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
let source_root = get_required_path("BINDERY_SOURCE_ROOT")?;
let work_root = get_required_path("BINDERY_WORK_ROOT")?;
let index_dir = match env::var("BINDERY_INDEX_DIR") {
Ok(v) if !v.trim().is_empty() => PathBuf::from(v),
_ => work_root.join(".bindery").join("index"),
};
Ok(Self {
source_root,
work_root,
index_dir,
mcp_mirror_root: env::var("BINDERY_MCP_MIRROR_ROOT").ok().filter(|v| !v.trim().is_empty()).map(PathBuf::from),
embeddings_backend: env::var("BINDERY_EMBEDDINGS_BACKEND").unwrap_or_else(|_| "none".to_string()),
ollama_url: env::var("BINDERY_OLLAMA_URL").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()),
ollama_model: env::var("BINDERY_OLLAMA_MODEL").unwrap_or_else(|_| "nomic-embed-text".to_string()),
onnx_url: env::var("BINDERY_ONNX_URL").unwrap_or_else(|_| {
let port = env_usize("BINDERY_ONNX_PORT", 11435);
detect_windows_host_url(port).unwrap_or_else(|| format!("http://127.0.0.1:{}", port))
}),
onnx_model: env::var("BINDERY_ONNX_MODEL").unwrap_or_else(|_| "bge-m3".to_string()),
onnx_server_dir: env::var("BINDERY_ONNX_SERVER_DIR").ok().filter(|v| !v.trim().is_empty()),
embed_batch_size: env_usize("BINDERY_EMBED_BATCH_SIZE", 32),
sync_delete_default: env_bool("BINDERY_SYNC_DELETE", false),
max_response_bytes: env_usize("BINDERY_MAX_RESPONSE_BYTES", 60_000),
snippet_max_chars: env_usize("BINDERY_SNIPPET_MAX_CHARS", 1600),
default_topk: env_usize("BINDERY_DEFAULT_TOPK", 6),
author: env::var("BINDERY_AUTHOR").ok().filter(|v| !v.trim().is_empty()),
libreoffice_path: env::var("BINDERY_LIBREOFFICE_PATH")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| "libreoffice".to_string()),
})
}
pub fn manifest_path(&self) -> PathBuf {
self.work_root.join(".bindery").join("work_manifest.json")
}
pub fn config_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.source_root.to_string_lossy().as_bytes());
hasher.update(self.work_root.to_string_lossy().as_bytes());
hasher.update(self.index_dir.to_string_lossy().as_bytes());
hasher.update(self.embeddings_backend.as_bytes());
hasher.update(self.ollama_url.as_bytes());
hasher.update(self.ollama_model.as_bytes());
hasher.update(self.onnx_url.as_bytes());
hasher.update(self.onnx_model.as_bytes());
hex::encode(hasher.finalize())
}
}
pub fn is_mount_path(path: &Path) -> bool {
path.to_string_lossy().starts_with("/mnt/")
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"1" | "true" | "yes" | "y" => Some(true),
"0" | "false" | "no" | "n" => Some(false),
_ => None,
})
.unwrap_or(default)
}
fn env_usize(key: &str, default: usize) -> usize {
env::var(key).ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(default)
}
fn get_required_path(key: &str) -> Result<PathBuf> {
let value = env::var(key).map_err(|_| anyhow!("Missing required env: {key}"))?;
if value.trim().is_empty() {
return Err(anyhow!("Missing required env: {key}"));
}
Ok(PathBuf::from(value))
}
/// Detect the Windows host IP from WSL by reading the default gateway via `ip route`.
/// Returns `Some("http://<ip>:<port>")` on success, `None` if detection fails.
fn detect_windows_host_url(port: usize) -> Option<String> {
let output = Command::new("ip").args(["route"]).output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let ip = stdout
.lines()
.find(|l| l.starts_with("default"))?
.split_whitespace()
.nth(2)?
.to_string();
if ip.is_empty() { return None; }
Some(format!("http://{}:{}", ip, port))
}

View file

@ -0,0 +1,82 @@
use crate::Chunk;
use anyhow::{Result, anyhow};
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
pub fn chunk_file(work_root: &Path, path: &Path) -> Result<Vec<Chunk>> {
let file = File::open(path)?;
let rel_path = path.strip_prefix(work_root)
.map_err(|_| anyhow!("Path not under work root: {}", path.to_string_lossy()))?;
let rel_path_str = rel_path.to_string_lossy().replace('\\', "/");
let mut chunks = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut start_line: Option<u32> = None;
let mut line_no: u32 = 0;
for line in BufReader::new(file).lines() {
let line = line?;
line_no += 1;
if line.trim().is_empty() {
if let Some(start) = start_line.take() {
let end = line_no - 1;
let text = current.join("\n");
let id = chunk_id(&rel_path_str, start, end, &text);
chunks.push(Chunk {
id,
path: rel_path_str.clone(),
start_line: start,
end_line: end,
text,
});
current.clear();
}
continue;
}
if start_line.is_none() {
start_line = Some(line_no);
}
current.push(line);
}
if let Some(start) = start_line.take() {
let end = line_no;
let text = current.join("\n");
let id = chunk_id(&rel_path_str, start, end, &text);
chunks.push(Chunk {
id,
path: rel_path_str,
start_line: start,
end_line: end,
text,
});
}
Ok(chunks)
}
fn chunk_id(rel_path: &str, start_line: u32, end_line: u32, text: &str) -> String {
let content_hash = hash_bytes(text.as_bytes());
let raw = format!("{rel_path}:{start_line}:{end_line}:{content_hash}");
hash_bytes(raw.as_bytes())
}
fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::chunk_id;
#[test]
fn chunk_id_stable() {
let a = chunk_id("Story/EN/Chapter1.md", 1, 2, "Hello");
let b = chunk_id("Story/EN/Chapter1.md", 1, 2, "Hello");
assert_eq!(a, b);
}
}

View file

@ -0,0 +1,163 @@
use anyhow::{Result, anyhow};
use regex::Regex;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct DiscoverOptions {
pub language: String,
pub act: Option<String>,
pub chapter_range: Option<String>,
}
pub fn discover_chapter_files(work_root: &Path, opts: &DiscoverOptions) -> Result<Vec<PathBuf>> {
// Normalize language to uppercase (folders are EN/NL, or ALL for both)
let lang = opts.language.to_uppercase();
// Support "ALL" to index all languages
let languages: Vec<&str> = if lang == "ALL" {
vec!["EN", "NL"]
} else {
vec![lang.as_str()]
};
let mut all_results = Vec::new();
for lang in &languages {
let story_root = work_root.join("Story").join(lang);
if !story_root.exists() {
if languages.len() == 1 {
return Err(anyhow!("Story root not found: {}", story_root.to_string_lossy()));
}
continue; // Skip missing language folders when using ALL
}
let act_filter = opts.act.as_ref().map(|v| v.to_uppercase());
let act_folder = act_filter.as_deref().and_then(|act| match (*lang, act) {
("EN", "I") => Some("Act I - Awakening"),
("EN", "II") => Some("Act II - Resonance"),
("EN", "III") => Some("Act III - Collapse"),
("NL", "I") => Some("Deel I - Ontwaken"),
("NL", "II") => Some("Deel II - Resonantie"),
("NL", "III") => Some("Deel III - Instorting"),
_ => None,
});
let chapter_range = opts.chapter_range.as_ref().and_then(|value| parse_chapter_range(value));
let chapter_re = Regex::new(r"^Chapter(\d+)\.md$").expect("chapter regex");
for entry in WalkDir::new(&story_root).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let filename = match path.file_name().and_then(|s| s.to_str()) {
Some(v) => v,
None => continue,
};
let caps = match chapter_re.captures(filename) {
Some(c) => c,
None => continue,
};
if let Some(folder) = act_folder {
let path_str = path.to_string_lossy();
if !path_str.contains(folder) {
continue;
}
}
if let Some((min, max)) = chapter_range {
let num: u32 = caps.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
if num < min || num > max {
continue;
}
}
all_results.push(path.to_path_buf());
}
}
all_results.sort();
Ok(all_results)
}
pub fn discover_index_files(work_root: &Path, opts: &DiscoverOptions) -> Result<Vec<PathBuf>> {
let mut files = BTreeSet::new();
for path in discover_chapter_files(work_root, opts)? {
files.insert(path);
}
let story_root = work_root.join("Story");
if story_root.exists() {
let agents = story_root.join("AGENTS.md");
if agents.exists() {
files.insert(agents);
}
if let Ok(entries) = std::fs::read_dir(&story_root) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|v| v.to_str()) else { continue; };
if name.starts_with("Details_") && name.ends_with(".md") {
files.insert(path);
}
}
}
}
let notes_root = work_root.join("Notes");
if notes_root.exists() {
for entry in WalkDir::new(&notes_root).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
files.insert(entry.path().to_path_buf());
}
}
let root_details = work_root;
if let Ok(entries) = std::fs::read_dir(root_details) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|v| v.to_str()) else { continue; };
if name.starts_with("Details_") && name.ends_with(".md") {
files.insert(path);
}
}
}
Ok(files.into_iter().collect())
}
fn parse_chapter_range(value: &str) -> Option<(u32, u32)> {
let parts: Vec<&str> = value.split('-').collect();
if parts.len() != 2 {
return None;
}
let start = parts[0].trim().parse::<u32>().ok()?;
let end = parts[1].trim().parse::<u32>().ok()?;
Some((start.min(end), start.max(end)))
}
#[cfg(test)]
mod tests {
use super::parse_chapter_range;
#[test]
fn parses_chapter_range() {
assert_eq!(parse_chapter_range("1-8"), Some((1, 8)));
assert_eq!(parse_chapter_range("8-1"), Some((1, 8)));
assert_eq!(parse_chapter_range("bad"), None);
}
}

View file

@ -0,0 +1,3 @@
pub mod discover;
pub mod chunk;
pub mod read;

View file

@ -0,0 +1,42 @@
use anyhow::Result;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
pub fn read_lines_string(path: &Path, start: u32, end: u32) -> Result<String> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = Vec::new();
let mut line_no: u32 = 0;
for line in reader.lines() {
let line = line?;
line_no += 1;
if line_no < start {
continue;
}
if line_no > end {
break;
}
lines.push(line);
}
Ok(lines.join("\n"))
}
pub fn read_lines_vec(path: &Path, start: u32, end: u32) -> Result<Vec<(u32, String)>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = Vec::new();
let mut line_no: u32 = 0;
for line in reader.lines() {
let line = line?;
line_no += 1;
if line_no < start {
continue;
}
if line_no > end {
break;
}
lines.push((line_no, line));
}
Ok(lines)
}

View file

@ -0,0 +1,24 @@
pub mod provider;
pub mod ollama;
pub mod onnx;
pub mod none;
use crate::config::Config;
use anyhow::Result;
use std::sync::Arc;
pub fn build_provider(config: &Config) -> Result<Arc<dyn provider::EmbeddingProvider>> {
match config.embeddings_backend.to_lowercase().as_str() {
"ollama" => Ok(Arc::new(ollama::OllamaProvider::new(
config.ollama_url.clone(),
config.ollama_model.clone(),
))),
"onnx" => Ok(Arc::new(onnx::OnnxProvider::new(
config.onnx_url.clone(),
config.onnx_model.clone(),
Some(&config.source_root),
config.onnx_server_dir.clone(),
))),
_ => Ok(Arc::new(none::NoneProvider::default())),
}
}

View file

@ -0,0 +1,23 @@
use crate::embeddings::provider::EmbeddingProvider;
use anyhow::{Result, anyhow};
#[derive(Default, Clone)]
pub struct NoneProvider {}
impl EmbeddingProvider for NoneProvider {
fn embed(&self, _input: &str) -> Result<Vec<f32>> {
Err(anyhow!("Embeddings backend is none"))
}
fn is_available(&self) -> bool {
false
}
fn model(&self) -> String {
"none".to_string()
}
fn backend(&self) -> String {
"none".to_string()
}
}

View file

@ -0,0 +1,95 @@
use crate::embeddings::provider::EmbeddingProvider;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json;
use std::time::Duration;
#[derive(Clone)]
pub struct OllamaProvider {
url: String,
model: String,
}
impl OllamaProvider {
pub fn new(url: String, model: String) -> Self {
Self { url, model }
}
fn embed_request(&self, input: &str) -> Result<Vec<f32>> {
let endpoint = format!("{}/api/embeddings", self.url.trim_end_matches('/'));
let body = EmbeddingRequest { model: self.model.clone(), prompt: input.to_string() };
let body_json = serde_json::to_string(&body)?;
let response: Result<ureq::Response, ureq::Error> = ureq::post(&endpoint)
.set("Content-Type", "application/json")
.timeout(Duration::from_secs(30))
.send_string(&body_json);
match response {
Ok(resp) => {
if resp.status() >= 400 {
return Err(anyhow!("Ollama embeddings failed: {}", resp.status()));
}
let text = resp.into_string().map_err(|e| anyhow!("Ollama response read failed: {e}"))?;
let parsed: EmbeddingResponse = serde_json::from_str(&text)?;
Ok(parsed.embedding)
}
Err(ureq::Error::Status(code, resp)) => {
let text = resp.into_string().unwrap_or_default();
Err(anyhow!("Ollama embeddings failed: {} {}", code, text))
}
Err(err) => Err(anyhow!("Ollama request failed: {err}")),
}
}
fn ping(&self) -> bool {
let endpoint = format!("{}/api/tags", self.url.trim_end_matches('/'));
let response: Result<ureq::Response, ureq::Error> = ureq::get(&endpoint)
.timeout(Duration::from_secs(10))
.call();
match response {
Ok(resp) => resp.status() >= 200 && resp.status() < 300,
Err(_) => false,
}
}
}
impl EmbeddingProvider for OllamaProvider {
fn embed(&self, input: &str) -> Result<Vec<f32>> {
self.embed_request(input)
}
fn is_available(&self) -> bool {
self.ping()
}
fn model(&self) -> String {
self.model.clone()
}
fn backend(&self) -> String {
"ollama".to_string()
}
}
#[derive(Debug, Serialize)]
struct EmbeddingRequest {
model: String,
prompt: String,
}
#[derive(Debug, Deserialize)]
struct EmbeddingResponse {
embedding: Vec<f32>,
}
#[cfg(test)]
mod tests {
use super::EmbeddingResponse;
#[test]
fn parses_embedding_response() {
let value = serde_json::json!({"embedding": [0.1, 0.2, 0.3]});
let parsed: EmbeddingResponse = serde_json::from_value(value).expect("parse");
assert_eq!(parsed.embedding.len(), 3);
}
}

View file

@ -0,0 +1,275 @@
use crate::embeddings::provider::EmbeddingProvider;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
#[derive(Clone)]
pub struct OnnxProvider {
url: String,
model: String,
/// Standalone ONNX server directory (Windows path), if configured.
onnx_server_dir: Option<String>,
/// Windows path to source root (derived from BINDERY_SOURCE_ROOT), used as fallback.
source_root_win: Option<String>,
}
impl OnnxProvider {
pub fn new(url: String, model: String, source_root: Option<&Path>, onnx_server_dir: Option<String>) -> Self {
let source_root_win = source_root.and_then(|p| wsl_to_windows_path(p));
Self { url, model, onnx_server_dir, source_root_win }
}
fn embed_request(&self, input: &str) -> Result<Vec<f32>> {
// Auto-start server if not running
self.ensure_server_running()?;
let endpoint = format!("{}/embeddings", self.url.trim_end_matches('/'));
let body = EmbeddingRequest {
model: self.model.clone(),
input: EmbeddingInput::Single(input.to_string()),
};
let body_json = serde_json::to_string(&body)?;
let response: Result<ureq::Response, ureq::Error> = ureq::post(&endpoint)
.set("Content-Type", "application/json")
.timeout(Duration::from_secs(30))
.send_string(&body_json);
match response {
Ok(resp) => {
if resp.status() >= 400 {
return Err(anyhow!("ONNX embeddings failed: {}", resp.status()));
}
let text = resp.into_string().map_err(|e| anyhow!("ONNX response read failed: {e}"))?;
let parsed: EmbeddingResponse = serde_json::from_str(&text)?;
parsed.into_single_embedding()
}
Err(ureq::Error::Status(code, resp)) => {
let text = resp.into_string().unwrap_or_default();
Err(anyhow!("ONNX embeddings failed: {} {}", code, text))
}
Err(err) => Err(anyhow!("ONNX request failed: {err}")),
}
}
fn embed_batch_request(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>> {
self.ensure_server_running()?;
let endpoint = format!("{}/embeddings", self.url.trim_end_matches('/'));
let body = EmbeddingRequest {
model: self.model.clone(),
input: EmbeddingInput::Batch(inputs.iter().map(|s| s.to_string()).collect()),
};
let body_json = serde_json::to_string(&body)?;
// Longer timeout for batches
let response: Result<ureq::Response, ureq::Error> = ureq::post(&endpoint)
.set("Content-Type", "application/json")
.timeout(Duration::from_secs(120))
.send_string(&body_json);
match response {
Ok(resp) => {
if resp.status() >= 400 {
return Err(anyhow!("ONNX batch embeddings failed: {}", resp.status()));
}
let text = resp.into_string().map_err(|e| anyhow!("ONNX response read failed: {e}"))?;
let parsed: EmbeddingResponse = serde_json::from_str(&text)?;
parsed.into_batch_embeddings()
}
Err(ureq::Error::Status(code, resp)) => {
let text = resp.into_string().unwrap_or_default();
Err(anyhow!("ONNX batch embeddings failed: {} {}", code, text))
}
Err(err) => Err(anyhow!("ONNX batch request failed: {err}")),
}
}
fn ping(&self) -> bool {
let endpoint = format!("{}/health", self.url.trim_end_matches('/'));
let response: Result<ureq::Response, ureq::Error> = ureq::get(&endpoint)
.timeout(Duration::from_secs(5))
.call();
match response {
Ok(resp) => resp.status() >= 200 && resp.status() < 300,
Err(_) => false,
}
}
/// Ensure the ONNX server is running, spawning it via cmd.exe if needed.
fn ensure_server_running(&self) -> Result<()> {
if self.ping() {
return Ok(());
}
// Prefer standalone ONNX_SERVER_DIR, fall back to source_root/_src/scripts.
// Normalize forward slashes to backslashes so cmd.exe is happy regardless
// of how the path was written in .env.
let (script_dir, script_path) = if let Some(ref dir) = self.onnx_server_dir {
let dir = dir.replace('/', "\\");
let dir = dir.trim_end_matches('\\').to_string();
let path = format!(r"{}\start_onnx_server.cmd", dir);
(dir, path)
} else if let Some(ref win_root) = self.source_root_win {
let dir = format!(r"{}\_src\scripts", win_root);
let path = format!(r"{}\start_onnx_server.cmd", dir);
(dir, path)
} else {
return Err(anyhow!("ONNX server not running and neither BINDERY_ONNX_SERVER_DIR nor BINDERY_SOURCE_ROOT configured for auto-start"));
};
eprintln!("[onnx] Server not running, spawning: {}", script_path);
// Spawn via cmd.exe (WSL interop) - /d sets the working directory so
// relative paths inside the .cmd script (like .venv\) resolve correctly.
let result = Command::new("cmd.exe")
.args(["/c", "start", "/b", "/d", &script_dir, "", &script_path])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match &result {
Ok(_) => eprintln!("[onnx] Spawn succeeded, waiting for server..."),
Err(e) => eprintln!("[onnx] Spawn failed: {e}"),
}
if let Err(e) = result {
return Err(anyhow!("Failed to spawn ONNX server: {e}"));
}
// Poll until healthy (max 30s - reduced to avoid MCP timeout)
for i in 0..30 {
std::thread::sleep(Duration::from_secs(1));
eprintln!("[onnx] Health check attempt {}...", i + 1);
if self.ping() {
eprintln!("[onnx] Server ready after {}s", i + 1);
return Ok(());
}
}
eprintln!("[onnx] Server startup timeout (30s)");
Err(anyhow!("ONNX server startup timeout (30s)"))
}
}
/// Convert WSL path (/mnt/d/Source/MyRepo) to Windows path (D:\Source\MyRepo)
fn wsl_to_windows_path(path: &Path) -> Option<String> {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix("/mnt/") {
let mut chars = rest.chars();
let drive = chars.next()?.to_ascii_uppercase();
let remainder = chars.as_str();
// remainder starts with / or is empty
let win_path = format!("{}:{}", drive, remainder.replace('/', "\\"));
Some(win_path)
} else {
// Already a Windows path or not a mount path
Some(s.replace('/', "\\"))
}
}
impl EmbeddingProvider for OnnxProvider {
fn embed(&self, input: &str) -> Result<Vec<f32>> {
self.embed_request(input)
}
fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>> {
self.embed_batch_request(inputs)
}
fn is_available(&self) -> bool {
// Try auto-start if not running
self.ensure_server_running().is_ok()
}
fn model(&self) -> String {
self.model.clone()
}
fn backend(&self) -> String {
"onnx".to_string()
}
}
#[derive(Debug, Serialize)]
struct EmbeddingRequest {
model: String,
input: EmbeddingInput,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum EmbeddingInput {
Single(String),
Batch(Vec<String>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum EmbeddingResponse {
Single { embedding: Vec<f32> },
Batch { embeddings: Vec<Vec<f32>> },
Raw(()),
}
impl EmbeddingResponse {
fn into_single_embedding(self) -> Result<Vec<f32>> {
match self {
EmbeddingResponse::Single { embedding } => Ok(embedding),
EmbeddingResponse::Batch { embeddings } => embeddings
.into_iter()
.next()
.ok_or_else(|| anyhow!("ONNX embeddings missing")),
EmbeddingResponse::Raw(_) => Err(anyhow!("ONNX embeddings response missing embedding field")),
}
}
fn into_batch_embeddings(self) -> Result<Vec<Vec<f32>>> {
match self {
EmbeddingResponse::Single { embedding } => Ok(vec![embedding]),
EmbeddingResponse::Batch { embeddings } => Ok(embeddings),
EmbeddingResponse::Raw(_) => Err(anyhow!("ONNX embeddings response missing embeddings field")),
}
}
}
#[cfg(test)]
mod tests {
use super::{wsl_to_windows_path, EmbeddingResponse};
use std::path::Path;
#[test]
fn parses_single_embedding_response() {
let value = serde_json::json!({"embedding": [0.1, 0.2, 0.3]});
let parsed: EmbeddingResponse = serde_json::from_value(value).expect("parse");
let vector = parsed.into_single_embedding().expect("vector");
assert_eq!(vector.len(), 3);
}
#[test]
fn parses_batch_embedding_response() {
let value = serde_json::json!({"embeddings": [[0.1, 0.2]]});
let parsed: EmbeddingResponse = serde_json::from_value(value).expect("parse");
let vector = parsed.into_single_embedding().expect("vector");
assert_eq!(vector.len(), 2);
}
#[test]
fn converts_wsl_path_to_windows() {
let wsl = Path::new("/mnt/d/Source/MyRepo");
let win = wsl_to_windows_path(wsl).unwrap();
assert_eq!(win, r"D:\Source\MyRepo");
}
#[test]
fn converts_wsl_path_lowercase_drive() {
let wsl = Path::new("/mnt/c/Users/test");
let win = wsl_to_windows_path(wsl).unwrap();
assert_eq!(win, r"C:\Users\test");
}
#[test]
fn passes_through_windows_path() {
let win_in = Path::new("D:/Source/MyRepo");
let win = wsl_to_windows_path(win_in).unwrap();
assert_eq!(win, r"D:\Source\MyRepo");
}
}

View file

@ -0,0 +1,15 @@
use anyhow::Result;
pub trait EmbeddingProvider: Send + Sync {
fn embed(&self, input: &str) -> Result<Vec<f32>>;
/// Embed multiple texts in a single batch request.
/// Default implementation falls back to sequential single embeds.
fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>> {
inputs.iter().map(|s| self.embed(s)).collect()
}
fn is_available(&self) -> bool;
fn model(&self) -> String;
fn backend(&self) -> String;
}

262
mcp-rust/src/format.rs Normal file
View file

@ -0,0 +1,262 @@
//! Typography formatting for markdown files.
//!
//! This module converts straight quotes to curly quotes, `...` to ellipsis,
//! and `--` to em-dash while preserving content inside HTML comments.
//!
//! # Design Notes
//! - Regexes are compiled once using `once_cell::sync::Lazy` for performance
//! - HTML comments are protected during conversion to preserve their content
//! - The `---` sequence (markdown horizontal rule) is preserved
use anyhow::Result;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
// =============================================================================
// Cached Regex Patterns
// =============================================================================
// Using Lazy<Regex> avoids recompiling patterns on every function call.
// This significantly improves performance when processing many files.
/// Matches HTML comments: `<!-- ... -->`
/// Used to protect comment contents from typography conversion.
static COMMENT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"<!--[\s\S]*?-->").expect("Invalid COMMENT_RE pattern")
});
/// Matches opening double quote context: after whitespace, line start, or brackets
static OPEN_DOUBLE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(^|[\s\(\[\{—–\-])\""#).expect("Invalid OPEN_DOUBLE_RE pattern")
});
/// Matches opening single quote context: after whitespace, line start, or brackets
static OPEN_SINGLE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(^|[\s\(\[\{—–\-])'").expect("Invalid OPEN_SINGLE_RE pattern")
});
/// Matches a closing double quote after an em-dash at end-of-word or line
static CLOSE_DOUBLE_AFTER_EM_DASH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"—"([\s\)\]\}\.,;:!?]|$)"#).expect("Invalid CLOSE_DOUBLE_AFTER_EM_DASH_RE pattern")
});
// =============================================================================
// Typographic Characters
// =============================================================================
// Using string slices directly instead of char + .to_string() avoids allocations
const OPEN_DOUBLE: &str = "\u{201C}"; // "
const CLOSE_DOUBLE: &str = "\u{201D}"; // "
const OPEN_SINGLE: &str = "\u{2018}"; // '
const CLOSE_SINGLE: &str = "\u{2019}"; // ' (also used for apostrophes)
const ELLIPSIS: &str = "\u{2026}"; // …
const EM_DASH: &str = "\u{2014}"; // —
// =============================================================================
// Public API
// =============================================================================
/// Update typographic characters in text.
///
/// Performs the following conversions:
/// - `...` → `…` (ellipsis)
/// - `--` → `—` (em-dash, but not `---` which is markdown HR)
/// - `"text"` → `“text”` (curly double quotes)
/// - `'text'` → `text` (curly single quotes)
/// - `don't` → `dont` (apostrophes)
///
/// # Arguments
/// * `text` - The input text to convert
///
/// # Returns
/// A new string with typographic characters applied.
#[must_use]
pub fn update_typography(text: &str) -> String {
let mut result = text.to_string();
// Step 1: Convert ... to ellipsis (must happen before quote processing)
result = result.replace("...", ELLIPSIS);
// Step 2: Protect HTML comments from em-dash conversion
// We replace comments with placeholders, convert dashes, then restore
let mut protected_comments: Vec<String> = Vec::new();
result = COMMENT_RE.replace_all(&result, |caps: &regex::Captures| {
let placeholder = format!("\x00COMMENT{}\x00", protected_comments.len());
protected_comments.push(caps[0].to_string());
placeholder
}).to_string();
// Step 3: Convert -- to em-dash (but preserve --- for markdown HR)
// Strategy: temporarily protect ---, then convert --, then restore ---
let protected_triple = "\x00TRIPLE\x00";
result = result.replace("---", protected_triple);
result = result.replace("--", EM_DASH);
result = result.replace(protected_triple, "---");
// Step 4: Restore HTML comments
for (i, comment) in protected_comments.iter().enumerate() {
result = result.replace(&format!("\x00COMMENT{}\x00", i), comment);
}
// Step 4b: Fix closing quotes after em-dash introduced from --
result = CLOSE_DOUBLE_AFTER_EM_DASH_RE
.replace_all(&result, |caps: &regex::Captures| {
format!("{}{}{}", EM_DASH, CLOSE_DOUBLE, &caps[1])
})
.to_string();
// Step 5: Convert double quotes
// Opening: after whitespace, start of line, or opening brackets
result = OPEN_DOUBLE_RE.replace_all(&result, |caps: &regex::Captures| {
format!("{}{}", &caps[1], OPEN_DOUBLE)
}).to_string();
// Closing: all remaining straight double quotes
result = result.replace('"', CLOSE_DOUBLE);
// Step 6: Convert single quotes
// Opening: after whitespace, start of line, or opening brackets
result = OPEN_SINGLE_RE.replace_all(&result, |caps: &regex::Captures| {
format!("{}{}", &caps[1], OPEN_SINGLE)
}).to_string();
// Closing/apostrophe: all remaining straight single quotes
result = result.replace('\'', CLOSE_SINGLE);
result
}
/// Result of formatting a single file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatResult {
/// Path to the formatted file
pub path: String,
/// Whether the file content was modified
pub changed: bool,
}
/// Format typography in a single markdown file.
///
/// # Arguments
/// * `path` - Path to the markdown file
/// * `dry_run` - If true, report what would change without writing
///
/// # Errors
/// Returns an error if the file cannot be read or written.
pub fn format_file(path: &Path, dry_run: bool) -> Result<FormatResult> {
let content = fs::read_to_string(path)?;
let converted = update_typography(&content);
let changed = content != converted;
if changed && !dry_run {
fs::write(path, &converted)?;
}
Ok(FormatResult {
path: path.display().to_string(),
changed,
})
}
/// Format all markdown files in a directory.
///
/// # Arguments
/// * `dir` - Directory path to search for `.md` files
/// * `recurse` - If true, process subdirectories recursively
/// * `dry_run` - If true, report what would change without writing
///
/// # Errors
/// Returns an error if the directory cannot be read.
/// Individual file errors are logged but don't stop processing.
pub fn format_directory(dir: &Path, recurse: bool, dry_run: bool) -> Result<Vec<FormatResult>> {
let mut results = Vec::new();
let walker = if recurse {
WalkDir::new(dir)
} else {
WalkDir::new(dir).max_depth(1)
};
for entry in walker.into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
match format_file(path, dry_run) {
Ok(result) => results.push(result),
Err(e) => {
// Log warning but continue processing other files
tracing::warn!("Failed to format {}: {}", path.display(), e);
}
}
}
}
Ok(results)
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ellipsis() {
assert_eq!(update_typography("Wait... what?"), "Wait\u{2026} what?");
}
#[test]
fn test_em_dash() {
assert_eq!(update_typography("Hello--world"), "Hello\u{2014}world");
}
#[test]
fn test_em_dash_preserves_triple() {
// Triple dash (markdown HR) should NOT be converted
assert_eq!(update_typography("---"), "---");
}
#[test]
fn test_double_quotes() {
assert_eq!(update_typography(r#""Hello""#), "\u{201C}Hello\u{201D}");
assert_eq!(update_typography(r#"She said "hi""#), "She said \u{201C}hi\u{201D}");
}
#[test]
fn test_em_dash_before_closing_quote() {
assert_eq!(update_typography(r#""But--""#), "\u{201C}But\u{2014}\u{201D}");
assert_eq!(update_typography("\"But--\","), "\u{201C}But\u{2014}\u{201D},");
}
#[test]
fn test_single_quotes() {
assert_eq!(update_typography("'Hello'"), "\u{2018}Hello\u{2019}");
}
#[test]
fn test_apostrophe() {
// Apostrophes in contractions should use closing single quote
assert_eq!(update_typography("don't"), "don\u{2019}t");
assert_eq!(update_typography("Ren's"), "Ren\u{2019}s");
}
#[test]
fn test_html_comment_preserved() {
// Dashes inside HTML comments should NOT be converted
assert_eq!(
update_typography("<!-- comment with -- dash -->"),
"<!-- comment with -- dash -->"
);
}
#[test]
fn test_multiple_comments() {
// Multiple comments should all be preserved
let input = "text <!-- a -- b --> more <!-- c -- d --> end";
let output = update_typography(input);
assert_eq!(output, "text <!-- a -- b --> more <!-- c -- d --> end");
}
}

View file

@ -0,0 +1,79 @@
use crate::{Chunk, Candidate};
use anyhow::{Result, anyhow};
use std::path::Path;
use tantivy::{Index, schema::{TEXT, STORED, STRING, SchemaBuilder, TantivyDocument, Value}};
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
pub const LEXICAL_DIR: &str = "lexical";
pub fn build_lexical(index_dir: &Path, chunks: &[Chunk]) -> Result<()> {
let target_dir = index_dir.join(LEXICAL_DIR);
if target_dir.exists() {
std::fs::remove_dir_all(&target_dir)?;
}
std::fs::create_dir_all(&target_dir)?;
let mut schema_builder = SchemaBuilder::default();
let path_field = schema_builder.add_text_field("path", STRING | STORED);
let chunk_id_field = schema_builder.add_text_field("chunk_id", STRING | STORED);
let start_line_field = schema_builder.add_u64_field("start_line", STORED);
let end_line_field = schema_builder.add_u64_field("end_line", STORED);
let content_field = schema_builder.add_text_field("content", TEXT);
let schema = schema_builder.build();
let index = Index::create_in_dir(&target_dir, schema.clone())?;
let mut writer = index.writer(50_000_000)?;
for chunk in chunks {
let mut doc = TantivyDocument::default();
doc.add_text(path_field, &chunk.path);
doc.add_text(chunk_id_field, &chunk.id);
doc.add_u64(start_line_field, chunk.start_line as u64);
doc.add_u64(end_line_field, chunk.end_line as u64);
doc.add_text(content_field, &chunk.text);
writer.add_document(doc)?;
}
writer.commit()?;
Ok(())
}
pub fn search_lexical(index_dir: &Path, query: &str, top_k: usize) -> Result<Vec<Candidate>> {
let target_dir = index_dir.join(LEXICAL_DIR);
if !target_dir.exists() {
return Err(anyhow!("Lexical index missing"));
}
let index = Index::open_in_dir(&target_dir)?;
let schema = index.schema();
let path_field = schema.get_field("path").map_err(|e| anyhow!("Missing path field: {e}"))?;
let chunk_id_field = schema.get_field("chunk_id").map_err(|e| anyhow!("Missing chunk_id field: {e}"))?;
let start_line_field = schema.get_field("start_line").map_err(|e| anyhow!("Missing start_line field: {e}"))?;
let end_line_field = schema.get_field("end_line").map_err(|e| anyhow!("Missing end_line field: {e}"))?;
let content_field = schema.get_field("content").map_err(|e| anyhow!("Missing content field: {e}"))?;
let reader = index.reader()?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&index, vec![content_field]);
let query = query_parser.parse_query(query)?;
let top_docs = searcher.search(&query, &TopDocs::with_limit(top_k))?;
let mut results = Vec::new();
for (score, doc_address) in top_docs {
let retrieved: TantivyDocument = searcher.doc(doc_address)?;
let path = retrieved.get_first(path_field).and_then(|v| v.as_str()).unwrap_or("?").to_string();
let chunk_id = retrieved.get_first(chunk_id_field).and_then(|v| v.as_str()).unwrap_or("?").to_string();
let start_line = retrieved.get_first(start_line_field).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let end_line = retrieved.get_first(end_line_field).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
results.push(Candidate {
id: chunk_id,
path,
start_line,
end_line,
score: score as f32,
source: "lex".to_string(),
});
}
Ok(results)
}

View file

@ -0,0 +1,42 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexMeta {
pub schema_version: u32,
pub built_at: DateTime<Utc>,
pub work_root: String,
pub index_dir: String,
pub language: String,
pub scope: String,
pub act: Option<String>,
pub chapter_range: Option<String>,
pub embeddings_backend: String,
pub embeddings_model: String,
pub embeddings_dim: Option<usize>,
pub chunking: ChunkingMeta,
pub chunk_count: usize,
pub config_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkingMeta {
pub strategy: String,
pub min_lines: u32,
pub max_lines: u32,
}
pub fn write_meta(path: &Path, meta: &IndexMeta) -> Result<()> {
let json = serde_json::to_string_pretty(meta)?;
fs::write(path, json)?;
Ok(())
}
pub fn read_meta(path: &Path) -> Result<IndexMeta> {
let data = fs::read_to_string(path)?;
let meta = serde_json::from_str(&data)?;
Ok(meta)
}

View file

@ -0,0 +1,3 @@
pub mod lexical;
pub mod vector;
pub mod meta;

View file

@ -0,0 +1,110 @@
use crate::{Chunk, ChunkMeta};
use crate::embeddings::provider::EmbeddingProvider;
use anyhow::{Result, anyhow};
use hnsw_rs::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
pub const VECTOR_DIR: &str = "vector";
const VECTORS_FILE: &str = "vectors.json";
const META_FILE: &str = "vector_meta.json";
#[derive(Debug, Serialize, Deserialize)]
struct VectorStore {
dim: usize,
vectors: Vec<Vec<f32>>,
}
pub fn build_vector(index_dir: &Path, chunks: &[Chunk], provider: &dyn EmbeddingProvider, batch_size: usize) -> Result<Option<usize>> {
if !provider.is_available() {
return Ok(None);
}
let batch_size = batch_size.max(1); // Ensure at least 1
let mut vectors = Vec::with_capacity(chunks.len());
let mut dim: Option<usize> = None;
let mut metas = Vec::with_capacity(chunks.len());
// Process in batches for better throughput
let texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect();
for batch_start in (0..texts.len()).step_by(batch_size) {
let batch_end = (batch_start + batch_size).min(texts.len());
let batch = &texts[batch_start..batch_end];
let batch_vecs = provider.embed_batch(batch)?;
for (i, vec) in batch_vecs.into_iter().enumerate() {
let chunk_idx = batch_start + i;
if let Some(expected) = dim {
if vec.len() != expected {
return Err(anyhow!("Embedding dim mismatch at {chunk_idx}: expected {expected}, got {}", vec.len()));
}
} else {
dim = Some(vec.len());
}
vectors.push(vec);
metas.push(ChunkMeta {
id: chunks[chunk_idx].id.clone(),
path: chunks[chunk_idx].path.clone(),
start_line: chunks[chunk_idx].start_line,
end_line: chunks[chunk_idx].end_line,
});
}
}
let dim = dim.unwrap_or(0);
let store = VectorStore { dim, vectors };
let target_dir = index_dir.join(VECTOR_DIR);
if target_dir.exists() {
fs::remove_dir_all(&target_dir)?;
}
fs::create_dir_all(&target_dir)?;
fs::write(target_dir.join(VECTORS_FILE), serde_json::to_vec(&store)?)?;
fs::write(target_dir.join(META_FILE), serde_json::to_vec(&metas)?)?;
Ok(Some(dim))
}
pub fn load_vector_meta(index_dir: &Path) -> Result<Vec<ChunkMeta>> {
let meta_path = index_dir.join(VECTOR_DIR).join(META_FILE);
let data = fs::read(meta_path)?;
let meta = serde_json::from_slice(&data)?;
Ok(meta)
}
pub fn search_vector(index_dir: &Path, query_vec: &[f32], top_k: usize) -> Result<Vec<(usize, f32)>> {
let store_path = index_dir.join(VECTOR_DIR).join(VECTORS_FILE);
if !store_path.exists() {
return Err(anyhow!("Vector store missing"));
}
let data = fs::read(store_path)?;
let store: VectorStore = serde_json::from_slice(&data)?;
if store.dim != query_vec.len() {
return Err(anyhow!("Query dim mismatch: expected {}, got {}", store.dim, query_vec.len()));
}
let hnsw = build_hnsw(&store.vectors);
let result = hnsw.search(query_vec, top_k, 100);
let mut hits = Vec::new();
for nb in result {
hits.push((nb.d_id, nb.distance));
}
Ok(hits)
}
fn build_hnsw(vectors: &[Vec<f32>]) -> Hnsw<'_, f32, DistCosine> {
let max_nb_conn = 16;
let nb_elements = vectors.len().max(1);
let nb_layers = 16;
let ef_c = 200;
let hnsw = Hnsw::<f32, DistCosine>::new(max_nb_conn, nb_elements, nb_layers, ef_c, DistCosine {});
for (idx, vec) in vectors.iter().enumerate() {
hnsw.insert((&vec[..], idx));
}
hnsw
}

47
mcp-rust/src/lib.rs Normal file
View file

@ -0,0 +1,47 @@
pub mod config;
pub mod tools;
pub mod docstore;
pub mod index;
pub mod embeddings;
pub mod retrieve;
pub mod format;
pub mod merge;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chunk {
pub id: String,
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkMeta {
pub id: String,
pub path: String,
pub start_line: u32,
pub end_line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Candidate {
pub id: String,
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub score: f32,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TimingMs {
pub embed_query: Option<u128>,
pub lexical: Option<u128>,
pub vector: Option<u128>,
pub rerank: Option<u128>,
pub snippets: Option<u128>,
pub total: Option<u128>,
}

445
mcp-rust/src/main.rs Normal file
View file

@ -0,0 +1,445 @@
use async_trait::async_trait;
use rust_mcp_sdk::{
error::SdkResult,
macros::{mcp_tool, JsonSchema},
mcp_server::{server_runtime, McpServerOptions, ServerHandler},
schema::{
CallToolError, CallToolRequestParams, CallToolResult, Implementation,
InitializeResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
RpcError, ServerCapabilities, ServerCapabilitiesTools, TextContent,
},
McpServer, StdioTransport, ToMcpServerHandler, TransportOptions,
};
use rust_mcp_sdk::tool_box;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::sync::{Arc, Mutex};
use tracing_subscriber::{self, EnvFilter};
use bindery_mcp::{
config::Config,
embeddings,
tools,
TimingMs,
};
// ----------------------------------------------------------------------------
// Tool Definitions
// ----------------------------------------------------------------------------
#[mcp_tool(name = "health", description = "Return health and diagnostics for the MCP server")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct HealthTool {}
#[mcp_tool(name = "sync_workspace", description = "Deprecated: use index_build (sync is built-in)")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SyncWorkspaceTool {
#[serde(default)]
pub direction: Option<String>,
#[serde(default)]
pub paths: Option<Vec<String>>,
#[serde(default)]
pub delete: Option<bool>,
#[serde(default)]
pub dry_run: Option<bool>,
}
fn default_index_language() -> String { "ALL".to_string() }
#[mcp_tool(name = "index_build", description = "Build or rebuild lexical/vector indices for all languages (EN+NL). Set background=true to run async and get a task_id.")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct IndexBuildTool {
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub act: Option<String>,
#[serde(default)]
pub chapter_range: Option<String>,
#[serde(default)]
pub force_rebuild: Option<bool>,
#[serde(default)]
pub require_synced: Option<bool>,
#[serde(default)]
pub sync_paths: Option<Vec<String>>,
#[serde(default)]
pub background: Option<bool>,
}
#[mcp_tool(name = "index_status", description = "Return index status and metadata")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct IndexStatusTool {}
#[mcp_tool(name = "retrieve_context", description = "Hybrid retrieval (BM25 + vector) for story context. Use language=EN, NL, or ALL.")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RetrieveContextTool {
pub query: String,
#[serde(default = "default_index_language")]
pub language: String,
#[serde(default)]
pub top_k: Option<u32>,
#[serde(default)]
pub act: Option<String>,
#[serde(default)]
pub chapter_range: Option<String>,
}
#[mcp_tool(name = "get_text", description = "Read text by identifier from the source repo (mount)")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetTextTool {
pub language: String,
pub identifier: String,
#[serde(default)]
pub start_line: Option<u32>,
#[serde(default)]
pub end_line: Option<u32>,
}
#[mcp_tool(name = "get_review_text", description = "Return git diff from the source repo (EN/NL/ALL)")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetReviewTextTool {
#[serde(default = "default_review_language")]
pub language: String,
#[serde(default)]
pub context_lines: Option<u32>,
}
fn default_review_language() -> String { "ALL".to_string() }
#[mcp_tool(name = "get_chapter", description = "Get a full chapter by number and language")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetChapterTool {
pub chapter_number: u32,
pub language: String,
}
#[mcp_tool(name = "get_overview", description = "Get an overview of acts and chapters")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetOverviewTool {
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub act: Option<u32>,
}
#[mcp_tool(name = "get_notes", description = "Get an entry from Notes/Details_Notes.md")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct GetNotesTool {
pub category: String,
pub name: String,
#[serde(default)]
pub match_index: Option<u32>,
}
#[mcp_tool(name = "format", description = "Format typography in markdown files under the source repo")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct FormatTool {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub no_recurse: Option<bool>,
#[serde(default)]
pub dry_run: Option<bool>,
}
#[mcp_tool(name = "merge", description = "Merge markdown chapters into a book, optionally exporting DOCX/EPUB")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct MergeTool {
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_output_type")]
pub output_type: String,
#[serde(default)]
pub root: Option<String>,
}
fn default_language() -> String { "EN".to_string() }
fn default_output_type() -> String { "md".to_string() }
#[mcp_tool(name = "search", description = "Literal or regex search under the source repo (mount)")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SearchTool {
pub query: String,
#[serde(default)]
pub regex: Option<bool>,
#[serde(default)]
pub case_sensitive: Option<bool>,
#[serde(default)]
pub max_results: Option<u32>,
}
#[mcp_tool(name = "task_status", description = "Check status of background tasks")]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct TaskStatusTool {
#[serde(default)]
pub task_id: Option<String>,
}
/// Local input struct for index_build with background option
#[derive(Debug, Deserialize)]
struct IndexBuildInput {
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub act: Option<String>,
#[serde(default)]
pub chapter_range: Option<String>,
#[serde(default)]
pub force_rebuild: Option<bool>,
#[serde(default)]
pub require_synced: Option<bool>,
#[serde(default)]
pub sync_paths: Option<Vec<String>>,
#[serde(default)]
pub background: Option<bool>,
}
tool_box!(BinderyTools, [
HealthTool,
SyncWorkspaceTool,
IndexBuildTool,
IndexStatusTool,
RetrieveContextTool,
GetTextTool,
GetReviewTextTool,
GetChapterTool,
GetOverviewTool,
GetNotesTool,
SearchTool,
FormatTool,
MergeTool,
TaskStatusTool
]);
// ----------------------------------------------------------------------------
// Handler
// ----------------------------------------------------------------------------
#[derive(Clone)]
struct BinderyHandler {
config: Arc<Config>,
provider: Arc<dyn embeddings::provider::EmbeddingProvider>,
last_retrieve: Arc<Mutex<Option<TimingMs>>>,
task_manager: tools::tasks::TaskManager,
}
#[async_trait]
impl ServerHandler for BinderyHandler {
async fn handle_list_tools_request(
&self,
_request: Option<PaginatedRequestParams>,
_runtime: Arc<dyn McpServer>,
) -> Result<ListToolsResult, RpcError> {
Ok(ListToolsResult {
tools: BinderyTools::tools(),
meta: None,
next_cursor: None,
})
}
async fn handle_call_tool_request(
&self,
params: CallToolRequestParams,
_runtime: Arc<dyn McpServer>,
) -> Result<CallToolResult, CallToolError> {
match params.name.as_str() {
"health" => {
let result = tools::health::health(&self.config, self.provider.as_ref(), self.last_retrieve.clone())
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"sync_workspace" => {
let input: tools::sync_workspace::SyncInput = parse_args("sync_workspace", params.arguments)?;
let result = tools::sync_workspace::sync_workspace(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"index_build" => {
let input: IndexBuildInput = parse_args("index_build", params.arguments)?;
let background = input.background.unwrap_or(false);
if background {
let task_id = self.task_manager.create_task("index_build");
let config = self.config.clone();
let provider = self.provider.clone();
let manager = self.task_manager.clone();
let tid = task_id.clone();
let build_input = tools::index_build::IndexBuildInput {
scope: input.scope,
act: input.act,
chapter_range: input.chapter_range,
force_rebuild: input.force_rebuild,
require_synced: input.require_synced,
sync_paths: input.sync_paths,
};
std::thread::spawn(move || {
match tools::index_build::index_build(&config, provider.as_ref(), build_input) {
Ok(result) => {
let json = serde_json::to_value(&result).unwrap_or_default();
manager.complete_task(&tid, json);
}
Err(e) => {
manager.fail_task(&tid, e.to_string());
}
}
});
#[derive(Serialize)]
struct BackgroundResult { task_id: String, message: String }
let result = BackgroundResult {
task_id,
message: "Index build started in background. Use task_status to check progress.".to_string(),
};
Ok(json_result(&result)?)
} else {
let build_input = tools::index_build::IndexBuildInput {
scope: input.scope,
act: input.act,
chapter_range: input.chapter_range,
force_rebuild: input.force_rebuild,
require_synced: input.require_synced,
sync_paths: input.sync_paths,
};
let result = tools::index_build::index_build(&self.config, self.provider.as_ref(), build_input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
}
"index_status" => {
let _input: tools::index_status::IndexStatusInput = parse_args("index_status", params.arguments)?;
let result = tools::index_status::index_status(&self.config)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"retrieve_context" => {
let input: tools::retrieve_context::RetrieveContextInput = parse_args("retrieve_context", params.arguments)?;
let result = tools::retrieve_context::retrieve_context(
&self.config,
self.provider.as_ref(),
input,
self.last_retrieve.clone(),
).map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"get_text" => {
let input: tools::get_text::GetTextInput = parse_args("get_text", params.arguments)?;
let result = tools::get_text::get_text(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"get_review_text" => {
let input: tools::get_review_text::GetReviewTextInput = parse_args("get_review_text", params.arguments)?;
let result = tools::get_review_text::get_review_text(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"get_chapter" => {
let input: tools::get_chapter::GetChapterInput = parse_args("get_chapter", params.arguments)?;
let result = tools::get_chapter::get_chapter(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"get_overview" => {
let input: tools::get_overview::GetOverviewInput = parse_args("get_overview", params.arguments)?;
let result = tools::get_overview::get_overview(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"get_notes" => {
let input: tools::get_notes::GetNotesInput = parse_args("get_notes", params.arguments)?;
let result = tools::get_notes::get_notes(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"search" => {
let input: tools::search::SearchInput = parse_args("search", params.arguments)?;
let result = tools::search::search(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"format" => {
let input: tools::format::FormatInput = parse_args("format", params.arguments)?;
let result = tools::format::format_markdown(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"merge" => {
let input: tools::merge::MergeInput = parse_args("merge", params.arguments)?;
let result = tools::merge::merge_book(&self.config, input)
.map_err(|e| CallToolError::from_message(e.to_string()))?;
Ok(json_result(&result)?)
}
"task_status" => {
let input: tools::tasks::TaskStatusInput = parse_args("task_status", params.arguments)?;
let result = tools::tasks::task_status(&self.task_manager, input);
Ok(json_result(&result)?)
}
_ => Err(CallToolError::unknown_tool(params.name)),
}
}
}
fn json_result<T: Serialize>(value: &T) -> Result<CallToolResult, CallToolError> {
let mut map = Map::new();
map.insert("result".to_string(), serde_json::to_value(value).map_err(|e| CallToolError::from_message(e.to_string()))?);
Ok(CallToolResult::text_content(vec![TextContent::new("ok".to_string(), None, None)]).with_structured_content(map))
}
fn parse_args<T: for<'de> Deserialize<'de>>(tool_name: &str, args: Option<Map<String, Value>>) -> Result<T, CallToolError> {
let value = Value::Object(args.unwrap_or_else(Map::new));
serde_json::from_value(value).map_err(|e| CallToolError::invalid_arguments(tool_name, Some(e.to_string())))
}
#[tokio::main]
async fn main() -> SdkResult<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let config = Config::from_env().map_err(|e| RpcError::internal_error().with_message(e.to_string()))?;
let provider = embeddings::build_provider(&config).map_err(|e| RpcError::internal_error().with_message(e.to_string()))?;
eprintln!("bindery-mcp | source={} | work={} | index={} | embeddings={}({})",
config.source_root.to_string_lossy(),
config.work_root.to_string_lossy(),
config.index_dir.to_string_lossy(),
provider.backend(),
provider.model()
);
let handler = BinderyHandler {
config: Arc::new(config),
provider,
last_retrieve: Arc::new(Mutex::new(None)),
task_manager: tools::tasks::TaskManager::new(),
};
let server_info = InitializeResult {
server_info: Implementation {
name: "bindery-mcp".into(),
version: "0.2.0".into(),
title: Some("Bindery MCP".into()),
description: Some("Bindery MCP server (hybrid retrieval + Ollama or ONNX embeddings, multi-language book export)".into()),
icons: vec![],
website_url: None,
},
capabilities: ServerCapabilities {
tools: Some(ServerCapabilitiesTools { list_changed: None }),
..Default::default()
},
protocol_version: ProtocolVersion::V2025_11_25.into(),
instructions: None,
meta: None,
};
let transport = StdioTransport::new(TransportOptions::default())?;
let options = McpServerOptions {
server_details: server_info,
transport,
handler: handler.to_mcp_server_handler(),
task_store: None,
client_task_store: None,
};
let server = server_runtime::create_server(options);
server.start().await
}

1579
mcp-rust/src/merge.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
use crate::{Candidate, ChunkMeta};
use std::collections::HashMap;
use crate::retrieve::normalize::min_max_normalize;
pub fn merge_and_rerank(
lex: &[Candidate],
vec: &[(ChunkMeta, f32)],
weight_lex: f32,
weight_vec: f32,
) -> Vec<Candidate> {
let mut by_id: HashMap<String, Candidate> = HashMap::new();
for c in lex {
by_id.entry(c.id.clone()).or_insert_with(|| c.clone());
}
for (meta, _) in vec {
by_id.entry(meta.id.clone()).or_insert_with(|| Candidate {
id: meta.id.clone(),
path: meta.path.clone(),
start_line: meta.start_line,
end_line: meta.end_line,
score: 0.0,
source: "vec".to_string(),
});
}
let lex_scores: Vec<(String, f32)> = lex.iter().map(|c| (c.id.clone(), c.score)).collect();
let vec_scores: Vec<(String, f32)> = vec.iter().map(|(m, s)| (m.id.clone(), *s)).collect();
let lex_norm = min_max_normalize(&lex_scores);
let vec_norm = min_max_normalize(&vec_scores);
let mut merged = Vec::new();
for (id, mut base) in by_id {
let l = lex_norm.get(&id).copied().unwrap_or(0.0);
let v = vec_norm.get(&id).copied().unwrap_or(0.0);
let score = if l > 0.0 && v > 0.0 {
weight_lex * l + weight_vec * v
} else if l > 0.0 {
l
} else {
v
};
base.score = score;
base.source = if l > 0.0 && v > 0.0 { "hybrid".to_string() } else if l > 0.0 { "lex".to_string() } else { "vec".to_string() };
merged.push(base);
}
merged.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
merged
}

View file

@ -0,0 +1,171 @@
pub mod hybrid;
pub mod normalize;
use crate::{ChunkMeta, TimingMs};
use crate::config::Config;
use crate::docstore::read::read_lines_string;
use crate::embeddings::provider::EmbeddingProvider;
use crate::index::lexical::search_lexical;
use crate::index::vector::{load_vector_meta, search_vector, VECTOR_DIR};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrieveInput {
pub query: String,
pub language: String,
pub top_k: usize,
pub act: Option<String>,
pub chapter_range: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrieveResult {
pub results: Vec<RetrieveHit>,
pub truncated: bool,
pub timing_ms: TimingMs,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrieveHit {
pub path: String,
pub start_line: u32,
pub end_line: u32,
pub score: f32,
pub text: String,
pub source: String,
pub source_ref: Option<String>,
}
pub fn retrieve_context(config: &Config, provider: &dyn EmbeddingProvider, input: RetrieveInput) -> Result<RetrieveResult> {
let total_start = Instant::now();
let mut timing = TimingMs::default();
let mut warnings = Vec::new();
// Normalize language to uppercase
let lang = input.language.to_uppercase();
let lang_filter: Option<&str> = if lang == "ALL" {
None
} else {
Some(lang.as_str())
};
// Increase search count when filtering to ensure enough results
let search_top = if lang_filter.is_some() { 24 } else { 12 };
let lex_start = Instant::now();
let lexical = match search_lexical(&config.index_dir, &input.query, search_top) {
Ok(v) => v,
Err(err) => {
warnings.push(format!("lexical search failed: {err}"));
Vec::new()
}
};
timing.lexical = Some(lex_start.elapsed().as_millis());
let mut vector_hits: Vec<(ChunkMeta, f32)> = Vec::new();
let vector_path = config.index_dir.join(VECTOR_DIR);
if vector_path.exists() && provider.is_available() {
let embed_start = Instant::now();
match provider.embed(&input.query) {
Ok(query_vec) => {
timing.embed_query = Some(embed_start.elapsed().as_millis());
let vec_start = Instant::now();
match search_vector(&config.index_dir, &query_vec, search_top) {
Ok(raw_hits) => {
let meta = load_vector_meta(&config.index_dir).unwrap_or_default();
for (idx, distance) in raw_hits {
if let Some(meta) = meta.get(idx) {
let sim = (1.0 - distance).max(-1.0);
vector_hits.push((meta.clone(), sim));
}
}
}
Err(err) => warnings.push(format!("vector search failed: {err}")),
}
timing.vector = Some(vec_start.elapsed().as_millis());
}
Err(err) => warnings.push(format!("embed query failed: {err}")),
}
} else if !vector_path.exists() {
warnings.push("vector index missing".to_string());
} else {
warnings.push("embeddings backend not available".to_string());
}
let rerank_start = Instant::now();
let mut merged = hybrid::merge_and_rerank(&lexical, &vector_hits, 0.6, 0.4);
// Filter by language if specified
// Only filter paths that are language-specific (contain /EN/ or /NL/)
// Keep Notes/, Story/Details_*, and other non-language-specific files
if let Some(lang) = lang_filter {
let lang_pattern = format!("/{}/", lang);
let other_lang_patterns: Vec<String> = ["EN", "NL"]
.iter()
.filter(|&&l| l != lang)
.map(|l| format!("/{}/", l))
.collect();
merged.retain(|hit| {
// If path contains the requested language folder, keep it
if hit.path.contains(&lang_pattern) {
return true;
}
// If path contains another language folder, exclude it
for pattern in &other_lang_patterns {
if hit.path.contains(pattern) {
return false;
}
}
// Keep language-neutral files (Notes/, Story/Details_*, etc.)
true
});
}
if input.top_k < merged.len() {
merged.truncate(input.top_k);
}
timing.rerank = Some(rerank_start.elapsed().as_millis());
let snippets_start = Instant::now();
let mut hits = Vec::new();
let mut total_bytes: usize = 0;
let mut truncated = false;
for cand in merged {
let path = config.work_root.join(&cand.path);
let text = read_lines_string(&path, cand.start_line, cand.end_line).unwrap_or_else(|_| "".to_string());
let text = if text.len() > config.snippet_max_chars {
text.chars().take(config.snippet_max_chars).collect::<String>()
} else {
text
};
let bytes = text.as_bytes().len();
if total_bytes + bytes > config.max_response_bytes {
truncated = true;
break;
}
total_bytes += bytes;
let source_ref = Some(format!("{}:{}", config.source_root.join(&cand.path).to_string_lossy(), cand.start_line));
hits.push(RetrieveHit {
path: cand.path,
start_line: cand.start_line,
end_line: cand.end_line,
score: cand.score,
text,
source: cand.source,
source_ref,
});
}
timing.snippets = Some(snippets_start.elapsed().as_millis());
timing.total = Some(total_start.elapsed().as_millis());
Ok(RetrieveResult {
results: hits,
truncated,
timing_ms: timing,
warnings,
})
}

View file

@ -0,0 +1,20 @@
use std::collections::HashMap;
pub fn min_max_normalize(values: &[(String, f32)]) -> HashMap<String, f32> {
let mut map = HashMap::new();
if values.is_empty() {
return map;
}
let mut min = f32::MAX;
let mut max = f32::MIN;
for (_, v) in values {
if *v < min { min = *v; }
if *v > max { max = *v; }
}
let denom = if (max - min).abs() < f32::EPSILON { 1.0 } else { max - min };
for (k, v) in values {
let norm = if denom == 1.0 && (max - min).abs() < f32::EPSILON { 1.0 } else { (*v - min) / denom };
map.insert(k.clone(), norm);
}
map
}

View file

@ -0,0 +1,61 @@
use crate::{config::Config, format as format_core};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatInput {
pub path: Option<String>,
pub no_recurse: Option<bool>,
pub dry_run: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatOutput {
pub root: String,
pub results: Vec<format_core::FormatResult>,
pub changed: u32,
pub total: u32,
pub dry_run: bool,
}
pub fn format_markdown(config: &Config, input: FormatInput) -> Result<FormatOutput> {
let dry_run = input.dry_run.unwrap_or(false);
let no_recurse = input.no_recurse.unwrap_or(false);
let target = resolve_path(config, input.path.as_deref())?;
let results = if target.is_dir() {
format_core::format_directory(&target, !no_recurse, dry_run)?
} else if target.is_file() {
vec![format_core::format_file(&target, dry_run)?]
} else {
return Err(anyhow!("Path does not exist: {}", target.display()));
};
let changed = results.iter().filter(|r| r.changed).count() as u32;
let total = results.len() as u32;
Ok(FormatOutput {
root: target.display().to_string(),
results,
changed,
total,
dry_run,
})
}
fn resolve_path(config: &Config, path: Option<&str>) -> Result<PathBuf> {
let base = config.source_root.clone();
match path {
Some(p) => {
let candidate = PathBuf::from(p);
if candidate.is_absolute() {
Ok(candidate)
} else {
Ok(base.join(candidate))
}
}
None => Ok(base),
}
}

View file

@ -0,0 +1,106 @@
use crate::config::Config;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetChapterInput {
pub chapter_number: u32,
pub language: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetChapterResult {
pub file: String,
pub chapter_number: u32,
pub language: String,
pub text: String,
}
pub fn get_chapter(config: &Config, input: GetChapterInput) -> Result<GetChapterResult> {
if input.chapter_number == 0 {
return Err(anyhow!("chapter_number is required"));
}
let language = input.language.trim().to_uppercase();
if language != "EN" && language != "NL" {
return Err(anyhow!("language must be EN or NL"));
}
let root = config.source_root.join("Story").join(&language);
let filename = format!("Chapter{}.md", input.chapter_number);
let mut found: Option<PathBuf> = None;
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Some(name) = entry.file_name().to_str() {
if name.eq_ignore_ascii_case(&filename) {
found = Some(entry.path().to_path_buf());
break;
}
}
}
}
let path = found.ok_or_else(|| anyhow!("Chapter not found: {} ({})", input.chapter_number, language))?;
let text = std::fs::read_to_string(&path)?;
Ok(GetChapterResult {
file: rel_from_root(&config.source_root, &path),
chapter_number: input.chapter_number,
language,
text,
})
}
fn rel_from_root(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn get_chapter_finds_file() {
let dir = tempdir().unwrap();
let root = dir.path().join("Story/EN/Act I - Awakening");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("Chapter1.md");
std::fs::write(&file, "# Chapter 1\n\nHello").unwrap();
let config = Config {
source_root: dir.path().to_path_buf(),
work_root: dir.path().to_path_buf(),
index_dir: dir.path().join(".bindery/index"),
mcp_mirror_root: None,
embeddings_backend: "none".to_string(),
ollama_url: "http://127.0.0.1:11434".to_string(),
ollama_model: "nomic-embed-text".to_string(),
onnx_url: "http://127.0.0.1:11435".to_string(),
onnx_model: "bge-m3".to_string(),
sync_delete_default: false,
max_response_bytes: 60000,
snippet_max_chars: 1600,
default_topk: 6,
embed_batch_size: 32,
author: None,
};
let result = get_chapter(
&config,
GetChapterInput {
chapter_number: 1,
language: "EN".to_string(),
},
).unwrap();
assert!(result.text.contains("Hello"));
assert!(result.file.contains("Story/EN"));
}
}

View file

@ -0,0 +1,140 @@
use crate::config::Config;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
const NOTES_FILE: &str = "Notes/Details_Notes.md";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetNotesInput {
pub category: String,
pub name: String,
pub match_index: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetNotesResult {
pub file: String,
pub category: String,
pub name: String,
pub text: String,
}
pub fn get_notes(config: &Config, input: GetNotesInput) -> Result<GetNotesResult> {
if input.category.trim().is_empty() || input.name.trim().is_empty() {
return Err(anyhow!("category and name are required"));
}
let path = config.source_root.join(NOTES_FILE);
let content = std::fs::read_to_string(&path)?;
let lines: Vec<&str> = content.lines().collect();
let category = input.category.trim();
let name = input.name.trim();
let match_index = input.match_index.unwrap_or(1).max(1) as usize;
let category_range = find_heading_range(&lines, 2, category)
.ok_or_else(|| anyhow!("category not found"))?;
let name_indices = find_heading_indices_in_range(&lines, 3, name, category_range.0, category_range.1);
if name_indices.len() < match_index {
return Err(anyhow!("name not found"));
}
let start = name_indices[match_index - 1] + 1;
let end = find_next_heading(&lines, 3, start, category_range.1).unwrap_or(category_range.1);
let text = lines[start..end].join("\n").trim().to_string();
Ok(GetNotesResult {
file: NOTES_FILE.to_string(),
category: category.to_string(),
name: name.to_string(),
text,
})
}
fn find_heading_range(lines: &[&str], level: usize, heading: &str) -> Option<(usize, usize)> {
let prefix = "#".repeat(level) + " ";
let mut start = None;
for (idx, line) in lines.iter().enumerate() {
if line.trim_start().starts_with(&prefix) {
let title = line.trim_start().trim_start_matches(&prefix).trim();
if start.is_some() {
return start.map(|s| (s, idx));
}
if title.eq_ignore_ascii_case(heading) {
start = Some(idx + 1);
}
}
}
start.map(|s| (s, lines.len()))
}
fn find_heading_indices_in_range(lines: &[&str], level: usize, heading: &str, start: usize, end: usize) -> Vec<usize> {
let prefix = "#".repeat(level) + " ";
let mut indices = Vec::new();
for (idx, line) in lines.iter().enumerate().take(end).skip(start) {
if line.trim_start().starts_with(&prefix) {
let title = line.trim_start().trim_start_matches(&prefix).trim();
if title.eq_ignore_ascii_case(heading) {
indices.push(idx);
}
}
}
indices
}
fn find_next_heading(lines: &[&str], level: usize, start: usize, end: usize) -> Option<usize> {
let prefix = "#".repeat(level) + " ";
for (idx, line) in lines.iter().enumerate().take(end).skip(start) {
if line.trim_start().starts_with(&prefix) {
return Some(idx);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn get_notes_returns_text() {
let dir = tempdir().unwrap();
let notes_path = dir.path().join(NOTES_FILE);
std::fs::create_dir_all(notes_path.parent().unwrap()).unwrap();
std::fs::write(
&notes_path,
"## Characters\n\n### Ren\nA short note.\n",
).unwrap();
let config = Config {
source_root: dir.path().to_path_buf(),
work_root: dir.path().to_path_buf(),
index_dir: dir.path().join(".bindery/index"),
mcp_mirror_root: None,
embeddings_backend: "none".to_string(),
ollama_url: "http://127.0.0.1:11434".to_string(),
ollama_model: "nomic-embed-text".to_string(),
onnx_url: "http://127.0.0.1:11435".to_string(),
onnx_model: "bge-m3".to_string(),
sync_delete_default: false,
max_response_bytes: 60000,
snippet_max_chars: 1600,
default_topk: 6,
embed_batch_size: 32,
author: None,
};
let result = get_notes(
&config,
GetNotesInput {
category: "Characters".to_string(),
name: "Ren".to_string(),
match_index: None,
},
).unwrap();
assert!(result.text.contains("A short note."));
}
}

View file

@ -0,0 +1,243 @@
use crate::config::Config;
use crate::tools::get_chapter::get_chapter;
use crate::tools::get_chapter::GetChapterInput;
use anyhow::{Result, anyhow};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
const ACT_FILES: &[(u32, &str)] = &[
(1, "Arc/Act_I_Awakening.md"),
(2, "Arc/Act_II_Resonance.md"),
(3, "Arc/Act_III_Collapse.md"),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetOverviewInput {
pub language: Option<String>,
pub act: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetOverviewResult {
pub acts: Vec<ActOverview>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActOverview {
pub act_number: u32,
pub act_title: String,
pub chapters: Vec<ChapterOverview>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChapterOverview {
pub chapter_number: u32,
pub title: String,
pub pov: Option<String>,
pub synopsis: Option<String>,
pub status: ChapterStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChapterStatus {
pub en: Option<String>,
pub nl: Option<String>,
}
pub fn get_overview(config: &Config, input: GetOverviewInput) -> Result<GetOverviewResult> {
if let Some(act) = input.act {
if !(1..=3).contains(&act) {
return Err(anyhow!("act must be 1-3"));
}
}
let language_filter = input.language.as_ref().map(|value| value.trim().to_uppercase());
if let Some(lang) = &language_filter {
if lang != "EN" && lang != "NL" {
return Err(anyhow!("language must be EN or NL"));
}
}
let chapter_header = Regex::new(r"(?i)^###\s*Chapter\s+(\d+)\s*(?:-\s*(.+))?$")?;
let mut acts = Vec::new();
for (act_number, rel_path) in ACT_FILES {
if let Some(filter_act) = input.act {
if *act_number != filter_act {
continue;
}
}
let path = config.source_root.join(rel_path);
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)?;
let act_title = extract_act_title(&content).unwrap_or_else(|| default_act_title(rel_path));
let chapters = parse_chapters(&content, &chapter_header, config, language_filter.as_deref());
acts.push(ActOverview {
act_number: *act_number,
act_title,
chapters,
});
}
Ok(GetOverviewResult { acts })
}
fn extract_act_title(content: &str) -> Option<String> {
for line in content.lines() {
if line.trim_start().starts_with("# ") {
return Some(line.trim_start().trim_start_matches("# ").trim().to_string());
}
}
None
}
fn default_act_title(path: &str) -> String {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Act")
.to_string()
}
fn parse_chapters(
content: &str,
regex: &Regex,
config: &Config,
language_filter: Option<&str>,
) -> Vec<ChapterOverview> {
struct ChapterParse {
number: u32,
title: String,
pov: Option<String>,
beats: Vec<String>,
}
let mut chapters = Vec::new();
let mut current: Option<ChapterParse> = None;
for line in content.lines() {
if let Some(caps) = regex.captures(line) {
if let Some(ch) = current.take() {
chapters.push(ch);
}
let number = caps.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
let title = caps.get(2).map(|m| m.as_str().trim().to_string()).unwrap_or_default();
current = Some(ChapterParse {
number,
title,
pov: None,
beats: Vec::new(),
});
continue;
}
if let Some(chapter) = current.as_mut() {
let trimmed = line.trim();
if trimmed.starts_with("**POV**:") {
let value = trimmed.trim_start_matches("**POV**:").trim();
if !value.is_empty() {
chapter.pov = Some(value.to_string());
}
} else if trimmed.starts_with("1. ") {
let beat = trimmed.trim_start_matches("1. ").trim();
if !beat.is_empty() {
chapter.beats.push(beat.to_string());
}
}
}
}
if let Some(ch) = current.take() {
chapters.push(ch);
}
chapters.into_iter().map(|chapter| {
let status = build_status(config, chapter.number, language_filter);
ChapterOverview {
chapter_number: chapter.number,
title: chapter.title,
pov: chapter.pov,
synopsis: build_synopsis(&chapter.beats),
status,
}
}).collect()
}
fn build_synopsis(beats: &[String]) -> Option<String> {
if beats.is_empty() {
return None;
}
let mut synopsis = String::new();
for (idx, beat) in beats.iter().take(2).enumerate() {
if idx > 0 {
synopsis.push(' ');
}
synopsis.push_str(beat);
}
Some(synopsis)
}
fn build_status(config: &Config, chapter_number: u32, language_filter: Option<&str>) -> ChapterStatus {
let mut status = ChapterStatus { en: None, nl: None };
let langs = match language_filter {
Some(lang) => vec![lang],
None => vec!["EN", "NL"],
};
for lang in langs {
let exists = get_chapter(config, GetChapterInput {
chapter_number,
language: lang.to_string(),
}).is_ok();
let value = if exists { "present" } else { "missing" };
match lang {
"EN" => status.en = Some(value.to_string()),
"NL" => status.nl = Some(value.to_string()),
_ => {}
}
}
status
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn overview_parses_act_file() {
let dir = tempdir().unwrap();
let act_path = dir.path().join("Arc/Act_I_Awakening.md");
std::fs::create_dir_all(act_path.parent().unwrap()).unwrap();
std::fs::write(
&act_path,
"# Act I\n\n### Chapter 1 - Start\n**POV**: Ren\n1. Beat one\n",
).unwrap();
let config = Config {
source_root: dir.path().to_path_buf(),
work_root: dir.path().to_path_buf(),
index_dir: dir.path().join(".bindery/index"),
mcp_mirror_root: None,
embeddings_backend: "none".to_string(),
ollama_url: "http://127.0.0.1:11434".to_string(),
ollama_model: "nomic-embed-text".to_string(),
onnx_url: "http://127.0.0.1:11435".to_string(),
onnx_model: "bge-m3".to_string(),
sync_delete_default: false,
max_response_bytes: 60000,
snippet_max_chars: 1600,
default_topk: 6,
embed_batch_size: 32,
author: None,
};
let result = get_overview(&config, GetOverviewInput { language: None, act: Some(1) }).unwrap();
assert_eq!(result.acts.len(), 1);
assert_eq!(result.acts[0].chapters.len(), 1);
}
}

View file

@ -0,0 +1,312 @@
use crate::config::Config;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetReviewTextInput {
#[serde(default = "default_review_language", alias = "mode")]
pub language: String,
#[serde(default)]
pub context_lines: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetReviewTextResult {
pub files: Vec<ReviewFileResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewFileResult {
pub file: String,
pub changes: Vec<ReviewLine>,
pub hunks: Vec<ReviewHunk>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewLine {
pub line: usize,
pub before: Option<String>,
pub after: Option<String>,
pub change_type: ChangeType,
pub before_line: Option<usize>,
pub after_line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewHunk {
pub before_range: LineRange,
pub after_range: LineRange,
pub context_before: Vec<ReviewContextLine>,
pub changes: Vec<ReviewHunkChange>,
pub context_after: Vec<ReviewContextLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewContextLine {
pub line: usize,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewHunkChange {
pub change_type: ChangeType,
pub before: Option<String>,
pub after: Option<String>,
pub before_line: Option<usize>,
pub after_line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeType {
Insert,
Delete,
Replace,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LineRange {
pub start: usize,
pub count: usize,
}
pub fn get_review_text(config: &Config, input: GetReviewTextInput) -> Result<GetReviewTextResult> {
let mode = input.language.trim().to_lowercase();
let context_lines = input.context_lines.unwrap_or(3).max(1) as usize;
let path_filters = match mode.as_str() {
"en" | "story" => vec!["Story/EN".to_string(), "Story/Details_*.md".to_string(), "Story/AGENTS.md".to_string()],
"nl" | "translation" => vec!["Story/NL".to_string()],
"all" => vec![
"Story/EN".to_string(),
"Story/NL".to_string(),
"Story/Details_*.md".to_string(),
"Story/AGENTS.md".to_string(),
],
_ => return Err(anyhow!("language must be EN, NL, or ALL")),
};
let output = Command::new("git")
.args(git_diff_args(&path_filters))
.current_dir(&config.source_root)
.output()
.map_err(|e| anyhow!("git diff failed: {e}"))?;
let diff = String::from_utf8_lossy(&output.stdout).to_string();
let files = parse_diff(&diff, &config.source_root, context_lines)?;
Ok(GetReviewTextResult { files })
}
fn git_diff_args(path_filters: &[String]) -> Vec<String> {
let mut args = vec![
"diff".to_string(),
"--unified=0".to_string(),
"--ignore-cr-at-eol".to_string(),
"--".to_string(),
];
for filter in path_filters {
args.push(filter.clone());
}
args
}
fn parse_diff(diff: &str, repo_root: &Path, context_lines: usize) -> Result<Vec<ReviewFileResult>> {
let mut results = Vec::new();
let mut current_file: Option<String> = None;
let mut hunks: Vec<ReviewHunk> = Vec::new();
let mut changes: Vec<ReviewLine> = Vec::new();
let mut before_line = 0usize;
let mut after_line = 0usize;
let mut current_hunk: Option<(LineRange, LineRange, Vec<ReviewHunkChange>)> = None;
for line in diff.lines() {
if line.starts_with("diff --git ") {
flush_file(&mut results, current_file.take(), &mut hunks, &mut changes);
continue;
}
if line.starts_with("+++ b/") {
current_file = Some(line.trim_start_matches("+++ b/").to_string());
continue;
}
if line.starts_with("@@ ") {
if let Some((before_range, after_range, hunk_changes)) = current_hunk.take() {
let (context_before, context_after) = build_context(repo_root, current_file.as_deref(), &after_range, context_lines);
hunks.push(ReviewHunk {
before_range,
after_range,
context_before,
changes: hunk_changes,
context_after,
});
}
if let Some((before_range, after_range)) = parse_hunk_header(line) {
before_line = before_range.start;
after_line = after_range.start;
current_hunk = Some((before_range, after_range, Vec::new()));
}
continue;
}
if let Some((_, _, hunk_changes)) = current_hunk.as_mut() {
if line.starts_with('+') && !line.starts_with("+++") {
let text = line.trim_start_matches('+').to_string();
hunk_changes.push(ReviewHunkChange {
change_type: ChangeType::Insert,
before: None,
after: Some(text.clone()),
before_line: None,
after_line: Some(after_line),
});
changes.push(ReviewLine {
line: after_line,
before: None,
after: Some(text),
change_type: ChangeType::Insert,
before_line: None,
after_line: Some(after_line),
});
after_line += 1;
} else if line.starts_with('-') && !line.starts_with("---") {
let text = line.trim_start_matches('-').to_string();
hunk_changes.push(ReviewHunkChange {
change_type: ChangeType::Delete,
before: Some(text.clone()),
after: None,
before_line: Some(before_line),
after_line: None,
});
changes.push(ReviewLine {
line: before_line,
before: Some(text),
after: None,
change_type: ChangeType::Delete,
before_line: Some(before_line),
after_line: None,
});
before_line += 1;
} else if line.starts_with(' ') {
before_line += 1;
after_line += 1;
}
}
}
if let Some((before_range, after_range, hunk_changes)) = current_hunk.take() {
let (context_before, context_after) = build_context(repo_root, current_file.as_deref(), &after_range, context_lines);
hunks.push(ReviewHunk {
before_range,
after_range,
context_before,
changes: hunk_changes,
context_after,
});
}
flush_file(&mut results, current_file.take(), &mut hunks, &mut changes);
Ok(results)
}
fn parse_hunk_header(line: &str) -> Option<(LineRange, LineRange)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let before = parts[1].trim_start_matches('-');
let after = parts[2].trim_start_matches('+');
Some((parse_range(before), parse_range(after)))
}
fn parse_range(value: &str) -> LineRange {
let mut split = value.split(',');
let start = split.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let count = split.next().and_then(|v| v.parse().ok()).unwrap_or(0);
LineRange { start, count }
}
fn build_context(
repo_root: &Path,
file: Option<&str>,
after_range: &LineRange,
context_lines: usize,
) -> (Vec<ReviewContextLine>, Vec<ReviewContextLine>) {
let Some(file) = file else { return (Vec::new(), Vec::new()); };
let path = repo_root.join(file);
let Ok(content) = std::fs::read_to_string(&path) else { return (Vec::new(), Vec::new()); };
let lines: Vec<&str> = content.lines().collect();
let start_line = if after_range.start == 0 { 1 } else { after_range.start };
let end_line = if after_range.count == 0 {
start_line
} else {
start_line + after_range.count.saturating_sub(1)
};
let before_start = start_line.saturating_sub(context_lines);
let before_end = start_line.saturating_sub(1);
let after_start = end_line + 1;
let after_end = end_line + context_lines;
let context_before = collect_lines(&lines, before_start, before_end);
let context_after = collect_lines(&lines, after_start, after_end);
(context_before, context_after)
}
fn collect_lines(lines: &[&str], start: usize, end: usize) -> Vec<ReviewContextLine> {
if start == 0 || start > end {
return Vec::new();
}
let mut result = Vec::new();
for line_no in start..=end {
if let Some(text) = lines.get(line_no - 1) {
result.push(ReviewContextLine {
line: line_no,
text: text.to_string(),
});
}
}
result
}
fn flush_file(
results: &mut Vec<ReviewFileResult>,
file: Option<String>,
hunks: &mut Vec<ReviewHunk>,
changes: &mut Vec<ReviewLine>,
) {
if let Some(file) = file {
results.push(ReviewFileResult {
file,
changes: std::mem::take(changes),
hunks: std::mem::take(hunks),
});
}
}
fn default_review_language() -> String {
"ALL".to_string()
}
#[cfg(test)]
mod tests {
use super::{git_diff_args, parse_hunk_header};
#[test]
fn parses_hunk_header() {
let line = "@@ -10,2 +12,3 @@";
let (before, after) = parse_hunk_header(line).expect("range");
assert_eq!(before.start, 10);
assert_eq!(before.count, 2);
assert_eq!(after.start, 12);
assert_eq!(after.count, 3);
}
#[test]
fn includes_ignore_cr_at_eol_flag() {
let args = git_diff_args(&vec!["Story/EN".to_string()]);
assert!(args.contains(&"--ignore-cr-at-eol".to_string()));
}
}

View file

@ -0,0 +1,231 @@
use crate::config::Config;
use crate::docstore::read::read_lines_vec;
use anyhow::{Result, anyhow};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetTextInput {
pub language: String,
pub identifier: String,
pub start_line: Option<u32>,
pub end_line: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetTextResult {
pub path: String,
pub lines: Vec<LineText>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineText {
pub line: u32,
pub text: String,
}
pub fn get_text(config: &Config, input: GetTextInput) -> Result<GetTextResult> {
let language = input.language.trim().to_uppercase();
if language != "EN" && language != "NL" {
return Err(anyhow!("language must be EN or NL"));
}
let identifier = input.identifier.trim();
if identifier.is_empty() {
return Err(anyhow!("identifier is required"));
}
let (path, _attempts) = resolve_identifier(&config.source_root, &language, identifier)?;
let start_line = input.start_line.unwrap_or(1).max(1);
let end_line = input.end_line.unwrap_or(u32::MAX);
let lines = read_lines_vec(&path, start_line, end_line)?
.into_iter()
.map(|(line, text)| LineText { line, text })
.collect();
Ok(GetTextResult {
path: rel_from_root(&config.source_root, &path),
lines,
})
}
fn resolve_identifier(source_root: &Path, language: &str, identifier: &str) -> Result<(PathBuf, Vec<String>)> {
let mut attempts: Vec<String> = Vec::new();
let identifier = identifier.trim();
if is_relative_path(identifier) {
attempts.push(identifier.to_string());
let path = source_root.join(identifier);
if path.exists() {
return Ok((path, attempts));
}
}
let normalized = normalize_key(identifier);
if let Some(path) = resolve_alias(source_root, &normalized, &mut attempts) {
return Ok((path, attempts));
}
if let Some((act, chapter)) = parse_chapter_identifier(identifier) {
let candidates = chapter_candidate_paths(language, act, chapter);
for rel in &candidates {
attempts.push(rel.to_string());
let path = source_root.join(rel);
if path.exists() {
return Ok((path, attempts));
}
}
if act.is_none() {
if let Some(path) = find_chapter_in_story(source_root, language, chapter) {
let rel = rel_from_root(source_root, &path);
attempts.push(rel.clone());
return Ok((path, attempts));
}
}
}
Err(anyhow!(
"Unable to resolve identifier '{identifier}'. Tried: {attempts:?}. Use a relative path (e.g., Story/EN/Act I - Awakening/Chapter8.md) or shorthand like chapter8, act2 chapter9, agents, overall, act1, act2, act3.",
))
}
fn is_relative_path(value: &str) -> bool {
let path = PathBuf::from(value);
if path.is_absolute() || value.contains(':') {
return false;
}
!value.contains("..")
}
fn normalize_key(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
fn resolve_alias(source_root: &Path, normalized: &str, attempts: &mut Vec<String>) -> Option<PathBuf> {
let mut try_path = |rel: &str| {
attempts.push(rel.to_string());
let path = source_root.join(rel);
if path.exists() {
Some(path)
} else {
None
}
};
match normalized {
"agents" | "agentsmd" => try_path("AGENTS.md"),
"storyagents" | "agentsstory" => try_path("Story/AGENTS.md"),
"detailsoverall" | "detailsoveral" | "detailsstoryarcoverall" | "detailsstoryarcoveral"
| "overall" | "arcoverall" => {
try_path("Arc/Overall.md")
}
"detailsact1" | "detailsacti" | "detailsactiawakening" | "detailsstoryarcactiawakening"
| "act1" | "acti" | "arcact1" | "arcacti" => {
try_path("Arc/Act_I_Awakening.md")
}
"detailsact2" | "detailsactii" | "detailsactiiresonance" | "detailsstoryarcactiiresonance"
| "act2" | "actii" | "arcact2" | "arcactii" => {
try_path("Arc/Act_II_Resonance.md")
}
"detailsact3" | "detailsactiii" | "detailsactiiicollapse" | "detailsstoryarcactiiicollapse"
| "act3" | "actiii" | "arcact3" | "arcactiii" => {
try_path("Arc/Act_III_Collapse.md")
}
"detailscharacters" => try_path("Notes/Details_Characters.md"),
"detailsnotes" | "notes" => try_path("Notes/Details_Notes.md"),
"detailstranslationnotes" => try_path("Notes/Details_Translation_notes.md"),
"detailsworldandmagic" => try_path("Notes/Details_World_and_Magic.md"),
_ => None,
}
}
fn parse_chapter_identifier(value: &str) -> Option<(Option<u32>, u32)> {
let cleaned = value.trim().to_lowercase().replace(['_', '-'], " ");
let re = Regex::new(r"^act\s*([0-9]+|i{1,3})\s*chapter\s*([0-9]+)$").ok()?;
if let Some(caps) = re.captures(cleaned.trim()) {
let act = parse_act(caps.get(1)?.as_str());
let chapter = caps.get(2)?.as_str().parse::<u32>().ok()?;
return Some((act, chapter));
}
let re = Regex::new(r"^(?:chapter|ch)\s*([0-9]+)$").ok()?;
if let Some(caps) = re.captures(cleaned.trim()) {
let chapter = caps.get(1)?.as_str().parse::<u32>().ok()?;
return Some((None, chapter));
}
let re = Regex::new(r"^act\s*([0-9]+|i{1,3})\s*ch\s*([0-9]+)$").ok()?;
if let Some(caps) = re.captures(cleaned.trim()) {
let act = parse_act(caps.get(1)?.as_str());
let chapter = caps.get(2)?.as_str().parse::<u32>().ok()?;
return Some((act, chapter));
}
None
}
fn parse_act(value: &str) -> Option<u32> {
let value = value.trim().to_uppercase();
match value.as_str() {
"1" | "I" => Some(1),
"2" | "II" => Some(2),
"3" | "III" => Some(3),
_ => None,
}
}
fn act_folder(language: &str, act: u32) -> Option<&'static str> {
match (language, act) {
("EN", 1) => Some("Act I - Awakening"),
("EN", 2) => Some("Act II - Resonance"),
("EN", 3) => Some("Act III - Collapse"),
("NL", 1) => Some("Deel I - Ontwaken"),
("NL", 2) => Some("Deel II - Resonantie"),
("NL", 3) => Some("Deel III - Instorting"),
_ => None,
}
}
fn chapter_candidate_paths(language: &str, act: Option<u32>, chapter: u32) -> Vec<String> {
let mut candidates = Vec::new();
let filename = format!("Chapter{chapter}.md");
let acts: Vec<u32> = match act {
Some(v) => vec![v],
None => vec![1, 2, 3],
};
for act_num in acts {
if let Some(folder) = act_folder(language, act_num) {
candidates.push(format!("Story/{language}/{folder}/{filename}"));
}
}
candidates
}
fn find_chapter_in_story(source_root: &Path, language: &str, chapter: u32) -> Option<PathBuf> {
let root = source_root.join("Story").join(language);
let filename = format!("Chapter{chapter}.md");
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
if entry.file_name().to_string_lossy().eq_ignore_ascii_case(&filename) {
return Some(entry.path().to_path_buf());
}
}
None
}
fn rel_from_root(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}

View file

@ -0,0 +1,104 @@
use crate::config::Config;
use crate::embeddings::provider::EmbeddingProvider;
use crate::index::lexical::LEXICAL_DIR;
use crate::index::vector::VECTOR_DIR;
use crate::TimingMs;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResult {
pub cwd: String,
pub source_root: String,
pub work_root: String,
pub index_dir: String,
pub last_sync: Option<DateTime<Utc>>,
pub manifest_path: Option<String>,
pub sync_stale: Option<bool>,
pub embeddings_backend: String,
pub embeddings_model: String,
pub embeddings_url: Option<String>,
pub embeddings_engine_reachable: bool,
pub lexical_index_present: bool,
pub vector_index_present: bool,
pub last_retrieve_timing: Option<TimingMs>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WorkManifest {
pub source_root: String,
pub work_root: String,
pub synced_at: DateTime<Utc>,
pub paths: Vec<String>,
pub changed_files: Vec<String>,
pub file_count: usize,
}
pub fn health(config: &Config, provider: &dyn EmbeddingProvider, last_retrieve: Arc<Mutex<Option<TimingMs>>>) -> Result<HealthResult> {
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
let manifest_path = config.manifest_path();
let mut warnings = Vec::new();
let mut last_sync = None;
let mut manifest_path_str = None;
if manifest_path.exists() {
if let Ok(data) = fs::read_to_string(&manifest_path) {
if let Ok(manifest) = serde_json::from_str::<WorkManifest>(&data) {
last_sync = Some(manifest.synced_at);
manifest_path_str = Some(manifest_path.to_string_lossy().to_string());
} else {
warnings.push("manifest parse failed".to_string());
}
}
}
let lexical_index_present = config.index_dir.join(LEXICAL_DIR).exists();
let vector_index_present = config.index_dir.join(VECTOR_DIR).exists();
let last_retrieve_timing = last_retrieve.lock().ok().and_then(|v| v.clone());
let backend = provider.backend();
let reachable = match backend.as_str() {
"onnx" => ping_url(&format!("{}/health", config.onnx_url.trim_end_matches('/'))),
"ollama" => ping_url(&format!("{}/api/tags", config.ollama_url.trim_end_matches('/'))),
_ => false,
};
Ok(HealthResult {
cwd,
source_root: config.source_root.to_string_lossy().to_string(),
work_root: config.work_root.to_string_lossy().to_string(),
index_dir: config.index_dir.to_string_lossy().to_string(),
last_sync,
manifest_path: manifest_path_str,
sync_stale: None,
embeddings_backend: backend,
embeddings_model: provider.model(),
embeddings_url: Some(match provider.backend().as_str() {
"onnx" => config.onnx_url.clone(),
"ollama" => config.ollama_url.clone(),
_ => "none".to_string(),
}),
embeddings_engine_reachable: reachable,
lexical_index_present,
vector_index_present,
last_retrieve_timing,
warnings,
})
}
fn ping_url(url: &str) -> bool {
let response: Result<ureq::Response, ureq::Error> = ureq::get(url)
.timeout(Duration::from_secs(2))
.call();
match response {
Ok(resp) => resp.status() >= 200 && resp.status() < 300,
Err(_) => false,
}
}

View file

@ -0,0 +1,99 @@
use crate::config::Config;
use crate::docstore::chunk::chunk_file;
use crate::docstore::discover::{discover_index_files, DiscoverOptions};
use crate::embeddings::provider::EmbeddingProvider;
use crate::index::lexical::build_lexical;
use crate::index::meta::{ChunkingMeta, IndexMeta, write_meta};
use crate::index::vector::build_vector;
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexBuildInput {
pub scope: Option<String>,
pub act: Option<String>,
pub chapter_range: Option<String>,
pub force_rebuild: Option<bool>,
pub require_synced: Option<bool>,
pub sync_paths: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexBuildResult {
pub chunk_count: usize,
pub lexical_built: bool,
pub vector_built: bool,
pub embeddings_dim: Option<usize>,
pub elapsed_ms: u128,
pub warnings: Vec<String>,
}
pub fn index_build(config: &Config, provider: &dyn EmbeddingProvider, input: IndexBuildInput) -> Result<IndexBuildResult> {
let start = Instant::now();
let mut warnings = Vec::new();
if input.require_synced == Some(false) {
warnings.push("require_synced=false is ignored; index_build always syncs before indexing".to_string());
}
let sync_result = crate::tools::sync_workspace::sync_for_indexing(config, input.sync_paths)?;
warnings.extend(sync_result.warnings);
let opts = DiscoverOptions {
language: "ALL".to_string(),
act: input.act.clone(),
chapter_range: input.chapter_range.clone(),
};
let files = discover_index_files(&config.work_root, &opts)?;
let mut chunks = Vec::new();
for file in files {
let mut file_chunks = chunk_file(&config.work_root, &file)?;
chunks.append(&mut file_chunks);
}
build_lexical(&config.index_dir, &chunks)?;
let mut embeddings_dim = None;
let vector_built = if provider.is_available() {
match build_vector(&config.index_dir, &chunks, provider, config.embed_batch_size)? {
Some(dim) => {
embeddings_dim = Some(dim);
true
}
None => false,
}
} else {
warnings.push("embeddings backend not reachable; vector index skipped".to_string());
false
};
let meta = IndexMeta {
schema_version: 1,
built_at: Utc::now(),
work_root: config.work_root.to_string_lossy().to_string(),
index_dir: config.index_dir.to_string_lossy().to_string(),
language: "ALL".to_string(),
scope: input.scope.unwrap_or_else(|| "full".to_string()),
act: input.act,
chapter_range: input.chapter_range,
embeddings_backend: provider.backend(),
embeddings_model: provider.model(),
embeddings_dim,
chunking: ChunkingMeta { strategy: "markdown-paragraph".to_string(), min_lines: 1, max_lines: 9999 },
chunk_count: chunks.len(),
config_hash: config.config_hash(),
};
let meta_path = config.index_dir.join("meta.json");
fs::create_dir_all(&config.index_dir)?;
write_meta(&meta_path, &meta)?;
Ok(IndexBuildResult {
chunk_count: meta.chunk_count,
lexical_built: true,
vector_built,
embeddings_dim,
elapsed_ms: start.elapsed().as_millis(),
warnings,
})
}

View file

@ -0,0 +1,26 @@
use crate::config::Config;
use crate::index::meta::read_meta;
use crate::index::lexical::LEXICAL_DIR;
use crate::index::vector::VECTOR_DIR;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexStatusInput {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexStatusResult {
pub meta: Option<crate::index::meta::IndexMeta>,
pub lexical_present: bool,
pub vector_present: bool,
}
pub fn index_status(config: &Config) -> Result<IndexStatusResult> {
let meta_path = config.index_dir.join("meta.json");
let meta = if meta_path.exists() { read_meta(&meta_path).ok() } else { None };
Ok(IndexStatusResult {
meta,
lexical_present: config.index_dir.join(LEXICAL_DIR).exists(),
vector_present: config.index_dir.join(VECTOR_DIR).exists(),
})
}

114
mcp-rust/src/tools/merge.rs Normal file
View file

@ -0,0 +1,114 @@
use crate::{config::Config, merge};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeInput {
pub language: Option<String>,
pub output_type: Option<String>,
pub root: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeOutput {
pub root: String,
pub results: Vec<MergeRunResult>,
pub pandoc_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeRunResult {
pub language: String,
pub outputs: Vec<String>,
pub files_merged: usize,
}
pub fn merge_book(config: &Config, input: MergeInput) -> Result<MergeOutput> {
let root = input.root.map(PathBuf::from).unwrap_or_else(|| config.source_root.clone());
if !root.exists() {
return Err(anyhow!("Root path does not exist: {}", root.display()));
}
let languages = parse_languages(input.language.as_deref().unwrap_or("EN"))?;
let output_types = parse_output_types(input.output_type.as_deref().unwrap_or("md"))?;
let needs_pandoc = output_types.iter().any(|t| matches!(t, merge::OutputType::Docx | merge::OutputType::Epub | merge::OutputType::Pdf));
let pandoc_version = if needs_pandoc { Some(merge::check_pandoc()?) } else { None };
let mut results = Vec::new();
for language in languages {
let options = merge::MergeOptions {
root: root.clone(),
language,
output_types: output_types.clone(),
include_toc: true,
include_separators: true,
include_source_markers: true,
author: config.author.clone(),
libreoffice_path: config.libreoffice_path.clone(),
};
let result = merge::merge_book(&options)?;
let outputs = result.outputs.iter().map(|p| p.display().to_string()).collect();
results.push(MergeRunResult {
language: language.folder_name().to_string(),
outputs,
files_merged: result.files_merged,
});
}
Ok(MergeOutput {
root: root.display().to_string(),
results,
pandoc_version,
})
}
fn parse_languages(input: &str) -> Result<Vec<merge::Language>> {
let values: Vec<&str> = input
.split(|c| c == ',' || c == ' ' || c == ';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut languages = Vec::new();
for value in values {
if let Some(lang) = merge::Language::from_str(value) {
languages.push(lang);
} else {
return Err(anyhow!("Invalid language: {}", value));
}
}
if languages.is_empty() {
return Err(anyhow!("No valid languages provided"));
}
Ok(languages)
}
fn parse_output_types(input: &str) -> Result<Vec<merge::OutputType>> {
let values: Vec<&str> = input
.split(|c| c == ',' || c == ' ' || c == ';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut output_types = Vec::new();
for value in values {
if let Some(output) = merge::OutputType::from_str(value) {
output_types.push(output);
} else {
return Err(anyhow!("Invalid output type: {} (valid: md, docx, epub, pdf)", value));
}
}
if output_types.is_empty() {
return Err(anyhow!("No valid output types provided"));
}
Ok(output_types)
}

14
mcp-rust/src/tools/mod.rs Normal file
View file

@ -0,0 +1,14 @@
pub mod health;
pub mod sync_workspace;
pub mod index_build;
pub mod index_status;
pub mod retrieve_context;
pub mod get_text;
pub mod get_review_text;
pub mod search;
pub mod get_chapter;
pub mod get_overview;
pub mod get_notes;
pub mod format;
pub mod merge;
pub mod tasks;

View file

@ -0,0 +1,36 @@
use crate::config::Config;
use crate::embeddings::provider::EmbeddingProvider;
use crate::retrieve::{retrieve_context as retrieve, RetrieveInput, RetrieveResult};
use crate::TimingMs;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrieveContextInput {
pub query: String,
pub language: String,
pub top_k: Option<u32>,
pub act: Option<String>,
pub chapter_range: Option<String>,
}
pub fn retrieve_context(
config: &Config,
provider: &dyn EmbeddingProvider,
input: RetrieveContextInput,
last_timing: Arc<Mutex<Option<TimingMs>>>,
) -> Result<RetrieveResult> {
let top_k = input.top_k.map(|v| v as usize).unwrap_or(config.default_topk);
let result = retrieve(config, provider, RetrieveInput {
query: input.query,
language: input.language,
top_k,
act: input.act,
chapter_range: input.chapter_range,
})?;
if let Ok(mut guard) = last_timing.lock() {
*guard = Some(result.timing_ms.clone());
}
Ok(result)
}

View file

@ -0,0 +1,204 @@
use crate::config::Config;
use anyhow::{Result, anyhow};
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchInput {
pub query: String,
pub regex: Option<bool>,
pub case_sensitive: Option<bool>,
pub max_results: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub results: Vec<SearchHit>,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit {
pub path: String,
pub line: u32,
pub text: String,
}
pub fn search(config: &Config, input: SearchInput) -> Result<SearchResult> {
let max_results = input.max_results.map(|v| v as usize).unwrap_or(50);
let regex_mode = input.regex.unwrap_or(false);
let case_sensitive = input.case_sensitive.unwrap_or(true);
let (targets, roots) = build_search_targets(&config.source_root);
if targets.is_empty() {
return Err(anyhow!("No search roots found. Checked: {}", format_roots(&roots)));
}
if let Ok(result) = search_with_rg(&input.query, regex_mode, case_sensitive, &targets, &config.source_root, max_results) {
return Ok(result);
}
search_with_fallback(&input.query, regex_mode, case_sensitive, &targets, &config.source_root, max_results)
}
fn search_with_rg(query: &str, regex_mode: bool, case_sensitive: bool, targets: &[PathBuf], source_root: &Path, max_results: usize) -> Result<SearchResult> {
let mut cmd = Command::new("rg");
cmd.arg("--line-number").arg("--no-heading").arg("--color").arg("never");
if !regex_mode {
cmd.arg("-F");
}
if !case_sensitive {
cmd.arg("-i");
}
cmd.arg(query);
for target in targets {
cmd.arg(target);
}
let output = cmd.output()?;
if !output.status.success() {
if output.status.code() == Some(1) {
return Ok(SearchResult { results: Vec::new(), truncated: false });
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("rg failed (roots: {}): {stderr}", format_roots(&targets)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
for line in stdout.lines() {
if let Some(mut hit) = parse_rg_line(line) {
hit.path = rel_from_root(source_root, Path::new(&hit.path));
results.push(hit);
if results.len() >= max_results {
return Ok(SearchResult { results, truncated: true });
}
}
}
Ok(SearchResult { results, truncated: false })
}
fn parse_rg_line(line: &str) -> Option<SearchHit> {
let mut parts = line.splitn(3, ':');
let path = parts.next()?.to_string();
let line_no = parts.next()?.parse::<u32>().ok()?;
let text = parts.next()?.to_string();
Some(SearchHit { path, line: line_no, text })
}
fn search_with_fallback(query: &str, regex_mode: bool, case_sensitive: bool, targets: &[PathBuf], source_root: &Path, max_results: usize) -> Result<SearchResult> {
let pattern = if regex_mode {
RegexBuilder::new(query)
.case_insensitive(!case_sensitive)
.build()?
} else {
RegexBuilder::new(&regex::escape(query))
.case_insensitive(!case_sensitive)
.build()?
};
let mut results = Vec::new();
for target in targets {
if target.is_dir() {
for entry in WalkDir::new(target).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
search_file(entry.path(), &pattern, source_root, &mut results, max_results)?;
if results.len() >= max_results {
return Ok(SearchResult { results, truncated: true });
}
}
} else if target.is_file() {
search_file(target, &pattern, source_root, &mut results, max_results)?;
if results.len() >= max_results {
return Ok(SearchResult { results, truncated: true });
}
}
}
Ok(SearchResult { results, truncated: false })
}
fn search_file(
path: &Path,
pattern: &regex::Regex,
source_root: &Path,
results: &mut Vec<SearchHit>,
max_results: usize,
) -> Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for (idx, line) in reader.lines().enumerate() {
let line = line?;
if pattern.is_match(&line) {
let rel = rel_from_root(source_root, path);
results.push(SearchHit { path: rel, line: (idx + 1) as u32, text: line });
if results.len() >= max_results {
return Ok(());
}
}
}
Ok(())
}
fn build_search_targets(source_root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut targets = Vec::new();
let mut roots = Vec::new();
let story = source_root.join("Story");
roots.push(story.clone());
if story.exists() {
targets.push(story);
}
let notes = source_root.join("Notes");
roots.push(notes.clone());
if notes.exists() {
targets.push(notes);
}
let agents = source_root.join("AGENTS.md");
roots.push(agents.clone());
if agents.exists() {
targets.push(agents);
}
if let Ok(entries) = std::fs::read_dir(source_root) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
if name.starts_with("Details_") && name.ends_with(".md") {
roots.push(path.clone());
targets.push(path);
}
}
}
}
}
(targets, roots)
}
fn format_roots(roots: &[PathBuf]) -> String {
roots
.iter()
.map(|r| r.to_string_lossy().to_string())
.collect::<Vec<String>>()
.join(", ")
}
fn rel_from_root(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}

View file

@ -0,0 +1,561 @@
use crate::config::Config;
use anyhow::{Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use walkdir::WalkDir;
const DEFAULT_SYNC_PATHS: &[&str] = &[
"Story/EN",
"Story/NL",
"Story/AGENTS.md",
"Story/Details_*.md",
"Notes",
"AGENTS.md",
"Details_*.md",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncInput {
pub direction: Option<String>,
pub paths: Option<Vec<String>>,
pub delete: Option<bool>,
pub dry_run: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
pub changed_files_count: usize,
pub elapsed_ms: u128,
pub manifest_path: String,
pub synced_paths: Vec<String>,
pub warnings: Vec<String>,
pub rsync_failures: Vec<RsyncFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RsyncFailure {
pub path: String,
pub status: i32,
pub stderr: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WorkManifest {
pub source_root: String,
pub work_root: String,
pub synced_at: chrono::DateTime<Utc>,
pub paths: Vec<String>,
pub changed_files: Vec<String>,
pub file_count: usize,
}
#[derive(Debug, Clone, Copy)]
enum SyncDirection {
FromSource,
ToSource,
}
impl SyncDirection {
fn parse(value: &str) -> Result<Self> {
match value.to_lowercase().as_str() {
"from_source" => Ok(SyncDirection::FromSource),
"to_source" => Ok(SyncDirection::ToSource),
_ => Err(anyhow!("direction must be from_source or to_source")),
}
}
}
#[derive(Debug, Clone)]
struct SyncItem {
rel_path: String,
src: PathBuf,
dst: PathBuf,
is_dir: bool,
}
pub fn default_sync_paths() -> Vec<String> {
DEFAULT_SYNC_PATHS.iter().map(|v| v.to_string()).collect()
}
pub fn sync_for_indexing(config: &Config, paths: Option<Vec<String>>) -> Result<SyncResult> {
let paths = paths.unwrap_or_else(default_sync_paths);
sync_internal(config, SyncDirection::FromSource, paths, config.sync_delete_default, false, false)
}
pub fn sync_workspace(config: &Config, input: SyncInput) -> Result<SyncResult> {
let direction = input.direction.unwrap_or_else(|| "from_source".to_string());
let delete = input.delete.unwrap_or(config.sync_delete_default);
let dry_run = input.dry_run.unwrap_or(false);
let paths = input.paths.unwrap_or_else(default_sync_paths);
sync_internal(
config,
SyncDirection::parse(&direction)?,
paths,
delete,
dry_run,
true,
)
}
fn sync_internal(
config: &Config,
direction: SyncDirection,
paths: Vec<String>,
delete: bool,
dry_run: bool,
deprecated_warning: bool,
) -> Result<SyncResult> {
let start = Instant::now();
let mut changed_files = Vec::new();
let mut warnings = Vec::new();
let mut rsync_failures = Vec::new();
if deprecated_warning {
warnings.push("sync_workspace is deprecated; use index_build (it syncs automatically)".to_string());
}
let resolved_paths = resolve_paths(config, direction, &paths, &mut warnings)?;
let items = build_sync_items(config, direction, &resolved_paths, &mut warnings)?;
if rsync_available() {
for item in &items {
if let Err(err) = sync_with_rsync(item, delete, dry_run, &mut changed_files, &mut warnings, &mut rsync_failures) {
warnings.push(format!("rsync failed for {}: {err}", item.rel_path));
}
}
} else {
warnings.push("rsync not available; using fallback sync".to_string());
let fallback_changes = sync_with_fallback(&items, delete, dry_run, &mut warnings)?;
changed_files.extend(fallback_changes);
}
let file_count = count_files(&config.work_root, &resolved_paths);
let manifest = WorkManifest {
source_root: config.source_root.to_string_lossy().to_string(),
work_root: config.work_root.to_string_lossy().to_string(),
synced_at: Utc::now(),
paths: resolved_paths.clone(),
changed_files: changed_files.clone(),
file_count,
};
let manifest_path = config.manifest_path();
if let Some(parent) = manifest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
Ok(SyncResult {
changed_files_count: changed_files.len(),
elapsed_ms: start.elapsed().as_millis(),
manifest_path: manifest_path.to_string_lossy().to_string(),
synced_paths: resolved_paths,
warnings,
rsync_failures,
})
}
fn validate_rel_path(value: &str) -> Result<()> {
if value.contains("..") || value.starts_with('/') || value.starts_with('\\') {
return Err(anyhow!("Invalid relative path: {value}"));
}
Ok(())
}
fn parse_rsync_path(line: &str) -> Option<String> {
if line.starts_with('>') || line.starts_with('<') || line.starts_with('*') || line.starts_with('c') {
line.split_whitespace().last().map(|s| s.to_string())
} else {
None
}
}
fn truncate_text(value: &str, max_chars: usize) -> String {
let mut truncated = value.chars().take(max_chars).collect::<String>();
if value.chars().count() > max_chars {
truncated.push_str("...[truncated]");
}
truncated
}
fn resolve_sync_paths(config: &Config, direction: SyncDirection, rel: &str) -> Result<(PathBuf, PathBuf)> {
if rel == "mcp-rust" || rel.starts_with("mcp-rust/") {
let mirror_root = config.mcp_mirror_root.clone()
.ok_or_else(|| anyhow!("BINDERY_MCP_MIRROR_ROOT is required to sync mcp-rust"))?;
let subpath = Path::new(rel).strip_prefix("mcp-rust").unwrap_or(Path::new(""));
let subpath = if subpath.as_os_str().is_empty() { Path::new("") } else { subpath };
return if matches!(direction, SyncDirection::ToSource) {
Ok((mirror_root.join(subpath), config.source_root.join("mcp-rust").join(subpath)))
} else {
Ok((config.source_root.join("mcp-rust").join(subpath), mirror_root.join(subpath)))
};
}
if matches!(direction, SyncDirection::ToSource) {
Ok((config.work_root.join(rel), config.source_root.join(rel)))
} else {
Ok((config.source_root.join(rel), config.work_root.join(rel)))
}
}
fn count_files(work_root: &Path, rel_paths: &[String]) -> usize {
let mut count = 0usize;
for rel in rel_paths {
let path = work_root.join(rel);
if path.is_file() {
count += 1;
continue;
}
for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
count += 1;
}
}
}
count
}
fn resolve_paths(
config: &Config,
direction: SyncDirection,
input_paths: &[String],
warnings: &mut Vec<String>,
) -> Result<Vec<String>> {
let root = match direction {
SyncDirection::FromSource => &config.source_root,
SyncDirection::ToSource => &config.work_root,
};
let mut resolved = BTreeSet::new();
for rel in input_paths {
validate_rel_path(rel)?;
expand_path(root, rel, &mut resolved, warnings);
}
if resolved.is_empty() {
return Err(anyhow!(
"No valid sync paths found. Provided: {:?}",
input_paths
));
}
Ok(resolved.into_iter().collect())
}
fn expand_path(root: &Path, rel: &str, resolved: &mut BTreeSet<String>, warnings: &mut Vec<String>) {
if !rel.contains('*') {
let abs = root.join(rel);
if abs.exists() {
resolved.insert(rel.to_string());
} else {
warnings.push(format!("sync path not found: {}", abs.display()));
}
return;
}
let (parent, pattern) = split_pattern(rel);
let base = if parent.is_empty() { root.to_path_buf() } else { root.join(&parent) };
if !base.exists() {
warnings.push(format!("sync path not found: {}", base.display()));
return;
}
let Ok(entries) = std::fs::read_dir(&base) else {
warnings.push(format!("failed to read directory: {}", base.display()));
return;
};
let parent_ref = parent.as_str();
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|v| v.to_str()) else { continue; };
if !matches_pattern(name, pattern.as_str()) {
continue;
}
let rel_path = if parent_ref.is_empty() {
name.to_string()
} else {
format!("{parent_ref}/{name}")
};
resolved.insert(rel_path);
}
}
fn split_pattern(rel: &str) -> (String, String) {
let mut parts = rel.rsplitn(2, '/');
let file = parts.next().unwrap_or(rel);
let parent = parts.next().unwrap_or("");
(parent.to_string(), file.to_string())
}
fn matches_pattern(name: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return name == pattern;
}
let segments: Vec<&str> = pattern.split('*').collect();
if segments.is_empty() {
return true;
}
let mut pos = 0usize;
for (idx, seg) in segments.iter().enumerate() {
if seg.is_empty() {
continue;
}
if idx == 0 {
if !name.starts_with(seg) {
return false;
}
pos = seg.len();
continue;
}
if let Some(found) = name[pos..].find(seg) {
pos += found + seg.len();
} else {
return false;
}
}
if let Some(last) = segments.last() {
if !last.is_empty() && !name.ends_with(last) {
return false;
}
}
true
}
fn build_sync_items(
config: &Config,
direction: SyncDirection,
rel_paths: &[String],
warnings: &mut Vec<String>,
) -> Result<Vec<SyncItem>> {
let mut items = Vec::new();
for rel in rel_paths {
let (src, dst) = resolve_sync_paths(config, direction, rel)?;
if !src.exists() {
warnings.push(format!("sync source missing: {}", src.display()));
continue;
}
let is_dir = src.is_dir();
items.push(SyncItem {
rel_path: rel.clone(),
src,
dst,
is_dir,
});
}
Ok(items)
}
fn rsync_available() -> bool {
Command::new("rsync").arg("--version").output().is_ok()
}
fn sync_with_rsync(
item: &SyncItem,
delete: bool,
dry_run: bool,
changed_files: &mut Vec<String>,
warnings: &mut Vec<String>,
rsync_failures: &mut Vec<RsyncFailure>,
) -> Result<()> {
// Ensure destination exists
if let Some(parent) = if item.is_dir { Some(item.dst.clone()) } else { item.dst.parent().map(|p| p.to_path_buf()) } {
if let Err(e) = std::fs::create_dir_all(&parent) {
warnings.push(format!("Failed to create directory {}: {e}", parent.display()));
}
}
let mut cmd = Command::new("rsync");
cmd.arg("-a").arg("--checksum").arg("--itemize-changes");
if item.is_dir {
cmd.arg("--prune-empty-dirs");
}
if delete && item.is_dir {
cmd.arg("--delete");
}
if dry_run {
cmd.arg("--dry-run");
}
if item.is_dir {
cmd.arg(item.src.to_string_lossy().to_string() + "/");
cmd.arg(item.dst.to_string_lossy().to_string() + "/");
} else {
cmd.arg(item.src.to_string_lossy().to_string());
cmd.arg(item.dst.to_string_lossy().to_string());
}
let output = cmd.output().map_err(|e| anyhow!("rsync failed: {e}"))?;
if !output.status.success() {
let status = output.status.code().unwrap_or(-1);
let stderr = truncate_text(&String::from_utf8_lossy(&output.stderr), 2000);
warnings.push(format!("rsync status failed for {}", item.rel_path));
rsync_failures.push(RsyncFailure {
path: item.rel_path.clone(),
status,
stderr,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(path) = parse_rsync_path(line) {
let full_path = if item.is_dir {
if path.is_empty() {
item.rel_path.clone()
} else {
format!("{}/{}", item.rel_path.trim_end_matches('/'), path)
}
} else {
item.rel_path.clone()
};
changed_files.push(full_path);
}
}
Ok(())
}
fn sync_with_fallback(
items: &[SyncItem],
delete: bool,
dry_run: bool,
warnings: &mut Vec<String>,
) -> Result<Vec<String>> {
let mut changed = Vec::new();
let mut expected_files: HashSet<String> = HashSet::new();
for item in items {
if item.is_dir {
for entry in WalkDir::new(&item.src).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let rel = entry.path().strip_prefix(&item.src).unwrap_or(entry.path());
let rel = rel.to_string_lossy().replace('\\', "/");
let full_rel = format!("{}/{}", item.rel_path.trim_end_matches('/'), rel);
expected_files.insert(full_rel.clone());
let dst = item.dst.join(rel);
if file_needs_copy(entry.path(), &dst) {
changed.push(full_rel);
if !dry_run {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(entry.path(), &dst)?;
}
}
}
} else {
expected_files.insert(item.rel_path.clone());
if file_needs_copy(&item.src, &item.dst) {
changed.push(item.rel_path.clone());
if !dry_run {
if let Some(parent) = item.dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&item.src, &item.dst)?;
}
}
}
}
if delete {
for item in items {
let dst_root = &item.dst;
if !dst_root.exists() {
continue;
}
if item.is_dir {
for entry in WalkDir::new(dst_root).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let rel = entry.path().strip_prefix(dst_root).unwrap_or(entry.path());
let rel = rel.to_string_lossy().replace('\\', "/");
let full_rel = format!("{}/{}", item.rel_path.trim_end_matches('/'), rel);
if !expected_files.contains(&full_rel) {
changed.push(full_rel.clone());
if !dry_run {
if let Err(err) = std::fs::remove_file(entry.path()) {
warnings.push(format!("failed to delete {}: {err}", entry.path().display()));
}
}
}
}
} else if !expected_files.contains(&item.rel_path) {
changed.push(item.rel_path.clone());
if !dry_run {
if let Err(err) = std::fs::remove_file(dst_root) {
warnings.push(format!("failed to delete {}: {err}", dst_root.display()));
}
}
}
}
}
Ok(changed)
}
fn file_needs_copy(src: &Path, dst: &Path) -> bool {
let Ok(src_meta) = src.metadata() else { return false; };
let Ok(dst_meta) = dst.metadata() else { return true; };
if src_meta.len() != dst_meta.len() {
return true;
}
if let (Ok(src_m), Ok(dst_m)) = (src_meta.modified(), dst_meta.modified()) {
if src_m > dst_m {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::{resolve_sync_paths, truncate_text, SyncDirection};
use crate::config::Config;
use std::path::PathBuf;
fn test_config() -> Config {
Config {
source_root: PathBuf::from("/mnt/c/MyRepo"),
work_root: PathBuf::from("/home/user/bindery_work"),
index_dir: PathBuf::from("/home/user/.bindery/index"),
mcp_mirror_root: Some(PathBuf::from("/home/user/src/bindery-mcp")),
embeddings_backend: "none".to_string(),
ollama_url: "http://127.0.0.1:11434".to_string(),
ollama_model: "nomic-embed-text".to_string(),
onnx_url: "http://127.0.0.1:11435".to_string(),
onnx_model: "bge-m3".to_string(),
sync_delete_default: false,
max_response_bytes: 60000,
snippet_max_chars: 1600,
default_topk: 6,
embed_batch_size: 32,
author: None,
}
}
#[test]
fn resolves_mcp_rust_paths_from_source() {
let config = test_config();
let (src, dst) = resolve_sync_paths(&config, SyncDirection::FromSource, "mcp-rust/src")
.expect("resolve");
assert_eq!(src, PathBuf::from("/mnt/c/MyRepo/mcp-rust/src"));
assert_eq!(dst, PathBuf::from("/home/user/src/bindery-mcp/src"));
}
#[test]
fn truncates_long_stderr() {
let input = "a".repeat(10);
let result = truncate_text(&input, 5);
assert!(result.starts_with("aaaaa"));
assert!(result.contains("[truncated]"));
}
}

135
mcp-rust/src/tools/tasks.rs Normal file
View file

@ -0,0 +1,135 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskInfo {
pub task_id: String,
pub task_type: String,
pub status: TaskStatus,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub progress: Option<TaskProgress>,
pub result: Option<serde_json::Value>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskProgress {
pub current: usize,
pub total: usize,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStatusInput {
pub task_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStatusResult {
pub tasks: Vec<TaskInfo>,
}
#[derive(Clone)]
pub struct TaskManager {
tasks: Arc<Mutex<HashMap<String, TaskInfo>>>,
}
impl Default for TaskManager {
fn default() -> Self {
Self::new()
}
}
impl TaskManager {
pub fn new() -> Self {
Self {
tasks: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn create_task(&self, task_type: &str) -> String {
let task_id = Uuid::new_v4().to_string();
let task = TaskInfo {
task_id: task_id.clone(),
task_type: task_type.to_string(),
status: TaskStatus::Running,
started_at: Utc::now(),
completed_at: None,
progress: None,
result: None,
error: None,
};
self.tasks.lock().unwrap().insert(task_id.clone(), task);
task_id
}
pub fn update_progress(&self, task_id: &str, current: usize, total: usize, message: Option<String>) {
if let Ok(mut tasks) = self.tasks.lock() {
if let Some(task) = tasks.get_mut(task_id) {
task.progress = Some(TaskProgress { current, total, message });
}
}
}
pub fn complete_task(&self, task_id: &str, result: serde_json::Value) {
if let Ok(mut tasks) = self.tasks.lock() {
if let Some(task) = tasks.get_mut(task_id) {
task.status = TaskStatus::Completed;
task.completed_at = Some(Utc::now());
task.result = Some(result);
}
}
}
pub fn fail_task(&self, task_id: &str, error: String) {
if let Ok(mut tasks) = self.tasks.lock() {
if let Some(task) = tasks.get_mut(task_id) {
task.status = TaskStatus::Failed;
task.completed_at = Some(Utc::now());
task.error = Some(error);
}
}
}
pub fn get_task(&self, task_id: &str) -> Option<TaskInfo> {
self.tasks.lock().ok()?.get(task_id).cloned()
}
pub fn get_all_tasks(&self) -> Vec<TaskInfo> {
self.tasks.lock().ok().map(|t| t.values().cloned().collect()).unwrap_or_default()
}
pub fn cleanup_old_tasks(&self, max_age_hours: i64) {
let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours);
if let Ok(mut tasks) = self.tasks.lock() {
tasks.retain(|_, t| {
t.status == TaskStatus::Running || t.started_at > cutoff
});
}
}
}
pub fn task_status(manager: &TaskManager, input: TaskStatusInput) -> TaskStatusResult {
// Cleanup old tasks first
manager.cleanup_old_tasks(24);
let tasks = if let Some(id) = input.task_id {
manager.get_task(&id).into_iter().collect()
} else {
manager.get_all_tasks()
};
TaskStatusResult { tasks }
}

View file

@ -0,0 +1,12 @@
use bindery_mcp::retrieve::normalize::min_max_normalize;
#[test]
fn min_max_normalize_scales() {
let input = vec![
("a".to_string(), 1.0),
("b".to_string(), 3.0),
];
let out = min_max_normalize(&input);
assert_eq!(out.get("a").copied().unwrap(), 0.0);
assert_eq!(out.get("b").copied().unwrap(), 1.0);
}

View file

@ -0,0 +1,357 @@
use bindery_mcp::config::Config;
use bindery_mcp::embeddings::none::NoneProvider;
use bindery_mcp::tools::{
get_review_text::{get_review_text, GetReviewTextInput},
get_text::{get_text, GetTextInput},
index_build::{index_build, IndexBuildInput},
retrieve_context::{retrieve_context, RetrieveContextInput},
search::{search, SearchInput},
};
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
fn test_config(source_root: &Path, work_root: &Path, index_dir: &Path) -> Config {
Config {
source_root: source_root.to_path_buf(),
work_root: work_root.to_path_buf(),
index_dir: index_dir.to_path_buf(),
mcp_mirror_root: None,
embeddings_backend: "none".to_string(),
ollama_url: "http://127.0.0.1:11434".to_string(),
ollama_model: "nomic-embed-text".to_string(),
onnx_url: "http://127.0.0.1:11435".to_string(),
onnx_model: "bge-m3".to_string(),
sync_delete_default: true,
max_response_bytes: 60000,
snippet_max_chars: 1600,
default_topk: 6,
embed_batch_size: 32,
author: None,
}
}
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn index_build_syncs_and_updates_manifest() {
let dir = tempdir().unwrap();
let source_root = dir.path().join("source");
let work_root = dir.path().join("work");
let index_dir = dir.path().join("index");
write_file(
&source_root.join("Story/EN/Act I - Awakening/Chapter8.md"),
"End of chapter 8: Ren closes the loop.",
);
write_file(
&source_root.join("Arc/Act_II_Resonance.md"),
"Transition chapter 9 purpose: shift to resonance.",
);
write_file(
&source_root.join("Arc/Overall.md"),
"Overall arc notes.",
);
write_file(
&source_root.join("Story/AGENTS.md"),
"Story agents rules.",
);
write_file(
&source_root.join("Notes/Details_Notes.md"),
"Flux-touched: a sharp fear beat.",
);
write_file(&source_root.join("AGENTS.md"), "Root agents.");
let config = test_config(&source_root, &work_root, &index_dir);
let provider = NoneProvider::default();
let result = index_build(
&config,
&provider,
IndexBuildInput {
scope: None,
act: None,
chapter_range: None,
force_rebuild: None,
require_synced: None,
sync_paths: None,
},
)
.unwrap();
assert!(result.chunk_count > 0);
assert!(work_root.join("AGENTS.md").exists());
assert!(work_root.join("Arc/Act_II_Resonance.md").exists());
assert!(work_root.join("Notes/Details_Notes.md").exists());
let manifest_path = config.manifest_path();
let manifest = std::fs::read_to_string(&manifest_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&manifest).unwrap();
let paths = value.get("paths").unwrap().as_array().unwrap();
let paths: Vec<String> = paths
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
assert!(paths.contains(&"AGENTS.md".to_string()));
assert!(paths.contains(&"Arc/Act_II_Resonance.md".to_string()));
}
#[test]
fn retrieve_context_finds_chapter_and_act_details() {
let dir = tempdir().unwrap();
let source_root = dir.path().join("source");
let work_root = dir.path().join("work");
let index_dir = dir.path().join("index");
write_file(
&source_root.join("Story/EN/Act I - Awakening/Chapter8.md"),
"End of chapter 8: Ren remembers the Ashlight.",
);
write_file(
&source_root.join("Arc/Act_II_Resonance.md"),
"Transition chapter 9 purpose: bridge resonance beats.",
);
write_file(&source_root.join("Notes/Details_Notes.md"), "Flux-touched.");
let config = test_config(&source_root, &work_root, &index_dir);
let provider = NoneProvider::default();
index_build(
&config,
&provider,
IndexBuildInput {
scope: None,
act: None,
chapter_range: None,
force_rebuild: None,
require_synced: None,
sync_paths: None,
},
)
.unwrap();
let result = retrieve_context(
&config,
&provider,
RetrieveContextInput {
query: "end of chapter 8".to_string(),
language: "EN".to_string(),
top_k: Some(5),
act: None,
chapter_range: None,
},
Arc::new(Mutex::new(None)),
)
.unwrap();
assert!(result
.results
.iter()
.any(|hit| hit.path.ends_with("Chapter8.md")));
let result = retrieve_context(
&config,
&provider,
RetrieveContextInput {
query: "transition chapter 9 purpose".to_string(),
language: "EN".to_string(),
top_k: Some(5),
act: None,
chapter_range: None,
},
Arc::new(Mutex::new(None)),
)
.unwrap();
assert!(result
.results
.iter()
.any(|hit| hit.path.ends_with("Act_II_Resonance.md")));
}
#[test]
fn get_text_resolves_shorthand_and_details() {
let dir = tempdir().unwrap();
let source_root = dir.path().join("source");
let work_root = dir.path().join("work");
let index_dir = dir.path().join("index");
write_file(
&source_root.join("Story/EN/Act II - Resonance/Chapter9.md"),
"Chapter 9 text.",
);
write_file(
&source_root.join("Arc/Overall.md"),
"Overall arc summary.",
);
let config = test_config(&source_root, &work_root, &index_dir);
let result = get_text(
&config,
GetTextInput {
language: "EN".to_string(),
identifier: "chapter9".to_string(),
start_line: None,
end_line: None,
},
)
.unwrap();
assert!(result.lines.iter().any(|line| line.text.contains("Chapter 9 text")));
let result = get_text(
&config,
GetTextInput {
language: "EN".to_string(),
identifier: "details_overall".to_string(),
start_line: None,
end_line: None,
},
)
.unwrap();
assert!(result.lines.iter().any(|line| line.text.contains("Overall arc summary")));
let err = get_text(
&config,
GetTextInput {
language: "EN".to_string(),
identifier: "missing_key".to_string(),
start_line: None,
end_line: None,
},
)
.err()
.unwrap();
assert!(err.to_string().contains("Tried"));
}
#[test]
fn search_finds_hits_in_story_and_notes() {
let dir = tempdir().unwrap();
let source_root = dir.path().join("source");
let work_root = dir.path().join("work");
let index_dir = dir.path().join("index");
write_file(
&source_root.join("Story/EN/Act I - Awakening/Chapter1.md"),
"The Ashlight burns bright.",
);
write_file(
&source_root.join("Notes/Details_Notes.md"),
"Flux-touched means fear spikes.",
);
write_file(&source_root.join("AGENTS.md"), "Agents.");
let config = test_config(&source_root, &work_root, &index_dir);
let result = search(
&config,
SearchInput {
query: "Ashlight".to_string(),
regex: None,
case_sensitive: None,
max_results: Some(10),
},
)
.unwrap();
assert!(result
.results
.iter()
.any(|hit| hit.path.ends_with("Chapter1.md")));
let result = search(
&config,
SearchInput {
query: "Flux-touched".to_string(),
regex: None,
case_sensitive: None,
max_results: Some(10),
},
)
.unwrap();
assert!(result
.results
.iter()
.any(|hit| hit.path.ends_with("Details_Notes.md")));
}
#[test]
fn get_review_text_respects_language_filter() {
if Command::new("git").arg("--version").output().is_err() {
eprintln!("git not available; skipping get_review_text test");
return;
}
let dir = tempdir().unwrap();
let source_root = dir.path().join("repo");
let work_root = dir.path().join("work");
let index_dir = dir.path().join("index");
std::fs::create_dir_all(&source_root).unwrap();
run_git(&source_root, &["init"]);
run_git(&source_root, &["config", "user.email", "test@example.com"]);
run_git(&source_root, &["config", "user.name", "Tester"]);
let en_file = source_root.join("Story/EN/Act II - Resonance/Chapter9.md");
let nl_file = source_root.join("Story/NL/Deel II - Resonantie/Chapter9.md");
write_file(&en_file, "Old EN text.");
write_file(&nl_file, "Old NL text.");
run_git(&source_root, &["add", "."]);
run_git(&source_root, &["commit", "-m", "init"]);
write_file(&en_file, "New EN text.");
write_file(&nl_file, "New NL text.");
let config = test_config(&source_root, &work_root, &index_dir);
let en_result = get_review_text(
&config,
GetReviewTextInput {
language: "EN".to_string(),
context_lines: Some(1),
},
)
.unwrap();
assert!(en_result
.files
.iter()
.any(|file| file.file.contains("Story/EN/Act II - Resonance/Chapter9.md")));
assert!(!en_result
.files
.iter()
.any(|file| file.file.contains("Story/NL/Deel II - Resonantie/Chapter9.md")));
let all_result = get_review_text(
&config,
GetReviewTextInput {
language: "ALL".to_string(),
context_lines: Some(1),
},
)
.unwrap();
assert!(all_result
.files
.iter()
.any(|file| file.file.contains("Story/EN/Act II - Resonance/Chapter9.md")));
assert!(all_result
.files
.iter()
.any(|file| file.file.contains("Story/NL/Deel II - Resonantie/Chapter9.md")));
}
fn run_git(repo: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(repo)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}

42
mcpb/README.md Normal file
View file

@ -0,0 +1,42 @@
# Bindery MCP — Desktop Extension (.mcpb)
This directory contains the files needed to package the Bindery MCP server
as a Claude Desktop Extension (.mcpb).
## How it works
The extension is a thin Node.js bridge (`server/index.js`) that spawns the
native Rust MCP binary inside WSL via `wsl.exe`. Claude Desktop communicates
with it over stdio, exactly like any other MCP server.
```
Claude Desktop <—> Node.js bridge (stdio) <—> wsl.exe <—> bindery-mcp (Rust)
```
## Prerequisites
1. The Rust MCP server is built in WSL (see `SETUP_MCP_SERVER.md`)
2. `@anthropic-ai/mcpb` is installed globally: `npm install -g @anthropic-ai/mcpb`
## Packaging
```powershell
cd _src\mcpb
mcpb pack
```
This produces `bindery-mcp-1.0.0.mcpb`.
## Installing
1. Open Claude Desktop
2. Go to **Settings > Extensions**
3. Click **Install from file** (or drag-drop the `.mcpb` file)
4. Fill in the configuration (paths to your repo, work directory, etc.)
5. Done — Bindery tools are now available in Claude Desktop and Cowork
## Files
- `manifest.json` — Extension metadata, tool declarations, user config schema
- `server/index.js` — Node.js stdio bridge that spawns the WSL Rust binary
- `icon.svg` — Extension icon

BIN
mcpb/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

68
mcpb/icon.svg Normal file
View file

@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1e2e"/>
<stop offset="100%" style="stop-color:#151928"/>
</linearGradient>
<linearGradient id="pageL" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3a8a9e"/>
<stop offset="100%" style="stop-color:#2e7a8e"/>
</linearGradient>
<linearGradient id="pageR" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2a6e80"/>
<stop offset="100%" style="stop-color:#245e6e"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="128" height="128" rx="22" fill="url(#bg)"/>
<!-- Left page -->
<path d="M 16 30 Q 16 24 22 23 L 58 18 Q 62 17.5 64 21 L 64 102 Q 62 106 58 106.5 L 22 111 Q 16 112 16 106 Z"
fill="url(#pageL)" opacity="0.9"/>
<!-- Right page -->
<path d="M 112 30 Q 112 24 106 23 L 70 18 Q 66 17.5 64 21 L 64 102 Q 66 106 70 106.5 L 106 111 Q 112 112 112 106 Z"
fill="url(#pageR)" opacity="0.9"/>
<!-- Spine shadow -->
<rect x="61" y="17" width="6" height="92" rx="2" fill="#1a3a4a" opacity="0.7"/>
<!-- Text lines left page -->
<line x1="26" y1="42" x2="54" y2="39" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="52" x2="54" y2="50" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="62" x2="54" y2="61" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="72" x2="44" y2="71" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="26" y1="82" x2="54" y2="82" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="92" x2="40" y2="92" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- Text lines right page -->
<line x1="74" y1="39" x2="102" y2="42" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="50" x2="102" y2="52" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="61" x2="102" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="71" x2="92" y2="72" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="74" y1="82" x2="102" y2="82" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="92" x2="88" y2="92" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- Binding stitches along the spine — the "bindery" signature -->
<g stroke="#d4a857" stroke-width="2.2" stroke-linecap="round" fill="none" opacity="0.9">
<!-- Cross stitches -->
<line x1="57" y1="30" x2="71" y2="38"/>
<line x1="71" y1="30" x2="57" y2="38"/>
<line x1="57" y1="48" x2="71" y2="56"/>
<line x1="71" y1="48" x2="57" y2="56"/>
<line x1="57" y1="66" x2="71" y2="74"/>
<line x1="71" y1="66" x2="57" y2="74"/>
<line x1="57" y1="84" x2="71" y2="92"/>
<line x1="71" y1="84" x2="57" y2="92"/>
</g>
<!-- Thread running along the spine (vertical connecting line) -->
<line x1="64" y1="26" x2="64" y2="96" stroke="#d4a857" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
<!-- Small needle at top -->
<ellipse cx="64" cy="22" rx="1.5" ry="3" fill="#e8c87a" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

132
mcpb/manifest.json Normal file
View file

@ -0,0 +1,132 @@
{
"manifest_version": "0.3",
"name": "bindery-mcp",
"display_name": "Bindery MCP",
"version": "1.0.0",
"description": "Semantic search, retrieval, and book management tools for markdown book projects. Provides hybrid BM25 + vector search, chapter navigation, typography formatting, and DOCX/EPUB export.",
"author": {
"name": "Erik van der Boom"
},
"icon": "icon.png",
"keywords": ["writing", "search", "indexing", "book", "semantic-search"],
"license": "MIT",
"server": {
"type": "node",
"entry_point": "server/index.js",
"mcp_config": {
"command": "node",
"args": ["${__dirname}/server/index.js"],
"env": {
"BINDERY_WSL_BINARY": "${user_config.wsl_binary}",
"BINDERY_SOURCE_ROOT": "${user_config.source_root}",
"BINDERY_WORK_ROOT": "${user_config.work_root}",
"BINDERY_INDEX_DIR": "${user_config.index_dir}",
"BINDERY_EMBEDDINGS_BACKEND": "${user_config.embeddings_backend}",
"BINDERY_ONNX_URL": "${user_config.onnx_url}",
"BINDERY_ONNX_PORT": "${user_config.onnx_port}",
"BINDERY_ONNX_MODEL": "${user_config.onnx_model}",
"BINDERY_ONNX_SERVER_DIR": "${user_config.onnx_server_dir}",
"BINDERY_LIBREOFFICE_PATH": "${user_config.libreoffice_path}",
"BINDERY_MAX_RESPONSE_BYTES": "60000",
"BINDERY_SNIPPET_MAX_CHARS": "1600",
"BINDERY_DEFAULT_TOPK": "6",
"BINDERY_AUTHOR": "${user_config.author}"
}
}
},
"user_config": {
"wsl_binary": {
"type": "string",
"title": "WSL Binary Path",
"description": "Full path to the compiled bindery-mcp binary inside WSL (e.g. /home/user/bindery_source/target/release/bindery-mcp)",
"default": "/home/user/bindery_source/target/release/bindery-mcp",
"required": true
},
"source_root": {
"type": "directory",
"title": "Source Repository",
"description": "Path to the repository root containing your Story/ folder (e.g. C:\\Users\\YourUser\\MyBook or /mnt/c/Users/YourUser/MyBook)",
"required": true
},
"work_root": {
"type": "directory",
"title": "Work Directory",
"description": "Workspace for synced content and indexing. Use a fast local filesystem (e.g. WSL ext4 path like /home/user/bindery_work)",
"required": true
},
"index_dir": {
"type": "directory",
"title": "Index Directory",
"description": "Where lexical and vector indices are stored (e.g. /home/user/bindery_cache/.bindery/index)",
"required": true
},
"embeddings_backend": {
"type": "string",
"title": "Embeddings Backend",
"description": "Embedding backend: onnx, ollama, or none",
"default": "onnx",
"required": false
},
"onnx_url": {
"type": "string",
"title": "ONNX Server URL",
"description": "Full URL override for the ONNX embedding server. Leave empty to auto-detect the Windows host IP at startup.",
"default": "",
"required": false
},
"onnx_port": {
"type": "string",
"title": "ONNX Server Port",
"description": "Port the ONNX server listens on. Used with auto-detected IP if ONNX Server URL is not set.",
"default": "11435",
"required": false
},
"onnx_model": {
"type": "string",
"title": "ONNX Model",
"description": "Embedding model name",
"default": "bge-m3",
"required": false
},
"onnx_server_dir": {
"type": "string",
"title": "ONNX Server Directory",
"description": "Windows path to standalone ONNX server install (for auto-start)",
"default": "/mnt/c/Tools/onnx-embeddings",
"required": false
},
"libreoffice_path": {
"type": "string",
"title": "LibreOffice Path",
"description": "Path to the LibreOffice executable for PDF export. On WSL/Linux (default): 'libreoffice'. On Windows: full path to soffice.exe, e.g. C:\\Program Files\\LibreOffice\\program\\soffice.exe",
"default": "libreoffice",
"required": false
},
"author": {
"type": "string",
"title": "Author Name",
"description": "Author name for EPUB/DOCX metadata",
"default": "",
"required": false
}
},
"tools": [
{ "name": "health", "description": "Return health and diagnostics for the MCP server" },
{ "name": "retrieve_context", "description": "Hybrid retrieval (BM25 + vector) for story context. Use language=EN, NL, or ALL." },
{ "name": "get_text", "description": "Read text by identifier from the source repo" },
{ "name": "get_chapter", "description": "Get a full chapter by number and language" },
{ "name": "get_overview", "description": "Get an overview of acts and chapters" },
{ "name": "get_notes", "description": "Get an entry from Notes/Details_Notes.md" },
{ "name": "get_review_text", "description": "Return git diff from the source repo (EN/NL/ALL)" },
{ "name": "search", "description": "Literal or regex search under the source repo" },
{ "name": "index_build", "description": "Build or rebuild lexical/vector indices for all languages" },
{ "name": "index_status", "description": "Return index status and metadata" },
{ "name": "sync_workspace", "description": "Sync workspace files between source and work directories" },
{ "name": "format", "description": "Format typography in markdown files under the source repo" },
{ "name": "merge", "description": "Merge markdown chapters into a book, optionally exporting DOCX/EPUB" },
{ "name": "task_status", "description": "Check status of background tasks" }
],
"compatibility": {
"platforms": ["win32"]
}
}

115
mcpb/server/index.js Normal file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* Bindery MCP Node.js stdio bridge for the native Rust MCP server.
*
* This thin wrapper spawns the Rust binary inside WSL and pipes stdio
* through so Claude Desktop can talk to it via the standard MCP protocol.
*
* The Rust binary path is taken from BINDERY_WSL_BINARY (set by the mcpb
* manifest via user_config). The source directory is derived from it so that
* dotenvy can find the .env file alongside the binary's build root.
* All other env vars are forwarded from the manifest and take precedence over
* anything in .env.
*/
const { spawn } = require("child_process");
// ── Configuration ───────────────────────────────────────────────────────────
// Full WSL path to the compiled Rust binary.
// Derived from user_config.wsl_binary in the manifest.
const WSL_BINARY = process.env.BINDERY_WSL_BINARY
|| "/home/user/bindery_source/target/release/bindery-mcp";
// Derive the source root (two levels up from target/release/<binary>).
// This is where dotenvy looks for .env.
function sourceDir(binaryPath) {
const parts = binaryPath.split("/");
const releaseIdx = parts.lastIndexOf("release");
if (releaseIdx >= 2) return parts.slice(0, releaseIdx - 1).join("/");
// Fallback: same directory as binary
return parts.slice(0, -1).join("/");
}
// Build the environment block to forward into WSL.
// The mcpb manifest injects these from user_config; they take precedence over .env.
const ENV_VARS = [
"BINDERY_SOURCE_ROOT",
"BINDERY_WORK_ROOT",
"BINDERY_INDEX_DIR",
"BINDERY_EMBEDDINGS_BACKEND",
"BINDERY_ONNX_URL",
"BINDERY_ONNX_PORT",
"BINDERY_ONNX_MODEL",
"BINDERY_ONNX_SERVER_DIR",
"BINDERY_MAX_RESPONSE_BYTES",
"BINDERY_SNIPPET_MAX_CHARS",
"BINDERY_DEFAULT_TOPK",
"BINDERY_AUTHOR",
];
// ── Spawn the Rust MCP server via WSL ───────────────────────────────────────
function buildEnvExports() {
return ENV_VARS
.filter((key) => process.env[key] && process.env[key] !== "" && !process.env[key].startsWith("${"))
.map((key) => `export ${key}=${JSON.stringify(process.env[key])}`)
.join("; ");
}
function start() {
const dir = sourceDir(WSL_BINARY);
const envExports = buildEnvExports();
// cd to the source root (so dotenvy finds .env), then exec the binary directly.
// Env vars from the manifest are exported first and override .env for overlapping keys.
const wslCommand = [
envExports,
`cd ${JSON.stringify(dir)}`,
`exec ${JSON.stringify(WSL_BINARY)}`,
].filter(Boolean).join("; ");
const child = spawn("wsl.exe", ["--", "bash", "-c", wslCommand], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
// Bridge stdio: Claude Desktop <-> this process <-> WSL Rust binary
process.stdin.pipe(child.stdin);
child.stdout.pipe(process.stdout);
// Forward stderr for diagnostics (won't interfere with MCP protocol on stdout)
child.stderr.on("data", (data) => {
process.stderr.write(data);
});
child.on("error", (err) => {
process.stderr.write(`[bindery-bridge] Failed to start WSL process: ${err.message}\n`);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.stderr.write(`[bindery-bridge] WSL process killed by signal ${signal}\n`);
process.exit(1);
}
process.exit(code ?? 0);
});
// If the parent process (Claude Desktop) closes stdin, propagate to child
process.stdin.on("end", () => {
child.stdin.end();
});
// Handle parent process termination gracefully
function cleanup() {
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3000);
}
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
process.on("SIGHUP", cleanup);
}
start();

View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Download and export a sentence-transformers model to ONNX.
Example:
python scripts/download_onnx_model.py \
--model sentence-transformers/all-MiniLM-L6-v2 \
--out .bindery/models/all-MiniLM-L6-v2
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from huggingface_hub import snapshot_download
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True, help="HuggingFace model id")
parser.add_argument("--out", required=True, help="Output directory")
parser.add_argument("--task", default="feature-extraction", help="Optimum task")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Downloading model: {args.model}")
snapshot_path = snapshot_download(args.model)
print("Exporting to ONNX (optimum-cli)...")
cmd = [
"optimum-cli",
"export",
"onnx",
"--model",
args.model,
"--task",
args.task,
str(out_dir),
]
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
print("optimum-cli export failed.")
return result.returncode
# Copy tokenizer/config files if present
for name in [
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
"config.json",
]:
src = Path(snapshot_path) / name
if src.exists():
shutil.copy2(src, out_dir / name)
print("Done. Files written to:", out_dir)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,139 @@
import os
from typing import List, Union
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn
from transformers import AutoTokenizer
import onnxruntime as ort
from optimum.onnxruntime import ORTModelForFeatureExtraction
import numpy as np
def select_provider() -> str:
"""Pick the best available ONNX Runtime execution provider."""
available = ort.get_available_providers()
# Prefer DirectML (Windows GPU), then CUDA, then CPU
for pref in ["DmlExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]:
if pref in available:
return pref
return available[0] if available else "CPUExecutionProvider"
MODEL_ID = os.getenv("ONNX_MODEL_ID", "BAAI/bge-m3")
PROVIDER = os.getenv("ONNX_PROVIDER", "") or select_provider()
HOST = os.getenv("ONNX_HOST", "0.0.0.0")
PORT = int(os.getenv("ONNX_PORT", "11435"))
EXPORT = os.getenv("ONNX_EXPORT", "1").lower() in {"1", "true", "yes"}
print(f"ONNX provider: {PROVIDER} (available: {ort.get_available_providers()})")
# Suppress ORT's informational node-assignment warning (shape ops intentionally run on CPU).
ort.set_default_logger_severity(3)
try:
# fix_mistral_regex corrects a bad regex in this tokenizer's config (bge-m3 inherits
# it from its Mistral-derived tokenizer). The warning fires before the fix is applied
# and is a false positive — tokenization is correct with this flag set.
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, fix_mistral_regex=True)
except TypeError:
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
try:
model = ORTModelForFeatureExtraction.from_pretrained(
MODEL_ID,
export=EXPORT,
provider=PROVIDER,
)
except Exception as exc: # pragma: no cover - startup failures
hint = (
"ONNX export failed. Either install ONNX + torch for export, or "
"export once with: `optimum-cli export onnx --model BAAI/bge-m3 --task feature-extraction onnx/bge-m3` "
"then set ONNX_MODEL_ID=onnx/bge-m3 and ONNX_EXPORT=0."
)
raise RuntimeError(hint) from exc
class EmbeddingRequest(BaseModel):
model: str = MODEL_ID
input: Union[str, List[str]]
def mean_pool(last_hidden_state: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
mask = attention_mask.astype(np.float32)
masked = last_hidden_state * mask[:, :, None]
summed = masked.sum(axis=1)
counts = np.clip(mask.sum(axis=1, keepdims=True), a_min=1.0, a_max=None)
return summed / counts
def l2_normalize(x: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(x, axis=1, keepdims=True)
norm = np.clip(norm, a_min=1e-12, a_max=None)
return x / norm
def extract_last_hidden_state(outputs) -> np.ndarray:
if hasattr(outputs, "last_hidden_state"):
return outputs.last_hidden_state
if hasattr(outputs, "token_embeddings"):
return outputs.token_embeddings
if isinstance(outputs, dict):
if "last_hidden_state" in outputs:
return outputs["last_hidden_state"]
if "token_embeddings" in outputs:
return outputs["token_embeddings"]
if len(outputs):
return next(iter(outputs.values()))
if isinstance(outputs, (list, tuple)) and outputs:
return outputs[0]
raise ValueError(f"ONNX outputs missing last_hidden_state/token_embeddings: {outputs}")
app = FastAPI()
@app.get("/health")
def health():
return {"ok": True, "model": MODEL_ID, "provider": PROVIDER}
@app.post("/embeddings")
def embeddings(payload: EmbeddingRequest):
inputs = payload.input if isinstance(payload.input, list) else [payload.input]
tokenized = tokenizer(
inputs,
padding=True,
truncation=True,
return_tensors="np",
)
if hasattr(model, "model"):
session = model.model
output_names = [o.name for o in session.get_outputs()]
input_feed = {k: np.asarray(v) for k, v in tokenized.items()}
run_options = ort.RunOptions()
outputs = session.run(output_names, input_feed, run_options)
output_map = dict(zip(output_names, outputs))
if "sentence_embedding" in output_map:
pooled = output_map["sentence_embedding"]
if pooled.ndim == 1:
pooled = pooled[None, :]
pooled = l2_normalize(pooled)
vectors = pooled.astype(np.float32).tolist()
if isinstance(payload.input, list):
return {"embeddings": vectors}
return {"embedding": vectors[0]}
last_hidden_state = output_map.get("token_embeddings", outputs[0])
else:
outputs = model(**tokenized)
last_hidden_state = extract_last_hidden_state(outputs)
pooled = mean_pool(last_hidden_state, tokenized["attention_mask"])
pooled = l2_normalize(pooled)
vectors = pooled.astype(np.float32).tolist()
if isinstance(payload.input, list):
return {"embeddings": vectors}
return {"embedding": vectors[0]}
if __name__ == "__main__":
uvicorn.run(app, host=HOST, port=PORT)

View file

@ -0,0 +1,8 @@
# One-time export dependencies (PyTorch → ONNX conversion)
# After export, switch to onnx_requirements.txt for runtime
optimum[onnxruntime]==2.1.0
onnx==1.20.1
torch==2.10.0
transformers==4.57.6
accelerate==1.12.0
sentence-transformers==5.2.1

View file

@ -0,0 +1,9 @@
# Runtime dependencies for ONNX embedding server (DirectML GPU inference)
# For one-time model export, first use onnx_export_requirements.txt
fastapi==0.128.0
uvicorn==0.40.0
optimum==2.1.0
onnxruntime-directml==1.23.0
pydantic==2.12.5
numpy==2.4.1
hf-xet==1.2.0

View file

@ -0,0 +1,8 @@
@echo off
set "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%\.."
call "%SCRIPT_DIR%\..\.venv\Scripts\activate.bat"
set ONNX_MODEL_ID=onnx\bge-m3
set ONNX_EXPORT=0
set ONNX_PROVIDER=DmlExecutionProvider
python scripts\onnx_embed_server.py

View file

@ -0,0 +1,19 @@
@echo off
REM Standalone ONNX embedding server launcher.
REM Place this file alongside onnx_embed_server.py in your chosen install directory.
REM The .venv and onnx/ model folder should also be in this same directory.
REM
REM Expected directory layout:
REM <ONNX_SERVER_DIR>\
REM start_onnx_server.cmd (this file, renamed)
REM onnx_embed_server.py
REM onnx\bge-m3\ (exported ONNX model)
REM .venv\ (Python virtual environment)
set "SCRIPT_DIR=%~dp0"
cd /d "%SCRIPT_DIR%"
call "%SCRIPT_DIR%.venv\Scripts\activate.bat"
set ONNX_MODEL_ID=onnx\bge-m3
set ONNX_EXPORT=0
set ONNX_PROVIDER=DmlExecutionProvider
python onnx_embed_server.py

View file

@ -0,0 +1,4 @@
# Launches start_onnx_server.cmd without a visible terminal window.
# Use this with Task Scheduler (run only when user is logged on).
# Replaces start_onnx_silent.vbs, which is deprecated in Windows 11 24H2+.
Start-Process -FilePath "$PSScriptRoot\start_onnx_server.cmd" -WindowStyle Hidden

View file

@ -0,0 +1,2 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run Chr(34) & Replace(WScript.ScriptFullName, "start_onnx_silent.vbs", "start_onnx_server.cmd") & Chr(34), 0, False

5
vscode-ext/.vscodeignore Normal file
View file

@ -0,0 +1,5 @@
src/
node_modules/
.vscode-test/
*.map
tsconfig.json

45
vscode-ext/CLAUDE.md Normal file
View file

@ -0,0 +1,45 @@
# Claude — Bindery Extension Development
## What this is
The **Bindery** VS Code extension — a generic markdown book-authoring tool.
TypeScript, VS Code extension API. Located in `_src/vscode-ext/`.
## Architecture
```
src/
extension.ts ← activation, all commands, format-on-save handler
workspace.ts ← reads/writes .bindery/settings.json and .bindery/translations.json
merge.ts ← chapter discovery, markdown assembly, pandoc/LibreOffice invocation
format.ts ← typography transforms (curly quotes, em-dash, ellipsis)
```
## Key design rules
- **Generic**: no project-specific strings in source. Default strings like “Book”, not project names.
- **Config priority**: `.bindery/settings.json` → VS Code workspace settings → VS Code user settings → code defaults.
- **Machine paths only in VS Code settings**: `pandocPath`, `libreOfficePath` are never in the workspace file.
- **Substitution tiers**: built-in (merge.ts) → user-general (`bindery.generalSubstitutions`) → project (`.bindery/translations.json`). Later tiers win.
- **formatOnSave scope**: only fires for files inside the configured `storyFolder`.
## Workspace config files (`.bindery/`)
| File | Purpose |
|---|---|
| `settings.json` | Book metadata, project paths, language configs |
| `translations.json` | Per-language substitution rules (`type: substitution`) and glossaries (`type: glossary`) |
## Translation entry types
- `substitution` — auto-applied during export (word-by-word, e.g. US→UK).
- `glossary` — reference only, not auto-applied (e.g. EN→NL term table).
## Commands (all prefixed `bindery.`)
| Command | Description |
|---|---|
| `init` | Create `.bindery/settings.json` + `translations.json` |
| `formatDocument` / `formatFolder` | Apply typography formatting |
| `mergeMarkdown/Docx/Epub/Pdf/All` | Merge chapters and export |
| `findProbableUsToUkWords` | Scan EN source and surface US spellings |
| `addUkReplacement` | Add a substitution rule (project or general) |
## When suggesting changes
- Prefer editing the smallest surface area possible.
- Check that all command IDs match the `bindery.*` namespace throughout `package.json` AND `extension.ts`.
- Test mentally: does the new behaviour respect the config priority order?

21
vscode-ext/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Erik van der Boom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

148
vscode-ext/README.md Normal file
View file

@ -0,0 +1,148 @@
# Bindery
Markdown book authoring tools for VS Code: typography formatting (curly quotes, em-dashes, ellipses), multi-language chapter export (DOCX, EPUB, PDF), and dialect conversion.
## Features
### Typography Formatting
Converts plain-text typography to professional typographic characters:
| Input | Output | Description |
|-------|------—|-------------|
| `…` | `…` | Ellipsis |
| `—` | `—` | Em-dash (preserves `---` for markdown HR) |
| `”text”` | `”text”` | Curly double quotes |
| `text` | `text` | Curly single quotes |
| `dont` | `dont` | Smart apostrophes |
HTML comments are preserved and not modified.
**How to use:**
- **Right-click** a markdown file → **Format Typography**
- **Format Document** (`Shift+Alt+F`) — registered as a markdown formatter
- **Format on Save** — enable `bindery.formatOnSave` in settings
- **Explorer** — right-click a folder → **Format All Markdown in Folder**
### Chapter Merge & Export
Merges ordered chapter files into a single document with TOC generation:
- **Markdown** (`.md`) — with table of contents and separators
- **DOCX** (`.docx`) — via Pandoc, with page breaks and optional cover image
- **EPUB** (`.epub`) — via Pandoc, with chapter splitting and optional cover
- **PDF** (`.pdf`) — via Pandoc (intermediate DOCX) + LibreOffice headless conversion, giving consistent output quality across all platforms
For `UK` exports, the extension auto-generates `Story/UK` from `Story/EN` at export time, applies US→UK spelling conversion before merge, and removes `Story/UK` again after export completes.
**How to use:**
- **Editor toolbar** — click the $(book) **Bindery Export** button (visible on markdown files)
- **Command Palette** (`Ctrl+Shift+P`) → search “Bindery”
Supported commands:
- `Bindery: Merge Chapters → Markdown`
- `Bindery: Merge Chapters → DOCX`
- `Bindery: Merge Chapters → EPUB`
- `Bindery: Merge Chapters → PDF`
- `Bindery: Merge Chapters → All Formats`
- `Bindery: Find Probable US→UK Words`
- `Bindery: Add Substitution Rule`
- `Bindery: Initialise Workspace`
- `Bindery: Setup AI Assistant Files`
### File Discovery
The extension automatically discovers and orders your chapter files:
```
Story/
EN/ ← language folder
Prologue.md ← first
Act I - Awakening/ ← act folders (sorted by Roman numeral)
Chapter1.md ← chapters (sorted by number)
Chapter2.md
Act II - Resonance/
Chapter9.md
Epilogue.md ← last
```
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `bindery.storyFolder` | `”Story”` | Folder containing language subfolders |
| `bindery.languages` | EN | Language configurations (see below) |
| `bindery.mergedOutputDir` | `”Merged”` | Output directory for merged files |
| `bindery.author` | `””` | Author name for EPUB/DOCX metadata |
| `bindery.bookTitle` | `””` | Book title (auto-detected if empty) |
| `bindery.pandocPath` | `”pandoc”` | Path to Pandoc executable |
| `bindery.libreOfficePath` | `”libreoffice”` | Path to LibreOffice executable for PDF export. On Linux/WSL: `libreoffice` (default). On Windows: full path to `soffice.exe`, e.g. `C:\Program Files\LibreOffice\program\soffice.exe` |
| `bindery.formatOnSave` | `false` | Auto-format typography on save |
| `bindery.mergeFilePrefix` | `”Book”` | Prefix for output filenames |
| `bindery.generalSubstitutions` | `[]` | Dialect substitution rules applied across all projects |
### Dialect Conversion
Substitution rules are applied in tiers (later tiers win):
1. **Built-in** — common US→UK conversions
2. **General**`bindery.generalSubstitutions` in VS Code user settings
3. **Project**`.bindery/translations.json` in the workspace
You can extend conversion rules without editing code:
- Run `Bindery: Find Probable US→UK Words` to scan `Story/EN` for likely US spellings (like `-ize` forms), then add selected entries.
- Run `Bindery: Add Substitution Rule` to manually add one mapping.
### Language Configuration
Each language entry supports:
```json
{
“code”: “EN”,
“folderName”: “EN”,
“chapterWord”: “Chapter”,
“actPrefix”: “Act”,
“prologueLabel”: “Prologue”,
“epilogueLabel”: “Epilogue”
}
```
### Adding a New Language
Add to `.bindery/settings.json` (or `bindery.languages` in VS Code settings):
```json
{
“languages”: [
{ “code”: “EN”, “folderName”: “EN”, “chapterWord”: “Chapter”, “actPrefix”: “Act”, “prologueLabel”: “Prologue”, “epilogueLabel”: “Epilogue” },
{ “code”: “FR”, “folderName”: “FR”, “chapterWord”: “Chapitre”, “actPrefix”: “Acte”, “prologueLabel”: “Prologue”, “epilogueLabel”: “Épilogue” }
]
}
```
## Requirements
- **VS Code** 1.85+
- **Pandoc** (needed for DOCX/EPUB/PDF export) — [Install](https://pandoc.org/installing.html)
- **LibreOffice** (needed for PDF export) — used to convert the intermediate DOCX to PDF
- Linux/WSL: `sudo apt install libreoffice`
- Windows: [Download from libreoffice.org](https://www.libreoffice.org/download/download-libreoffice/), then set `bindery.libreOfficePath` to the full path of `soffice.exe` (e.g. `C:\Program Files\LibreOffice\program\soffice.exe`)
## Building from Source
```bash
cd vscode-ext
npm install
npm run compile
```
To install locally:
```bash
npm install -g @vscode/vsce
vsce package
code —install-extension bindery-0.2.0.vsix
```

BIN
vscode-ext/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

68
vscode-ext/icon.svg Normal file
View file

@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1e2e"/>
<stop offset="100%" style="stop-color:#151928"/>
</linearGradient>
<linearGradient id="pageL" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3a8a9e"/>
<stop offset="100%" style="stop-color:#2e7a8e"/>
</linearGradient>
<linearGradient id="pageR" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2a6e80"/>
<stop offset="100%" style="stop-color:#245e6e"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="128" height="128" rx="22" fill="url(#bg)"/>
<!-- Left page -->
<path d="M 16 30 Q 16 24 22 23 L 58 18 Q 62 17.5 64 21 L 64 102 Q 62 106 58 106.5 L 22 111 Q 16 112 16 106 Z"
fill="url(#pageL)" opacity="0.9"/>
<!-- Right page -->
<path d="M 112 30 Q 112 24 106 23 L 70 18 Q 66 17.5 64 21 L 64 102 Q 66 106 70 106.5 L 106 111 Q 112 112 112 106 Z"
fill="url(#pageR)" opacity="0.9"/>
<!-- Spine shadow -->
<rect x="61" y="17" width="6" height="92" rx="2" fill="#1a3a4a" opacity="0.7"/>
<!-- Text lines left page -->
<line x1="26" y1="42" x2="54" y2="39" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="52" x2="54" y2="50" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="62" x2="54" y2="61" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="72" x2="44" y2="71" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="26" y1="82" x2="54" y2="82" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="26" y1="92" x2="40" y2="92" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- Text lines right page -->
<line x1="74" y1="39" x2="102" y2="42" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="50" x2="102" y2="52" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="61" x2="102" y2="62" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="71" x2="92" y2="72" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<line x1="74" y1="82" x2="102" y2="82" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/>
<line x1="74" y1="92" x2="88" y2="92" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.3"/>
<!-- Binding stitches along the spine — the "bindery" signature -->
<g stroke="#d4a857" stroke-width="2.2" stroke-linecap="round" fill="none" opacity="0.9">
<!-- Cross stitches -->
<line x1="57" y1="30" x2="71" y2="38"/>
<line x1="71" y1="30" x2="57" y2="38"/>
<line x1="57" y1="48" x2="71" y2="56"/>
<line x1="71" y1="48" x2="57" y2="56"/>
<line x1="57" y1="66" x2="71" y2="74"/>
<line x1="71" y1="66" x2="57" y2="74"/>
<line x1="57" y1="84" x2="71" y2="92"/>
<line x1="71" y1="84" x2="57" y2="92"/>
</g>
<!-- Thread running along the spine (vertical connecting line) -->
<line x1="64" y1="26" x2="64" y2="96" stroke="#d4a857" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
<!-- Small needle at top -->
<ellipse cx="64" cy="22" rx="1.5" ry="3" fill="#e8c87a" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

58
vscode-ext/package-lock.json generated Normal file
View file

@ -0,0 +1,58 @@
{
"name": "fluxcore-tools",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fluxcore-tools",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.109.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz",
"integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

261
vscode-ext/package.json Normal file
View file

@ -0,0 +1,261 @@
{
"name": "bindery",
"displayName": "Bindery",
"description": "Markdown book authoring tools: typography formatting (curly quotes, em-dashes, ellipses), multi-language chapter export (DOCX, EPUB, PDF), and dialect conversion.",
"version": "0.2.0",
"publisher": "evdboom",
"repository": {
"type": "git",
"url": "https://github.com/evdboom/bindery"
},
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Formatters",
"Other"
],
"keywords": [
"markdown",
"book",
"writing",
"author",
"typography",
"epub",
"docx",
"merge",
"export",
"dialect"
],
"activationEvents": [
"onLanguage:markdown"
],
"main": "./out/extension.js",
"icon": "icon.png",
"contributes": {
"configuration": {
"title": "Bindery",
"properties": {
"bindery.pandocPath": {
"type": "string",
"default": "pandoc",
"description": "Path to the pandoc executable. Machine-specific — set in user settings, not workspace settings. Install from https://pandoc.org"
},
"bindery.libreOfficePath": {
"type": "string",
"default": "libreoffice",
"description": "Path to the LibreOffice executable for PDF export. Machine-specific — set in user settings. On Windows use the full path to soffice.exe, e.g. C:\\Program Files\\LibreOffice\\program\\soffice.exe"
},
"bindery.generalSubstitutions": {
"type": "array",
"default": [],
"description": "Dialect substitution rules applied across all projects (e.g. US→UK spelling). Applied after the built-in rules but before project-specific rules in .bindery/translations.json. Store in user settings so they follow you to every workspace.",
"items": {
"type": "object",
"required": ["from", "to"],
"properties": {
"from": { "type": "string", "description": "Source word (e.g. US English)" },
"to": { "type": "string", "description": "Target word (e.g. UK English)" }
}
}
},
"bindery.storyFolder": {
"type": "string",
"default": "Story",
"description": "Fallback story folder name if .bindery/settings.json is not present. Run 'Bindery: Initialise Workspace' to create the settings file."
},
"bindery.mergedOutputDir": {
"type": "string",
"default": "Merged",
"description": "Fallback output directory for merged files (relative to workspace root). Prefer .bindery/settings.json."
},
"bindery.mergeFilePrefix": {
"type": "string",
"default": "Book",
"description": "Fallback prefix for merged output filenames, e.g. 'Book' → Book_EN_Merged.md. Prefer .bindery/settings.json."
},
"bindery.author": {
"type": "string",
"default": "",
"description": "Fallback author name for EPUB/DOCX metadata. Prefer .bindery/settings.json."
},
"bindery.bookTitle": {
"type": "string",
"default": "",
"description": "Fallback book title for EPUB/DOCX metadata. Prefer .bindery/settings.json, which also supports per-language titles."
},
"bindery.formatOnSave": {
"type": "boolean",
"default": false,
"description": "Fallback: auto-apply typography formatting when saving markdown files inside the story folder. Prefer .bindery/settings.json."
},
"bindery.languages": {
"type": "array",
"default": [
{
"code": "EN",
"folderName": "EN",
"chapterWord": "Chapter",
"actPrefix": "Act",
"prologueLabel": "Prologue",
"epilogueLabel": "Epilogue"
}
],
"description": "Fallback language configurations for merge/export. Prefer .bindery/settings.json.",
"items": {
"type": "object",
"required": ["code", "folderName", "chapterWord", "actPrefix", "prologueLabel", "epilogueLabel"],
"properties": {
"code": { "type": "string" },
"folderName": { "type": "string" },
"chapterWord": { "type": "string" },
"actPrefix": { "type": "string" },
"prologueLabel": { "type": "string" },
"epilogueLabel": { "type": "string" }
}
}
}
}
},
"commands": [
{
"command": "bindery.init",
"title": "Initialise Workspace",
"category": "Bindery"
},
{
"command": "bindery.setupAI",
"title": "Setup AI Assistant Files",
"category": "Bindery"
},
{
"command": "bindery.formatDocument",
"title": "Format Typography",
"category": "Bindery"
},
{
"command": "bindery.formatFolder",
"title": "Format All Markdown in Folder",
"category": "Bindery"
},
{
"command": "bindery.mergeMarkdown",
"title": "Merge Chapters → Markdown",
"category": "Bindery"
},
{
"command": "bindery.mergeDocx",
"title": "Merge Chapters → DOCX",
"category": "Bindery"
},
{
"command": "bindery.mergeEpub",
"title": "Merge Chapters → EPUB",
"category": "Bindery"
},
{
"command": "bindery.mergePdf",
"title": "Merge Chapters → PDF",
"category": "Bindery"
},
{
"command": "bindery.mergeAll",
"title": "Merge Chapters → All Formats",
"category": "Bindery"
},
{
"command": "bindery.findProbableUsToUkWords",
"title": "Find Probable US→UK Words",
"category": "Bindery"
},
{
"command": "bindery.addUkReplacement",
"title": "Add Substitution Rule",
"category": "Bindery"
}
],
"menus": {
"editor/context": [
{
"command": "bindery.formatDocument",
"when": "resourceLangId == markdown",
"group": "1_modification@10"
},
{
"command": "bindery.findProbableUsToUkWords",
"when": "resourceLangId == markdown",
"group": "1_modification@11"
},
{
"command": "bindery.addUkReplacement",
"when": "resourceLangId == markdown",
"group": "1_modification@12"
},
{
"command": "bindery.mergeMarkdown",
"when": "resourceLangId == markdown",
"group": "1_modification@13"
}
],
"explorer/context": [
{
"command": "bindery.formatDocument",
"when": "resourceLangId == markdown",
"group": "7_modification@10"
},
{
"command": "bindery.formatFolder",
"when": "explorerResourceIsFolder",
"group": "7_modification@11"
},
{
"command": "bindery.mergeMarkdown",
"when": "explorerResourceIsFolder",
"group": "7_modification@12"
},
{
"command": "bindery.findProbableUsToUkWords",
"when": "resourceLangId == markdown",
"group": "7_modification@13"
},
{
"command": "bindery.addUkReplacement",
"when": "resourceLangId == markdown",
"group": "7_modification@14"
}
],
"editor/title": [
{
"submenu": "bindery.exportMenu",
"when": "resourceLangId == markdown",
"group": "navigation@100"
}
],
"bindery.exportMenu": [
{ "command": "bindery.mergeMarkdown", "group": "1_merge@1" },
{ "command": "bindery.mergeDocx", "group": "1_merge@2" },
{ "command": "bindery.mergeEpub", "group": "1_merge@3" },
{ "command": "bindery.mergePdf", "group": "1_merge@4" },
{ "command": "bindery.mergeAll", "group": "2_all@1" }
]
},
"submenus": [
{
"id": "bindery.exportMenu",
"label": "Bindery Export",
"icon": "$(book)"
}
]
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"lint": "eslint src --ext ts"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
}
}

533
vscode-ext/src/ai-setup.ts Normal file
View file

@ -0,0 +1,533 @@
/**
* Bindery AI assistant setup
*
* Generates AI assistant instruction files and Claude skill templates from
* the project's .bindery/settings.json. Each target produces different files:
*
* claude CLAUDE.md + .claude/skills/<skill>/SKILL.md
* copilot .github/copilot-instructions.md
* cursor .cursor/rules
* agents AGENTS.md (OpenAI Agents, Aider, Codex, etc.)
*/
import * as fs from 'fs';
import * as path from 'path';
import type { WorkspaceSettings } from './workspace';
import type { LanguageConfig } from './merge';
// ─── Public types ─────────────────────────────────────────────────────────────
export type AiTarget = 'claude' | 'copilot' | 'cursor' | 'agents';
export interface AiSetupOptions {
root: string;
settings: WorkspaceSettings;
targets: AiTarget[];
/** Skills to generate for the claude target. */
skills?: SkillTemplate[];
/** Overwrite existing files? Default false (skip existing). */
overwrite?: boolean;
}
export interface AiSetupResult {
created: string[]; // files created
skipped: string[]; // files that existed and were not overwritten
}
export type SkillTemplate =
| 'review'
| 'brainstorm'
| 'memory'
| 'translate'
| 'status'
| 'continuity'
| 'read_aloud';
export const ALL_SKILLS: SkillTemplate[] = [
'review', 'brainstorm', 'memory', 'translate', 'status', 'continuity', 'read_aloud',
];
// ─── Entry point ──────────────────────────────────────────────────────────────
export function setupAiFiles(options: AiSetupOptions): AiSetupResult {
const { root, settings, targets, skills = ALL_SKILLS, overwrite = false } = options;
const result: AiSetupResult = { created: [], skipped: [] };
const ctx = buildContext(settings);
for (const target of targets) {
switch (target) {
case 'claude':
writeFile(root, 'CLAUDE.md', claudeMd(ctx), overwrite, result);
for (const skill of skills) {
const skillDir = path.join('.claude', 'skills', skill);
writeFile(root, path.join(skillDir, 'SKILL.md'), skillMd(skill, ctx), overwrite, result);
}
break;
case 'copilot':
writeFile(root, path.join('.github', 'copilot-instructions.md'), copilotMd(ctx), overwrite, result);
break;
case 'cursor':
writeFile(root, path.join('.cursor', 'rules'), cursorRules(ctx), overwrite, result);
break;
case 'agents':
writeFile(root, 'AGENTS.md', agentsMd(ctx), overwrite, result);
break;
}
}
return result;
}
// ─── Template context ─────────────────────────────────────────────────────────
interface TemplateContext {
title: string;
author: string;
description: string;
genre: string;
audience: string;
storyFolder: string;
notesFolder: string;
arcFolder: string;
languages: LanguageConfig[];
langList: string; // e.g. "EN (source), NL (translation)"
hasMultiLang: boolean;
}
function buildContext(s: WorkspaceSettings): TemplateContext {
const title = (typeof s.bookTitle === 'string' ? s.bookTitle : undefined) ?? 'Untitled';
const author = s.author ?? '';
const description = s.description ?? '';
const genre = s.genre ?? '';
const audience = s.targetAudience ?? '';
const storyFolder = s.storyFolder ?? 'Story';
const notesFolder = 'Notes';
const arcFolder = 'Arc';
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, arcFolder, languages, langList, hasMultiLang: languages.length > 1 };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function writeFile(
root: string,
relPath: string,
content: string,
overwrite: boolean,
result: AiSetupResult
): void {
const full = path.join(root, relPath);
if (fs.existsSync(full) && !overwrite) {
result.skipped.push(relPath);
return;
}
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf-8');
result.created.push(relPath);
}
function audienceNote(ctx: TemplateContext): string {
return ctx.audience ? `Target audience: ${ctx.audience}.` : '';
}
function languageSection(ctx: TemplateContext): string {
if (!ctx.hasMultiLang) { return ''; }
return `\nLanguages: ${ctx.langList}.\n`;
}
// ─── CLAUDE.md ────────────────────────────────────────────────────────────────
function claudeMd(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const lines: string[] = [
`# Claude — ${title}`,
'',
'## Project',
];
if (genre) { lines.push(`Genre: ${genre}.`); }
if (description) { lines.push(description); }
if (ctx.audience){ lines.push(audienceNote(ctx)); }
if (author) { lines.push(`Author: ${author}.`); }
lines.push(languageSection(ctx));
lines.push(
'## Start of session',
`1. Read COWORK.md (if present) for current focus and context.`,
`2. Read ${notesFolder}/Memories/global.md for cross-chapter decisions.`,
`3. If working on a specific chapter, read ${notesFolder}/Memories/chXX.md if it exists.`,
'',
'## Repo layout',
'```',
`${arcFolder}/ ← story arc files (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`,
`${notesFolder}/ ← story bible (characters, world, translation table, memories)`,
`${storyFolder}/`,
...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`),
'```',
'',
'## Writing rules',
'- Never rewrite paragraphs unless explicitly asked. Suggest edits only.',
'- HTML comments `<!-- -->` in chapter files are writer notes. Treat as context, not prose.',
'- Quotation marks and dashes in chapter files are managed by the Bindery extension. Do not flag these as formatting errors.',
);
if (ctx.audience) {
lines.push(`- Content is aimed at ${ctx.audience}. Keep language accessible and themes age-appropriate.`);
}
lines.push(
'',
'## Key reference files',
'| File | Contains |',
'|---|---|',
`| \`${arcFolder}/Overall.md\` | Full story arc |`,
`| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`,
`| \`${notesFolder}/Details_Characters.md\` | Character profiles |`,
`| \`${notesFolder}/Details_World_and_Magic.md\` | World rules and magic system |`,
`| \`${notesFolder}/Details_Translation_notes.md\` | Term translations / glossary |`,
`| \`${notesFolder}/Memories/global.md\` | Cross-session decisions |`,
'',
'## Available skills',
'Use these slash commands to trigger structured workflows:',
'| Command | Purpose |',
'|---|---|',
'| `/review` | Review a chapter for language, arc consistency, and age-appropriateness |',
'| `/brainstorm` | Generate plot/character/scene ideas |',
'| `/memory` | Update memory files and compact if needed |',
'| `/translate` | Assist with chapter translation |',
'| `/status` | Book progress snapshot |',
'| `/continuity` | Check a chapter for consistency errors |',
'| `/read-aloud` | Test how a passage reads when spoken |',
);
return lines.filter(l => l !== '\n').join('\n') + '\n';
}
// ─── .github/copilot-instructions.md ─────────────────────────────────────────
function copilotMd(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const lines: string[] = [`# GitHub Copilot — ${title}`, ''];
if (genre || description || ctx.audience) {
lines.push('## Project');
if (genre) { lines.push(`${genre} novel.`); }
if (description) { lines.push(description); }
if (ctx.audience){ lines.push(audienceNote(ctx)); }
if (author) { lines.push(`Author: ${author}.`); }
lines.push(languageSection(ctx), '');
}
lines.push(
'## Repo layout',
'```',
`${arcFolder}/ ← story arc files`,
`${notesFolder}/ ← story bible, translation table, memories`,
`${storyFolder}/`,
...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters`),
'```',
'',
'## Writing guidelines',
'- HTML comments `<!-- -->` in chapter files are writer notes — treat as context only.',
'- Quotation marks and dashes are managed by the Bindery VS Code extension. Do not normalise them.',
'- Check `Notes/Details_Translation_notes.md` before using or translating world-specific terms.',
);
if (ctx.audience) {
lines.push(`- Content targets ${ctx.audience}. Keep vocabulary accessible and themes appropriate.`);
}
return lines.join('\n') + '\n';
}
// ─── .cursor/rules ────────────────────────────────────────────────────────────
function cursorRules(ctx: TemplateContext): string {
const { title, storyFolder, notesFolder, arcFolder } = ctx;
const lines: string[] = [
`# Cursor rules — ${title}`,
'',
`Story folder: \`${storyFolder}/\``,
`Notes folder: \`${notesFolder}/\``,
`Arc folder: \`${arcFolder}/\` (Overall.md, Act_I_*.md, Act_II_*.md, Act_III_*.md)`,
'',
'## Context files to read',
`- \`${notesFolder}/Memories/global.md\` — cross-chapter decisions (read at start of session)`,
`- \`${arcFolder}/Overall.md\` — full story arc`,
`- \`${notesFolder}/Details_Characters.md\` — character profiles`,
`- \`${notesFolder}/Details_World_and_Magic.md\` — world rules`,
`- \`${notesFolder}/Details_Translation_notes.md\` — term translations`,
'',
'## Rules',
'- HTML comments `<!-- -->` in chapter files are writer notes. Treat as context, not story content.',
'- Do not normalise quotation marks or dashes — these are managed by the Bindery extension.',
'- Do not rewrite prose unless explicitly asked. Suggest edits only.',
];
if (ctx.audience) {
lines.push(`- Target audience is ${ctx.audience}. Flag content that is too complex or inappropriate.`);
}
return lines.join('\n') + '\n';
}
// ─── AGENTS.md ────────────────────────────────────────────────────────────────
function agentsMd(ctx: TemplateContext): string {
const { title, author, description, genre, storyFolder, notesFolder, arcFolder } = ctx;
const lines: string[] = [`# Agent Instructions — ${title}`, ''];
lines.push('## Project overview');
if (genre) { lines.push(`${genre} novel.`); }
if (description) { lines.push(description); }
if (ctx.audience){ lines.push(audienceNote(ctx)); }
if (author) { lines.push(`Author: ${author}.`); }
lines.push(languageSection(ctx), '');
lines.push(
'## Start of session',
`1. Read \`${notesFolder}/Memories/global.md\` for cross-chapter context.`,
`2. If working on a specific chapter, read \`${notesFolder}/Memories/chXX.md\` if it exists.`,
`3. Check \`${notesFolder}/Details_Translation_notes.md\` before using or translating world-specific terms.`,
'',
'## Story files',
`- Chapter files are \`.md\` files in \`${storyFolder}/\`, organised in act subfolders.`,
'- HTML comments `<!-- -->` are writer notes — treat as context only, not prose.',
'- Quotation marks and em-dashes are managed by the Bindery extension. Do not normalise them.',
'',
'## Writing guidelines',
'- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.',
);
if (ctx.audience) {
lines.push(`- Audience is ${ctx.audience}. Keep vocabulary clear and themes age-appropriate.`);
}
lines.push(
'',
'## Key reference files',
'| File | Contains |',
'|---|---|',
`| \`${arcFolder}/Overall.md\` | Full story arc |`,
`| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`,
`| \`${notesFolder}/Details_Characters.md\` | Character profiles |`,
`| \`${notesFolder}/Details_World_and_Magic.md\` | World rules |`,
`| \`${notesFolder}/Details_Translation_notes.md\` | EN ↔ translation term table |`,
`| \`${notesFolder}/Memories/global.md\` | Cross-session decisions |`,
);
return lines.join('\n') + '\n';
}
// ─── Skill templates ──────────────────────────────────────────────────────────
function skillMd(skill: SkillTemplate, ctx: TemplateContext): string {
const { title, storyFolder, notesFolder, arcFolder, audience } = ctx;
const audienceStr = audience ? audience : 'the target audience';
switch (skill) {
case 'review': return `# Skill: /review
Review a chapter of "${title}" and give structured feedback.
## Trigger
User says \`/review\`, "review chapter X", "quick review", or "review my changes".
## Clarify first
- Which chapter number?
- Type: **Full** (language + arc + age-appropriateness) or **Quick** (language and typos only)?
## Steps
### 1. Load context
- Read \`${notesFolder}/Memories/global.md\`
- For a Full review, read the relevant arc file: \`${arcFolder}/Act_I_Awakening.md\`, \`Act_II_Resonance.md\`, or \`Act_III_Collapse.md\`
- For "review changes", use the git diff tool if available
### 2. Perform the review
**Quick** language and typos only.
**Full** adds:
- Arc consistency with the arc file
- Age-appropriateness for ${audienceStr}
- Character consistency with Notes/Details_Characters.md
### 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
## Rules
- Do not rewrite unless asked suggest only
- Respond in English always
`;
case 'brainstorm': return `# Skill: /brainstorm
Brainstorm story ideas, character moments, or plot solutions for "${title}".
## Trigger
User says \`/brainstorm\`, "I'm stuck", "help me think of ideas", or "Am I stuck?".
## Clarify first
- Focus: plot beat / character moment / scene idea / chapter opening-closing?
- Which chapter or story point?
- Any constraints to respect?
## Steps
1. Read \`${notesFolder}/Memories/global.md\` and the relevant arc file from \`${arcFolder}/\`.
2. If character-focused, read \`${notesFolder}/Details_Characters.md\`.
3. Generate 35 concrete ideas that fit the arc and feel true to the characters.
## Output format
**Option A [short title]**
[3-5 sentence description]
...
End with a brief note on which options feel most aligned with the arc.
## Rules
- Respect established world rules and character voices
- Keep ideas appropriate for ${audienceStr}
- Respond in English always
`;
case 'memory': return `# Skill: /memory
Update project memory files with decisions from the current session.
## Trigger
User says \`/memory\`, "save this to memory", "update memories", or at session end.
## Steps
1. Identify decisions/insights from the session.
2. Write to \`${notesFolder}/Memories/global.md\` (cross-chapter) or \`${notesFolder}/Memories/chXX.md\` (chapter-specific).
3. Append only never edit existing entries.
4. Format: \`**[YYYY-MM-DD]:** - [decision]\`
5. If a file exceeds ~150 lines, offer to compact the oldest 50% into a summary block.
## Rules
- Append only
- Date every entry
- Compaction is always opt-in
- Respond in English always
`;
case 'translate': return `# Skill: /translate
Translate a chapter or passage into the target language.
## Trigger
User says \`/translate\`, "translate chapter X", or "help me with the translation".
## Clarify first
- Which chapter or passage?
- Full translation or spot-check an existing translation?
## Steps
1. Read \`${notesFolder}/Details_Translation_notes.md\` for world-specific term translations.
2. Read the source chapter from \`${storyFolder}/\`.
3. Translate paragraph by paragraph, applying all terms from the translation table.
4. Output the translation in a fenced \`\`\`markdown code block for easy pasting.
For spot-check mode, use a feedback table instead of a full translation.
## Rules
- Always consult the translation table first never invent translations for world-specific terms
- Flag uncertain terms rather than guessing
- Respond in English in explanations
`;
case 'status': return `# Skill: /status
Snapshot of the book's progress: what's done, in progress, and coming up.
## Trigger
User says \`/status\`, "what's the book status", or "where are we".
## Steps
1. Read COWORK.md (current focus), \`${notesFolder}/Chapter_Status.md\`, and \`${notesFolder}/Memories/global.md\`.
2. Check \`${arcFolder}/\` for what's planned vs written (Overall.md + the relevant act file).
3. Output: overall count / done / in-progress / coming up (next 2-3 chapters) / open questions.
## Output
Keep it scannable bold headers, short lines. This is a working tool, not a narrative summary.
`;
case 'continuity': return `# Skill: /continuity
Cross-check a chapter for consistency errors.
## Trigger
User says \`/continuity\`, "check continuity", or "check chapter X for errors".
## Clarify first
- Chapter number?
- Focus: All / Characters / World rules / Timeline?
## Steps
1. Read the chapter, \`${notesFolder}/Memories/global.md\`, and \`${notesFolder}/Details_Characters.md\`.
2. For world rules: read \`${notesFolder}/Details_World_and_Magic.md\`.
3. For timeline: also read the previous chapter.
## Output format
| Type | Location | Issue | Reference |
|---|---|---|---|
| Character | Line X | Description contradicts... | global.md |
End with a one-line overall assessment. If no issues found, say so clearly.
## Rules
- Flag issues only do not suggest rewrites
- Phrase uncertain items as questions, not errors
- Respond in English always
`;
case 'read_aloud': return `# Skill: /read-aloud
Test how a chapter sounds when read aloud to ${audienceStr}.
## Trigger
User says \`/read-aloud\`, "reading test", or "how does this sound".
## Clarify first
- Whole chapter or specific passage?
## What to check
- Sentences over ~30 words
- Sequences of 3+ short sentences (staccato)
- Vocabulary too complex for ${audienceStr}
- Said-bookisms in dialogue ("she exclaimed breathlessly" prefer "said" or action beat)
- Paragraphs over 8 lines without a break
- Accidental word repetition within 2-3 sentences
## Output format
| Type | Location | Flagged text | Note |
|---|---|---|---|
| Long sentence | Para 3 | "..." (34 words) | Consider splitting |
Brief overall impression (2-3 sentences) after the table.
## Rules
- Focus on how it sounds when spoken not a content review
- Suggestions are gentle ("consider", not "must change")
- Respond in English always
`;
}
}

851
vscode-ext/src/extension.ts Normal file
View file

@ -0,0 +1,851 @@
/**
* Bindery VS Code Extension
*
* Markdown book authoring tools:
* - Typography formatting (curly quotes, em-dashes, ellipses)
* - Chapter merge/export Markdown, DOCX, EPUB, PDF
* - Dialect conversion (US UK, extensible via translations.json)
*
* Workspace configuration lives in .bindery/settings.json and .bindery/translations.json.
* VS Code settings serve as a fallback; machine-specific paths (pandoc, LibreOffice)
* are intentionally kept in VS Code settings only.
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { updateTypography } from './format';
import {
mergeBook, checkPandoc, getBuiltInUkReplacements,
type LanguageConfig, type OutputType, type MergeOptions, type UkReplacement,
} from './merge';
import {
readWorkspaceSettings, readTranslations,
getBinderyFolder, getSettingsPath, getTranslationsPath,
getBookTitleForLang, getSubstitutionRules, getIgnoredWords,
upsertSubstitutionRule, addIgnoredWords,
type WorkspaceSettings, type TranslationsFile,
} from './workspace';
import {
setupAiFiles, ALL_SKILLS,
type AiTarget, type SkillTemplate,
} from './ai-setup';
// ─── Known language presets ───────────────────────────────────────────────────
const KNOWN_LANGUAGES: Record<string, LanguageConfig> = {
EN: { code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' },
NL: { code: 'NL', folderName: 'NL', chapterWord: 'Hoofdstuk', actPrefix: 'Deel', prologueLabel: 'Proloog', epilogueLabel: 'Epiloog' },
UK: { code: 'UK', folderName: 'UK', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' },
FR: { code: 'FR', folderName: 'FR', chapterWord: 'Chapitre', actPrefix: 'Acte', prologueLabel: 'Prologue', epilogueLabel: 'Épilogue' },
DE: { code: 'DE', folderName: 'DE', chapterWord: 'Kapitel', actPrefix: 'Teil', prologueLabel: 'Prolog', epilogueLabel: 'Epilog' },
ES: { code: 'ES', folderName: 'ES', chapterWord: 'Capítulo', actPrefix: 'Acto', prologueLabel: 'Prólogo', epilogueLabel: 'Epílogo' },
};
const DEFAULT_LANGUAGE: LanguageConfig = KNOWN_LANGUAGES.EN;
// ─── VS Code config + workspace helpers ──────────────────────────────────────
function getVscConfig() {
return vscode.workspace.getConfiguration('bindery');
}
function getWorkspaceRoot(): string | undefined {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
interface EffectiveConfig {
storyFolder: string;
mergedOutputDir: string;
mergeFilePrefix: string;
formatOnSave: boolean;
author: string | undefined;
bookTitle: string | undefined;
languages: LanguageConfig[];
pandocPath: string;
libreOfficePath: string;
}
/**
* Merge workspace file settings with VS Code settings.
* Workspace file wins for every key it defines.
* pandoc/libreOffice paths are always taken from VS Code settings (machine-specific).
*/
function getEffectiveConfig(wsSettings: WorkspaceSettings | null): EffectiveConfig {
const vsc = getVscConfig();
return {
storyFolder: wsSettings?.storyFolder ?? vsc.get<string>('storyFolder') ?? 'Story',
mergedOutputDir: wsSettings?.mergedOutputDir ?? vsc.get<string>('mergedOutputDir') ?? 'Merged',
mergeFilePrefix: wsSettings?.mergeFilePrefix ?? vsc.get<string>('mergeFilePrefix') ?? 'Book',
formatOnSave: wsSettings?.formatOnSave ?? vsc.get<boolean>('formatOnSave') ?? false,
author: (wsSettings?.author || vsc.get<string>('author')) || undefined,
bookTitle: (typeof wsSettings?.bookTitle === 'string' ? wsSettings.bookTitle : undefined)
|| vsc.get<string>('bookTitle')
|| undefined,
languages: wsSettings?.languages
?? vsc.get<LanguageConfig[]>('languages')
?? [DEFAULT_LANGUAGE],
pandocPath: vsc.get<string>('pandocPath') ?? 'pandoc',
libreOfficePath: vsc.get<string>('libreOfficePath') ?? 'libreoffice',
};
}
/** True if filePath is inside <root>/<storyFolder>/. */
function isInsideStoryFolder(filePath: string, root: string, storyFolder: string): boolean {
// Normalise separators so Windows paths compare correctly
const norm = (p: string) => p.replace(/\\/g, '/');
const story = norm(path.join(root, storyFolder));
const file = norm(filePath);
return file.startsWith(story + '/');
}
function isUkLanguage(lang: LanguageConfig): boolean {
const c = lang.code.trim().toUpperCase();
return c === 'UK' || c === 'EN-GB';
}
function languageCanExport(root: string, storyFolder: string, lang: LanguageConfig): boolean {
if (isUkLanguage(lang)) {
return fs.existsSync(path.join(root, storyFolder, 'EN'));
}
return fs.existsSync(path.join(root, storyFolder, lang.folderName));
}
// ─── Substitution tier helpers ────────────────────────────────────────────────
//
// Tier 1 (built-in) — UK_REPLACEMENTS array inside merge.ts, always applied first.
// Tier 2 (general) — bindery.generalSubstitutions in VS Code *user* settings.
// Words you want across every project (e.g. recognize→recognise).
// Tier 3 (project) — .bindery/translations.json → en-gb entry.
// Terms specific to this book/world.
//
// Later tiers win on conflict.
function getGeneralSubstitutions(): UkReplacement[] {
const entries = getVscConfig().get<Array<{ from?: string; to?: string }>>('generalSubstitutions') ?? [];
return entries
.filter(e => e?.from?.trim() && e?.to?.trim())
.map(e => ({ us: e.from!.trim().toLowerCase(), uk: e.to!.trim() }));
}
/**
* Build the combined substitution list passed to merge.ts.
* merge.ts applies tier 1 (built-ins) internally; this function merges tiers 2 + 3.
*/
function buildCombinedSubstitutions(translations: TranslationsFile | null): UkReplacement[] {
const general = getGeneralSubstitutions();
const project = getSubstitutionRules(translations, 'en-gb');
const map = new Map<string, string>();
for (const r of general) { map.set(r.us, r.uk); }
for (const r of project) { map.set(r.us, r.uk); } // project overrides general
return Array.from(map.entries()).map(([us, uk]) => ({ us, uk }));
}
/**
* Combined ignored-words from translations.json (primary) and legacy
* bindery.ukIgnoredWords VS Code setting (fallback / migration path).
*/
function getAllIgnoredWords(translations: TranslationsFile | null): Set<string> {
const result = getIgnoredWords(translations, 'en-gb');
for (const word of getVscConfig().get<string[]>('ukIgnoredWords') ?? []) {
const w = word.trim().toLowerCase();
if (w) { result.add(w); }
}
return result;
}
async function upsertGeneralSubstitution(rule: { from: string; to: string }): Promise<void> {
const config = getVscConfig();
const current = config.get<Array<{ from?: string; to?: string }>>('generalSubstitutions') ?? [];
const map = new Map<string, string>(
current
.filter(e => e.from?.trim() && e.to?.trim())
.map(e => [e.from!.trim().toLowerCase(), e.to!.trim()])
);
map.set(rule.from.toLowerCase(), rule.to);
const updated = Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([from, to]) => ({ from, to }));
await config.update('generalSubstitutions', updated, vscode.ConfigurationTarget.Global);
}
// ─── UK spelling suggestion ───────────────────────────────────────────────────
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'); }
return undefined;
}
const PROBABLE_US_RE = /\b([A-Za-z]+(?:ization|izations|izing|ized|izes|ize|yzing|yzed|yzes|yze)|color(?:s|ed|ing)?|center(?:s|ed|ing)?|favorite(?:s)?|favor(?:s|ed|ing)?|traveled|traveling|traveler|travelers|canceled|canceling|gray|fiber|defense|offense|mom)\b/g;
// ─── Language auto-detection (for init) ──────────────────────────────────────
function detectLanguageFolders(storyPath: string): LanguageConfig[] {
if (!fs.existsSync(storyPath)) { return []; }
const detected: LanguageConfig[] = [];
for (const entry of fs.readdirSync(storyPath, { withFileTypes: true })) {
if (!entry.isDirectory()) { continue; }
const code = entry.name.toUpperCase();
if (KNOWN_LANGUAGES[code]) {
detected.push(KNOWN_LANGUAGES[code]);
} else if (/^[A-Z]{2,3}$/.test(code)) {
// Unknown code — add with English defaults; user can edit settings.json
detected.push({
code,
folderName: entry.name,
chapterWord: 'Chapter',
actPrefix: 'Act',
prologueLabel: 'Prologue',
epilogueLabel: 'Epilogue',
});
}
}
return detected;
}
// ─── Command: Init workspace ─────────────────────────────────────────────────
async function initWorkspaceCommand() {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const settingsPath = getSettingsPath(root);
if (fs.existsSync(settingsPath)) {
const choice = await vscode.window.showQuickPick(
[
{ label: 'Re-initialise', description: 'Overwrites settings.json (translations.json is kept)', value: true as const },
{ label: 'Cancel', value: false as const },
],
{ placeHolder: '.bindery/settings.json already exists' }
);
if (!choice?.value) { return; }
}
const title = await vscode.window.showInputBox({
title: 'Bindery: Initialise (1/4)',
prompt: 'Book title',
placeHolder: 'e.g. The Hollow Road',
});
if (title === undefined) { return; }
const author = await vscode.window.showInputBox({
title: 'Bindery: Initialise (2/4)',
prompt: 'Author name',
placeHolder: 'e.g. Jane Smith',
});
if (author === undefined) { return; }
const storyFolder = await vscode.window.showInputBox({
title: 'Bindery: Initialise (3/4)',
prompt: 'Story folder name (relative to workspace root)',
value: 'Story',
});
if (!storyFolder) { return; }
const audience = await vscode.window.showInputBox({
title: 'Bindery: Initialise (4/5)',
prompt: 'Target audience (used for AI review feedback)',
placeHolder: 'e.g. 12+, adults, 8-10',
});
if (audience === undefined) { return; }
const formatOption = await vscode.window.showQuickPick(
[
{ label: 'No', value: false as const },
{ label: 'Yes', value: true as const },
],
{ title: 'Bindery: Initialise (5/5)', placeHolder: 'Auto-apply typography on save (Story folder only)?' }
);
if (!formatOption) { return; }
// Detect existing language folders to pre-populate languages array
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 settings: WorkspaceSettings = {
...(title ? { bookTitle: title } : {}),
...(author ? { author } : {}),
...(audience ? { targetAudience: audience } : {}),
storyFolder,
mergedOutputDir: 'Merged',
mergeFilePrefix: slug,
formatOnSave: formatOption.value,
languages,
};
const binderyFolder = getBinderyFolder(root);
fs.mkdirSync(binderyFolder, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
// Create translations.json only if it does not yet exist
const translationsPath = getTranslationsPath(root);
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');
}
const langNote = detectedLangs.length > 0
? ` Detected: ${detectedLangs.map(l => l.code).join(', ')}.`
: '';
const action = await vscode.window.showInformationMessage(
`Bindery workspace initialised.${langNote}`,
'Open settings.json',
'Open translations.json'
);
if (action === 'Open settings.json') {
vscode.window.showTextDocument(await vscode.workspace.openTextDocument(settingsPath));
} else if (action === 'Open translations.json') {
vscode.window.showTextDocument(await vscode.workspace.openTextDocument(translationsPath));
}
}
// ─── Command: Add substitution rule ──────────────────────────────────────────
async function addUkReplacementCommand() {
const root = getWorkspaceRoot();
const us = await vscode.window.showInputBox({
title: 'Add Substitution Rule — source word',
prompt: 'US English word',
placeHolder: 'e.g. airplane',
});
if (!us) { return; }
const suggested = suggestUkSpelling(us) ?? '';
const uk = await vscode.window.showInputBox({
title: 'Add Substitution Rule — target word',
prompt: 'UK English word',
value: suggested,
placeHolder: 'e.g. aeroplane',
});
if (!uk) { return; }
const scope = await vscode.window.showQuickPick(
[
{ label: 'This project only', description: 'Saved to .bindery/translations.json', value: 'project' as const },
{ label: 'All projects', description: 'Saved to your VS Code user settings', value: 'general' as const },
],
{ placeHolder: 'Where should this rule be saved?' }
);
if (!scope) { return; }
if (scope.value === 'project') {
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
upsertSubstitutionRule(root, 'en-gb', { from: us.toLowerCase(), to: uk });
vscode.window.showInformationMessage(`Saved to .bindery/translations.json: ${us.toLowerCase()}${uk}`);
} else {
await upsertGeneralSubstitution({ from: us.toLowerCase(), to: uk });
vscode.window.showInformationMessage(`Saved to general user settings: ${us.toLowerCase()}${uk}`);
}
}
// ─── Command: Find probable US→UK words ──────────────────────────────────────
async function findProbableUsToUkWordsCommand() {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const wsSettings = readWorkspaceSettings(root);
const translations = readTranslations(root);
const storyFolder = getEffectiveConfig(wsSettings).storyFolder;
const enPath = path.join(root, storyFolder, 'EN');
if (!fs.existsSync(enPath)) {
vscode.window.showErrorMessage(`EN source folder not found: ${enPath}`);
return;
}
// Scan EN story files for probable US words
const found = new Set<string>();
const stack = [enPath];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { stack.push(fullPath); continue; }
if (!entry.isFile() || !entry.name.endsWith('.md')) { continue; }
const lines = fs.readFileSync(fullPath, 'utf-8').split(/\r?\n/);
let inFence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) { continue; }
PROBABLE_US_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PROBABLE_US_RE.exec(line)) !== null) {
found.add(m[1].toLowerCase());
}
}
}
}
// Filter out already-handled words
const configuredProject = new Set(getSubstitutionRules(translations, 'en-gb').map(r => r.us));
const configuredGeneral = new Set(getGeneralSubstitutions().map(r => r.us));
const builtIn = new Set(getBuiltInUkReplacements().map(r => r.us.toLowerCase()));
const ignored = getAllIgnoredWords(translations);
const candidates = Array.from(found)
.filter(w => !ignored.has(w) && !configuredProject.has(w) && !configuredGeneral.has(w) && !builtIn.has(w))
.map(w => ({ us: w, uk: suggestUkSpelling(w) }))
.filter(c => !!c.uk)
.sort((a, b) => a.us.localeCompare(b.us));
if (candidates.length === 0) {
vscode.window.showInformationMessage(`No new probable US→UK words found in ${storyFolder}/EN.`);
return;
}
const picks = await vscode.window.showQuickPick(
candidates.map(c => ({
label: `${c.us}${c.uk!}`,
description: '',
us: c.us,
uk: c.uk!,
})),
{ canPickMany: true, placeHolder: 'Select words, then choose an action' }
);
if (!picks || picks.length === 0) { return; }
const action = await vscode.window.showQuickPick(
[
{ label: 'Add — this project', description: 'To .bindery/translations.json', value: 'project' as const },
{ label: 'Add — all projects', description: 'To your VS Code user settings', value: 'general' as const },
{ label: 'Ignore', description: 'Add to ignored list for this project', value: 'ignore' as const },
],
{ placeHolder: `Action for ${picks.length} selected word(s)` }
);
if (!action) { return; }
if (action.value === 'project') {
for (const pick of picks) {
upsertSubstitutionRule(root, 'en-gb', { from: pick.us, to: pick.uk });
}
vscode.window.showInformationMessage(`Added ${picks.length} rule(s) to .bindery/translations.json.`);
} else if (action.value === 'general') {
for (const pick of picks) {
await upsertGeneralSubstitution({ from: pick.us, to: pick.uk });
}
vscode.window.showInformationMessage(`Added ${picks.length} rule(s) to general user settings.`);
} else {
const added = addIgnoredWords(root, 'en-gb', picks.map(p => p.us));
vscode.window.showInformationMessage(`Ignored ${added} word(s) in .bindery/translations.json.`);
}
}
// ─── Merge helpers ────────────────────────────────────────────────────────────
function buildMergeOptions(
root: string,
lang: LanguageConfig,
outputTypes: OutputType[],
wsSettings: WorkspaceSettings | null,
translations: TranslationsFile | null,
): MergeOptions {
const cfg = getEffectiveConfig(wsSettings);
// Language-specific title from workspace file, falling back to the global title
const bookTitle = getBookTitleForLang(wsSettings, lang.code) ?? cfg.bookTitle;
return {
root,
storyFolder: cfg.storyFolder,
language: lang,
outputTypes,
includeToc: true,
includeSeparators: true,
author: cfg.author,
bookTitle,
outputDir: cfg.mergedOutputDir,
filePrefix: cfg.mergeFilePrefix,
pandocPath: cfg.pandocPath,
libreOfficePath: cfg.libreOfficePath,
ukReplacements: buildCombinedSubstitutions(translations),
};
}
// ─── Command: Setup AI assistant files ───────────────────────────────────────
const AI_TARGET_ITEMS: Array<{ label: string; detail: string; value: AiTarget }> = [
{ label: '$(symbol-misc) Claude (Cowork / Claude Code)', detail: 'Generates CLAUDE.md and .claude/skills/ templates', value: 'claude' },
{ label: '$(github) GitHub Copilot', detail: 'Generates .github/copilot-instructions.md', value: 'copilot' },
{ label: '$(edit) Cursor', detail: 'Generates .cursor/rules', value: 'cursor' },
{ label: '$(robot) Agents (OpenAI / Aider / Codex)', detail: 'Generates AGENTS.md', value: 'agents' },
];
const SKILL_ITEMS: Array<{ label: string; description: string; value: SkillTemplate }> = [
{ label: '/review', description: 'Chapter review — language, arc, age-appropriateness', value: 'review' },
{ label: '/brainstorm', description: 'Generate plot / character / scene ideas', value: 'brainstorm' },
{ label: '/memory', description: 'Update memory files and compact if needed', value: 'memory' },
{ label: '/translate', description: 'Assisted chapter translation', value: 'translate' },
{ label: '/status', description: 'Book progress snapshot', value: 'status' },
{ label: '/continuity', description: 'Cross-check chapter for consistency errors', value: 'continuity' },
{ label: '/read-aloud', description: 'Reading-aloud test for a chapter or passage', value: 'read_aloud' },
];
async function setupAiCommand() {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const wsSettings = readWorkspaceSettings(root);
if (!wsSettings) {
const init = await vscode.window.showWarningMessage(
'No .bindery/settings.json found. Run "Bindery: Initialise Workspace" first.',
'Initialise now'
);
if (init) { await initWorkspaceCommand(); }
return;
}
// Fill in any missing context fields interactively
const settings = { ...wsSettings };
if (!settings.description) {
const desc = await vscode.window.showInputBox({
title: 'Bindery: Setup AI — project description',
prompt: 'One-line description used in AI instruction files',
placeHolder: 'e.g. Post-apocalyptic sci-fi/fantasy adventure',
});
if (desc === undefined) { return; }
if (desc) { settings.description = desc; }
}
if (!settings.genre) {
const genre = await vscode.window.showInputBox({
title: 'Bindery: Setup AI — genre',
prompt: 'Genre (used in AI instruction files)',
placeHolder: 'e.g. sci-fi/fantasy, mystery, contemporary fiction',
});
if (genre === undefined) { return; }
if (genre) { settings.genre = genre; }
}
if (!settings.targetAudience) {
const audience = await vscode.window.showInputBox({
title: 'Bindery: Setup AI — target audience',
prompt: 'Target audience (used to calibrate review feedback)',
placeHolder: 'e.g. 12+, adults, 8-10',
});
if (audience === undefined) { return; }
if (audience) { settings.targetAudience = audience; }
}
// Save any new fields back to settings.json
const hasNew = settings.description !== wsSettings.description
|| settings.genre !== wsSettings.genre
|| settings.targetAudience !== wsSettings.targetAudience;
if (hasNew) {
const settingsPath = getSettingsPath(root);
try {
const current = JSON.parse(require('fs').readFileSync(settingsPath, 'utf-8'));
const updated = { ...current, ...settings };
require('fs').writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
} catch { /* non-fatal */ }
}
// Select AI targets
const targetPicks = await vscode.window.showQuickPick(AI_TARGET_ITEMS, {
canPickMany: true,
placeHolder: 'Select AI assistants to set up',
});
if (!targetPicks || targetPicks.length === 0) { return; }
const targets = targetPicks.map(p => p.value);
// Select skills (only for Claude target)
let skills: SkillTemplate[] = ALL_SKILLS;
if (targets.includes('claude')) {
const skillPicks = await vscode.window.showQuickPick(SKILL_ITEMS, {
canPickMany: true,
placeHolder: 'Select skill templates to generate (.claude/skills/)',
});
if (skillPicks === undefined) { return; }
skills = skillPicks.length > 0 ? skillPicks.map(p => p.value) : [];
}
// Overwrite existing?
const overwritePick = await vscode.window.showQuickPick(
[
{ label: 'Skip existing files', value: false as const },
{ label: 'Overwrite existing files', value: true as const },
],
{ placeHolder: 'How should existing files be handled?' }
);
if (!overwritePick) { return; }
// Generate
const result = setupAiFiles({ root, settings, targets, skills, overwrite: overwritePick.value });
const summary: string[] = [];
if (result.created.length > 0) {
summary.push(`Created: ${result.created.join(', ')}`);
}
if (result.skipped.length > 0) {
summary.push(`Skipped (already exist): ${result.skipped.join(', ')}`);
}
const msg = summary.length > 0 ? summary.join(' | ') : 'No files generated.';
vscode.window.showInformationMessage(msg);
}
// ─── Typography formatting provider ──────────────────────────────────────────
class TypographyFormattingProvider implements vscode.DocumentFormattingEditProvider {
provideDocumentFormattingEdits(
document: vscode.TextDocument,
_options: vscode.FormattingOptions,
_token: vscode.CancellationToken,
): vscode.TextEdit[] {
const original = document.getText();
const formatted = updateTypography(original);
if (original === formatted) { return []; }
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(original.length)
);
return [vscode.TextEdit.replace(fullRange, formatted)];
}
}
// ─── Commands: format ─────────────────────────────────────────────────────────
async function formatDocumentCommand(uri?: vscode.Uri) {
const targetUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (!targetUri) { vscode.window.showWarningMessage('No markdown file selected.'); return; }
const doc = await vscode.workspace.openTextDocument(targetUri);
const original = doc.getText();
const formatted = updateTypography(original);
if (original === formatted) {
vscode.window.showInformationMessage('Typography: no changes needed.');
return;
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(doc.positionAt(0), doc.positionAt(original.length));
edit.replace(targetUri, fullRange, formatted);
await vscode.workspace.applyEdit(edit);
vscode.window.showInformationMessage('Typography formatting applied.');
}
async function formatFolderCommand(uri?: vscode.Uri) {
const targetUri = uri ?? vscode.workspace.workspaceFolders?.[0]?.uri;
if (!targetUri) { vscode.window.showWarningMessage('No folder selected.'); return; }
const folderPath = targetUri.fsPath;
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
vscode.window.showWarningMessage('Selected path is not a directory.');
return;
}
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Formatting markdown files…' },
async () => {
const count = formatDirectoryRecursive(folderPath);
vscode.window.showInformationMessage(`Typography: ${count} file(s) updated.`);
}
);
}
function formatDirectoryRecursive(dirPath: string): number {
let count = 0;
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
count += formatDirectoryRecursive(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
const formatted = updateTypography(content);
if (content !== formatted) {
fs.writeFileSync(fullPath, formatted, 'utf-8');
count++;
}
}
}
return count;
}
// ─── Commands: merge ──────────────────────────────────────────────────────────
let mergeInProgress = false;
async function mergeCommand(outputTypes: OutputType[]) {
if (mergeInProgress) { vscode.window.showWarningMessage('A merge is already in progress.'); return; }
mergeInProgress = true;
try {
await doMerge(outputTypes);
} finally {
mergeInProgress = false;
}
}
async function doMerge(outputTypes: OutputType[]) {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const wsSettings = readWorkspaceSettings(root);
const translations = readTranslations(root);
const cfg = getEffectiveConfig(wsSettings);
// Select languages to export
let selectedLangs: LanguageConfig[];
if (cfg.languages.length > 1) {
const available = cfg.languages.filter(l => languageCanExport(root, cfg.storyFolder, l));
if (available.length === 0) {
vscode.window.showErrorMessage(`No language folders found in ${cfg.storyFolder}/`);
return;
}
if (available.length === 1) {
selectedLangs = available;
} else {
const items = [
{ label: 'All languages', description: available.map(l => l.code).join(', '), langs: available },
...available.map(l => ({ label: l.code, description: l.folderName, langs: [l] })),
];
const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Select language(s) to merge' });
if (!picked) { return; }
selectedLangs = picked.langs;
}
} else {
selectedLangs = cfg.languages;
}
// Verify pandoc if needed
if (outputTypes.some(t => t === 'docx' || t === 'epub' || t === 'pdf')) {
try {
await checkPandoc(cfg.pandocPath);
} catch {
vscode.window.showErrorMessage(
'Pandoc is required for DOCX/EPUB/PDF export but was not found. ' +
'Install it from https://pandoc.org or set bindery.pandocPath in settings.'
);
return;
}
}
const formatLabel = outputTypes.map(t => t.toUpperCase()).join(' + ');
const result = await vscode.window.withProgress<{ outputs: string[]; warnings: string[] }>(
{ location: vscode.ProgressLocation.Notification, title: `Bindery: Merging → ${formatLabel}`, cancellable: false },
async (progress) => {
const allOutputs: string[] = [];
const allWarnings: string[] = [];
for (let i = 0; i < selectedLangs.length; i++) {
const lang = selectedLangs[i];
progress.report({ message: `${lang.code} (${i + 1}/${selectedLangs.length})…` });
try {
const options = buildMergeOptions(root, lang, outputTypes, wsSettings, translations);
const r = await mergeBook(options);
allOutputs.push(...r.outputs);
allWarnings.push(...r.warnings.map(w => `${lang.code}: ${w}`));
} catch (err: any) {
vscode.window.showErrorMessage(`Merge failed for ${lang.code}: ${err.message}`);
}
}
return { outputs: allOutputs, warnings: allWarnings };
}
);
if (result.warnings.length > 0) {
const preview = result.warnings.slice(0, 2).join(' | ');
const more = result.warnings.length > 2 ? ` (+${result.warnings.length - 2} more)` : '';
vscode.window.showWarningMessage(`Merge completed with warnings: ${preview}${more}`);
}
if (result.outputs.length > 0) {
const names = result.outputs.map((p: string) => path.basename(p)).join(', ');
const action = await vscode.window.showInformationMessage(`Merged → ${names}`, 'Open Folder');
if (action === 'Open Folder') {
vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(path.dirname(result.outputs[0])));
}
}
}
// ─── Activation ───────────────────────────────────────────────────────────────
export function activate(context: vscode.ExtensionContext) {
// Formatting provider (used by VS Code's Format Document command)
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider(
{ language: 'markdown', scheme: 'file' },
new TypographyFormattingProvider()
)
);
// Format-on-save — only fires for files inside the configured Story folder
context.subscriptions.push(
vscode.workspace.onWillSaveTextDocument((event) => {
if (event.document.languageId !== 'markdown') { return; }
const root = getWorkspaceRoot();
if (!root) { return; }
const wsSettings = readWorkspaceSettings(root);
const cfg = getEffectiveConfig(wsSettings);
if (!cfg.formatOnSave) { return; }
if (!isInsideStoryFolder(event.document.uri.fsPath, root, cfg.storyFolder)) { return; }
const original = event.document.getText();
const formatted = updateTypography(original);
if (original === formatted) { return; }
const fullRange = new vscode.Range(
event.document.positionAt(0),
event.document.positionAt(original.length)
);
event.waitUntil(Promise.resolve([vscode.TextEdit.replace(fullRange, formatted)]));
})
);
// Commands
context.subscriptions.push(
vscode.commands.registerCommand('bindery.init', initWorkspaceCommand),
vscode.commands.registerCommand('bindery.setupAI', setupAiCommand),
vscode.commands.registerCommand('bindery.formatDocument', formatDocumentCommand),
vscode.commands.registerCommand('bindery.formatFolder', formatFolderCommand),
vscode.commands.registerCommand('bindery.mergeMarkdown', () => mergeCommand(['md'])),
vscode.commands.registerCommand('bindery.mergeDocx', () => mergeCommand(['docx'])),
vscode.commands.registerCommand('bindery.mergeEpub', () => mergeCommand(['epub'])),
vscode.commands.registerCommand('bindery.mergePdf', () => mergeCommand(['pdf'])),
vscode.commands.registerCommand('bindery.mergeAll', () => mergeCommand(['md', 'docx', 'epub', 'pdf'])),
vscode.commands.registerCommand('bindery.findProbableUsToUkWords', findProbableUsToUkWordsCommand),
vscode.commands.registerCommand('bindery.addUkReplacement', addUkReplacementCommand),
);
// Status bar — shown when a markdown file is active
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBar.text = '$(book) Bindery';
statusBar.tooltip = 'Bindery: Merge Chapters → All Formats';
statusBar.command = 'bindery.mergeAll';
context.subscriptions.push(statusBar);
const updateStatusBar = () => {
if (vscode.window.activeTextEditor?.document.languageId === 'markdown') {
statusBar.show();
} else {
statusBar.hide();
}
};
context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor(updateStatusBar));
updateStatusBar();
}
export function deactivate() { }

94
vscode-ext/src/format.ts Normal file
View file

@ -0,0 +1,94 @@
/**
* Typography formatting for markdown files.
*
* Converts straight quotes to curly quotes, `...` to ellipsis,
* and `--` to em-dash while preserving content inside HTML comments.
*
* Ported from mcp-rust/src/format.rs
*/
// ─── Typographic Characters ─────────────────────────────────────────────────
const OPEN_DOUBLE = '\u{201C}'; // "
const CLOSE_DOUBLE = '\u{201D}'; // "
const OPEN_SINGLE = '\u{2018}'; // '
const CLOSE_SINGLE = '\u{2019}'; // ' (also used for apostrophes)
const ELLIPSIS = '\u{2026}'; // …
const EM_DASH = '\u{2014}'; // —
// ─── Cached Regex Patterns ──────────────────────────────────────────────────
/** Matches HTML comments: <!-- ... --> */
const COMMENT_RE = /<!--[\s\S]*?-->/g;
/** Matches opening double quote context: after whitespace, line start, or brackets */
const OPEN_DOUBLE_RE = /(^|[\s([{—–-])"/gm;
/** Matches opening single quote context: after whitespace, line start, or brackets */
const OPEN_SINGLE_RE = /(^|[\s([{—–-])'/gm;
/** Matches a closing double quote after an em-dash at end-of-word or line */
const CLOSE_DOUBLE_AFTER_EM_DASH_RE = /—"([\s)\].,;:!?]|$)/gm;
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Apply typographic formatting to text.
*
* - `...` `` (ellipsis)
* - `--` `` (em-dash, but not `---` which is markdown HR)
* - `"text"` `\u201Ctext\u201D` (curly double quotes)
* - `'text'` `\u2018text\u2019` (curly single quotes / apostrophes)
*/
export function updateTypography(text: string): string {
let result = text;
// Step 1: Convert ... to ellipsis (must happen before quote processing)
result = result.replace(/\.\.\./g, ELLIPSIS);
// Step 2: Protect HTML comments from em-dash conversion
const protectedComments: string[] = [];
result = result.replace(COMMENT_RE, (match) => {
const placeholder = `\x00COMMENT${protectedComments.length}\x00`;
protectedComments.push(match);
return placeholder;
});
// 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'), '---');
// Step 4: Restore HTML comments
for (let i = 0; i < protectedComments.length; i++) {
result = result.replace(`\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) => {
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) => {
return `${before}${OPEN_DOUBLE}`;
});
// Closing: all remaining straight double quotes
result = result.replace(/"/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) => {
return `${before}${OPEN_SINGLE}`;
});
// Closing/apostrophe: all remaining straight single quotes
result = result.replace(/'/g, CLOSE_SINGLE);
return result;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

872
vscode-ext/src/merge.ts Normal file
View file

@ -0,0 +1,872 @@
/**
* Book merging collects and orders markdown files, generates TOC, calls Pandoc.
*
* Ported from mcp-rust/src/merge.rs
*/
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import { updateTypography } from './format';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface LanguageConfig {
code: string;
folderName: string;
chapterWord: string;
actPrefix: string;
prologueLabel: string;
epilogueLabel: string;
}
export type OutputType = 'md' | 'docx' | 'epub' | 'pdf';
export interface MergeOptions {
/** Workspace root path */
root: string;
/** Story folder name (e.g. "Story") */
storyFolder: string;
/** Language configuration */
language: LanguageConfig;
/** Output types to generate */
outputTypes: OutputType[];
/** Include TOC in markdown output */
includeToc: boolean;
/** Include separators between chapters */
includeSeparators: boolean;
/** Author name for EPUB/DOCX metadata */
author?: string;
/** Book title override */
bookTitle?: string;
/** Output directory (relative to root) */
outputDir: string;
/** Merged file prefix */
filePrefix: string;
/** Path to pandoc executable */
pandocPath: string;
/** Path to LibreOffice executable for PDF export (e.g. 'libreoffice' on Linux, full soffice.exe path on Windows) */
libreOfficePath?: string;
/** Custom US→UK replacements from workspace settings */
ukReplacements?: UkReplacement[];
}
export interface MergeResult {
outputs: string[];
filesMerged: number;
warnings: string[];
}
// ─── Internal Types ─────────────────────────────────────────────────────────
interface ActInfo {
name: string;
number: number;
subtitle?: string;
}
type FileType =
| { kind: 'prologue' }
| { kind: 'act'; act: ActInfo }
| { kind: 'chapter'; act: ActInfo; num: number }
| { kind: 'epilogue' };
interface OrderedFile {
filePath: string;
fileType: FileType;
}
// ─── Regex Patterns ─────────────────────────────────────────────────────────
/** Matches Act/Deel folder names: "Act I - Awakening", "Deel II - Resonantie" */
const ACT_FOLDER_RE = /^(Act|Deel)\s+(I{1,3}|IV|V)(?:\s*[-–—]\s*(.+))?$/;
/** Matches chapter filenames: "Chapter8.md", "chapter 12.md" */
const CHAPTER_NUM_RE = /(?:chapter|hoofdstuk)\s*(\d+)/i;
/** Matches H1 headings in markdown */
const H1_RE = /^\s*#\s+(.+?)\s*$/m;
/** Matches non-slug characters */
const SLUG_CLEAN_RE = /[^\p{L}\p{N}\s-]/gu;
/** Matches multiple blank lines */
const BLANK_LINES_RE = /\n{2,}/g;
/** Matches first H1 for demotion to H2 */
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 {
us: string;
uk: string;
}
const UK_REPLACEMENTS: UkReplacement[] = [
{ us: 'color', uk: 'colour' },
{ us: 'colors', uk: 'colours' },
{ us: 'colored', uk: 'coloured' },
{ us: 'coloring', uk: 'colouring' },
{ us: 'center', uk: 'centre' },
{ us: 'centers', uk: 'centres' },
{ us: 'centered', uk: 'centred' },
{ us: 'centering', uk: 'centring' },
{ us: 'theater', uk: 'theatre' },
{ us: 'theaters', uk: 'theatres' },
{ us: 'favorite', uk: 'favourite' },
{ us: 'favorites', uk: 'favourites' },
{ us: 'favor', uk: 'favour' },
{ us: 'favors', uk: 'favours' },
{ us: 'favored', uk: 'favoured' },
{ us: 'favoring', uk: 'favouring' },
{ us: 'traveled', uk: 'travelled' },
{ us: 'traveling', uk: 'travelling' },
{ us: 'traveler', uk: 'traveller' },
{ us: 'travelers', uk: 'travellers' },
{ us: 'canceled', uk: 'cancelled' },
{ us: 'canceling', uk: 'cancelling' },
{ us: 'gray', uk: 'grey' },
{ us: 'fiber', uk: 'fibre' },
{ us: 'defense', uk: 'defence' },
{ us: 'offense', uk: 'offence' },
{ us: 'realize', uk: 'realise' },
{ us: 'realizes', uk: 'realises' },
{ us: 'realized', uk: 'realised' },
{ us: 'realizing', uk: 'realising' },
{ us: 'realization', uk: 'realisation' },
{ us: 'organize', uk: 'organise' },
{ us: 'organizes', uk: 'organises' },
{ us: 'organized', uk: 'organised' },
{ us: 'organizing', uk: 'organising' },
{ us: 'organization', uk: 'organisation' },
{ us: 'analyze', uk: 'analyse' },
{ us: 'analyzes', uk: 'analyses' },
{ us: 'analyzed', uk: 'analysed' },
{ us: 'analyzing', uk: 'analysing' },
{ us: 'recognize', uk: 'recognise' },
{ us: 'recognizes', uk: 'recognises' },
{ us: 'recognized', uk: 'recognised' },
{ us: 'recognizing', uk: 'recognising' },
{ us: 'specialize', uk: 'specialise' },
{ us: 'specializes', uk: 'specialises' },
{ us: 'specialized', uk: 'specialised' },
{ us: 'specializing', uk: 'specialising' },
{ us: 'initialize', uk: 'initialise' },
{ us: 'initializes', uk: 'initialises' },
{ us: 'initialized', uk: 'initialised' },
{ us: 'initializing', uk: 'initialising' },
{ us: 'destabilize', uk: 'destabilise' },
{ us: 'destabilizes', uk: 'destabilises' },
{ us: 'destabilized', uk: 'destabilised' },
{ us: 'destabilizing', uk: 'destabilising' },
{ us: 'equalize', uk: 'equalise' },
{ us: 'equalizes', uk: 'equalises' },
{ us: 'equalized', uk: 'equalised' },
{ us: 'equalizing', uk: 'equalising' },
{ us: 'mesmerize', uk: 'mesmerise' },
{ us: 'mesmerizes', uk: 'mesmerises' },
{ us: 'mesmerized', uk: 'mesmerised' },
{ us: 'mesmerizing', uk: 'mesmerising' },
{ us: 'mom', uk: 'mum' },
];
const FENCE_RE = /^\s*```/;
function applyCasing(source: string, target: string): string {
if (source.toUpperCase() === source) {
return target.toUpperCase();
}
if (source[0] && source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
return target[0].toUpperCase() + target.slice(1);
}
return target;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function getBuiltInUkReplacements(): UkReplacement[] {
return [...UK_REPLACEMENTS];
}
function buildUkReplacementData(customReplacements: UkReplacement[] = []): { map: Map<string, string>; pattern: RegExp } {
const merged = new Map<string, string>();
for (const item of UK_REPLACEMENTS) {
const us = item.us.trim().toLowerCase();
const uk = item.uk.trim();
if (us && uk) {
merged.set(us, uk);
}
}
for (const item of customReplacements) {
const us = item.us.trim().toLowerCase();
const uk = item.uk.trim();
if (us && uk) {
merged.set(us, uk);
}
}
const usWords = Array.from(merged.keys()).map(escapeRegExp).sort((a, b) => b.length - a.length);
if (usWords.length === 0) {
return { map: merged, pattern: /$a/ };
}
const pattern = new RegExp(`\\b(${usWords.join('|')})\\b`, 'gi');
return { map: merged, pattern };
}
function convertUsToUkText(text: string, customReplacements: UkReplacement[] = []): string {
const replacementData = buildUkReplacementData(customReplacements);
let inFencedBlock = false;
const lines = text.split(/(\r?\n)/);
const out: string[] = [];
for (let i = 0; i < lines.length; i += 2) {
const line = lines[i] ?? '';
const lineBreak = lines[i + 1] ?? '';
if (FENCE_RE.test(line)) {
inFencedBlock = !inFencedBlock;
out.push(line + lineBreak);
continue;
}
if (inFencedBlock) {
out.push(line + lineBreak);
continue;
}
const converted = line.replace(replacementData.pattern, (match) => {
const replacement = replacementData.map.get(match.toLowerCase());
if (!replacement) {
return match;
}
return applyCasing(match, replacement);
});
out.push(converted + lineBreak);
}
return out.join('');
}
function convertUsToUkDirectory(dirPath: string, customReplacements: UkReplacement[] = []): number {
let changed = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
changed += convertUsToUkDirectory(fullPath, customReplacements);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
const converted = convertUsToUkText(content, customReplacements);
if (converted !== content) {
fs.writeFileSync(fullPath, converted, 'utf-8');
changed++;
}
}
}
return changed;
}
function isUkLanguage(lang: LanguageConfig): boolean {
return lang.code.trim().toUpperCase() === 'UK' || lang.folderName.trim().toUpperCase() === 'UK';
}
function prepareUkFromEn(root: string, storyFolder: string, lang: LanguageConfig, customReplacements: UkReplacement[] = []): void {
if (!isUkLanguage(lang)) {
return;
}
const storyRoot = path.join(root, storyFolder);
const enPath = path.join(storyRoot, 'EN');
const ukPath = path.join(storyRoot, lang.folderName);
if (!fs.existsSync(enPath)) {
throw new Error(`EN source folder not found for UK generation: ${enPath}`);
}
if (fs.existsSync(ukPath)) {
fs.rmSync(ukPath, { recursive: true, force: true });
}
fs.cpSync(enPath, ukPath, { recursive: true });
convertUsToUkDirectory(ukPath, customReplacements);
}
function cleanupUkTempFolder(root: string, storyFolder: string, lang: LanguageConfig): void {
if (!isUkLanguage(lang)) {
return;
}
const ukPath = path.join(root, storyFolder, lang.folderName);
if (fs.existsSync(ukPath)) {
fs.rmSync(ukPath, { recursive: true, force: true });
}
}
// ─── Utilities ──────────────────────────────────────────────────────────────
function romanToInt(roman: string): number | undefined {
const map: Record<string, number> = {
'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5
};
return map[roman.toUpperCase()];
}
function intToRoman(n: number): string {
const map: Record<number, string> = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V' };
return map[n] ?? 'I';
}
function parseActFolder(name: string): ActInfo | undefined {
const m = ACT_FOLDER_RE.exec(name);
if (!m) { return undefined; }
const num = romanToInt(m[2]);
if (num === undefined) { return undefined; }
return { name, number: num, subtitle: m[3]?.trim() };
}
function extractChapterNum(filename: string): number | undefined {
const m = CHAPTER_NUM_RE.exec(filename);
return m ? parseInt(m[1], 10) : undefined;
}
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(SLUG_CLEAN_RE, '')
.trim()
.split(/\s+/)
.join('-');
}
function demoteH1ToH2(text: string): string {
return text.replace(FIRST_H1_RE, '$1## ');
}
function collapseBlankLines(text: string): string {
return text.replace(BLANK_LINES_RE, '\n\n');
}
function formatActTitle(act: ActInfo, lang: LanguageConfig): string {
const roman = intToRoman(act.number);
return act.subtitle
? `${lang.actPrefix} ${roman} - ${act.subtitle}`
: `${lang.actPrefix} ${roman}`;
}
// ─── File Discovery ─────────────────────────────────────────────────────────
function getOrderedFiles(langPath: string, lang: LanguageConfig): OrderedFile[] {
const files: OrderedFile[] = [];
// Prologue
const prologue = path.join(langPath, 'Prologue.md');
if (fs.existsSync(prologue)) {
files.push({ filePath: prologue, fileType: { kind: 'prologue' } });
}
// Also check for localized name
if (lang.prologueLabel !== 'Prologue') {
const localPrologue = path.join(langPath, `${lang.prologueLabel}.md`);
if (fs.existsSync(localPrologue) && !fs.existsSync(prologue)) {
files.push({ filePath: localPrologue, fileType: { kind: 'prologue' } });
}
}
// Discover Act folders
const acts: ActInfo[] = [];
const actFolders = new Map<number, string>();
for (const entry of fs.readdirSync(langPath, { withFileTypes: true })) {
if (!entry.isDirectory()) { continue; }
const info = parseActFolder(entry.name);
if (info) {
actFolders.set(info.number, path.join(langPath, entry.name));
acts.push(info);
}
}
acts.sort((a, b) => a.number - b.number);
// Process each act
for (const act of acts) {
files.push({ filePath: actFolders.get(act.number)!, fileType: { kind: 'act', act } });
// Discover chapters in act folder
const actPath = actFolders.get(act.number)!;
const chapters: Array<{ num: number; filePath: string }> = [];
for (const entry of fs.readdirSync(actPath, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) { continue; }
const num = extractChapterNum(entry.name);
if (num !== undefined) {
chapters.push({ num, filePath: path.join(actPath, entry.name) });
}
}
chapters.sort((a, b) => a.num - b.num);
for (const ch of chapters) {
files.push({
filePath: ch.filePath,
fileType: { kind: 'chapter', act, num: ch.num }
});
}
}
// Epilogue
const epilogue = path.join(langPath, 'Epilogue.md');
if (fs.existsSync(epilogue)) {
files.push({ filePath: epilogue, fileType: { kind: 'epilogue' } });
}
if (lang.epilogueLabel !== 'Epilogue') {
const localEpilogue = path.join(langPath, `${lang.epilogueLabel}.md`);
if (fs.existsSync(localEpilogue) && !fs.existsSync(epilogue)) {
files.push({ filePath: localEpilogue, fileType: { kind: 'epilogue' } });
}
}
return files;
}
// ─── TOC Generation ─────────────────────────────────────────────────────────
function generateToc(files: OrderedFile[], lang: LanguageConfig): string {
let toc = '# Table of Contents\n\n';
let currentAct: number | null = null;
for (const file of files) {
let title: string;
switch (file.fileType.kind) {
case 'prologue':
title = lang.prologueLabel;
toc += `- [${title}](#${generateSlug(title)})\n`;
break;
case 'act':
currentAct = file.fileType.act.number;
title = formatActTitle(file.fileType.act, lang);
toc += `- ${title}\n`;
break;
case 'chapter': {
const content = fs.readFileSync(file.filePath, 'utf-8');
const h1match = H1_RE.exec(content);
title = h1match ? h1match[1] : path.basename(file.filePath, '.md');
toc += ` - [${title}](#${generateSlug(title)})\n`;
break;
}
case 'epilogue':
title = lang.epilogueLabel;
toc += `- [${title}](#${generateSlug(title)})\n`;
break;
}
}
return toc;
}
// ─── Markdown Content Builder ───────────────────────────────────────────────
const PAGE_BREAK = `
\`\`\`{=openxml}
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
\`\`\`
`;
function buildMarkdownContent(files: OrderedFile[], options: MergeOptions): string {
let content = '';
if (options.includeToc) {
const toc = generateToc(files, options.language);
content += toc + '\n---\n\n';
}
let currentAct: number | null = null;
for (const file of files) {
switch (file.fileType.kind) {
case 'prologue':
case 'epilogue': {
const fileContent = fs.readFileSync(file.filePath, 'utf-8');
content += fileContent;
break;
}
case 'act':
currentAct = file.fileType.act.number;
content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`;
break;
case 'chapter': {
const fileContent = fs.readFileSync(file.filePath, 'utf-8');
if (currentAct !== file.fileType.act.number) {
currentAct = file.fileType.act.number;
content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`;
}
content += fileContent;
break;
}
}
if (options.includeSeparators) {
content += '\n\n---\n\n';
} else {
content += '\n\n';
}
}
return collapseBlankLines(content);
}
// ─── Pandoc Content Builder ─────────────────────────────────────────────────
function hasNextContentFile(files: OrderedFile[], currentIndex: number): boolean {
for (let i = currentIndex + 1; i < files.length; i++) {
if (files[i].fileType.kind !== 'act') { return true; }
}
return false;
}
function imageMarkdownFor(file: OrderedFile, options: MergeOptions): string | undefined {
let imageName: string;
switch (file.fileType.kind) {
case 'prologue': imageName = 'prologue.jpg'; break;
case 'epilogue': imageName = 'epilogue.jpg'; break;
case 'chapter': imageName = `chapter${file.fileType.num}.jpg`; break;
default: return undefined;
}
const imagePath = path.join(options.root, 'images', imageName);
if (!fs.existsSync(imagePath)) { return undefined; }
return `![](${imagePath.replace(/\\/g, '/')})\n\n`;
}
function insertImageAfterHeading(content: string, imageMd: string): string {
const m = HEADING_LINE_RE.exec(content);
if (m) {
const end = m.index + m[0].length;
return content.substring(0, end) + imageMd + content.substring(end);
}
return imageMd + content;
}
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`;
}
function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputType: OutputType): string {
let content = '';
const pageBreak = outputType === 'pdf'
? '\n```{=latex}\n\\newpage\n```\n'
: PAGE_BREAK;
if (outputType === 'docx' || outputType === 'pdf') {
const cover = coverMarkdown(options);
if (cover) {
content += cover + pageBreak + '\n';
}
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
switch (file.fileType.kind) {
case 'prologue':
case 'epilogue': {
let fileContent = fs.readFileSync(file.filePath, 'utf-8');
const img = imageMarkdownFor(file, options);
if (img) { fileContent = insertImageAfterHeading(fileContent, img); }
content += fileContent;
break;
}
case 'act':
content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`;
break;
case 'chapter': {
let fileContent = fs.readFileSync(file.filePath, 'utf-8');
fileContent = demoteH1ToH2(fileContent);
const img = imageMarkdownFor(file, options);
if (img) { fileContent = insertImageAfterHeading(fileContent, img); }
content += fileContent;
break;
}
}
if (i < files.length - 1) {
content += '\n\n';
if ((outputType === 'docx' || outputType === 'pdf') && hasNextContentFile(files, i)) {
content += pageBreak;
}
}
}
return collapseBlankLines(content);
}
// ─── 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'; }
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 */ }
return 'Book';
}
export async function checkPandoc(pandocPath: string): Promise<string> {
return new Promise((resolve, reject) => {
cp.execFile(pandocPath, ['--version'], { encoding: 'utf-8' }, (err, stdout) => {
if (err) {
reject(new Error('Pandoc is not available. Install it from https://pandoc.org'));
} else {
resolve(stdout.split('\n')[0] ?? 'unknown');
}
});
});
}
async function checkPandocOutputSupport(pandocPath: string, outputType: OutputType): Promise<boolean> {
return new Promise((resolve) => {
cp.execFile(pandocPath, ['--list-output-formats'], { encoding: 'utf-8' }, (err, stdout) => {
if (err) {
resolve(false);
return;
}
const formats = stdout.split(/\r?\n/).map(v => v.trim().toLowerCase());
resolve(formats.includes(outputType));
});
});
}
function runPandoc(
inputPath: string,
outputPath: string,
outputType: OutputType,
root: string,
title: string,
lang: LanguageConfig,
author: string | undefined,
pandocPath: string
): Promise<void> {
const args: string[] = [
inputPath,
'-o', outputPath,
'--metadata', `title=${title}`,
];
if (author) {
args.push('--metadata', `author=${author}`);
}
// Language metadata
const langCode = lang.code.toLowerCase();
args.push('--metadata', `lang=${langCode}`);
// Date metadata
const date = new Date().toISOString().slice(0, 10);
args.push('--metadata', `date=${date}`);
if (outputType === 'docx') {
args.push('--from=markdown+raw_attribute');
const reference = path.join(root, 'reference.docx');
if (fs.existsSync(reference)) {
args.push('--reference-doc', reference);
}
}
if (outputType === 'epub') {
args.push('--split-level=2');
const cover = path.join(root, 'Story', lang.folderName, 'cover.jpg');
if (fs.existsSync(cover)) {
args.push('--epub-cover-image', cover);
}
}
return new Promise((resolve, reject) => {
cp.execFile(pandocPath, args, { encoding: 'utf-8' }, (err, _stdout, stderr) => {
if (err) {
const details = stderr || err.message;
reject(new Error(`Pandoc failed: ${details}`));
} else {
resolve();
}
});
});
}
function isMissingPdfEngineError(message: string): boolean {
return /pdflatex not found|pdf-engine|program not found/i.test(message);
}
/**
* Run LibreOffice headless to convert a DOCX to PDF.
* LibreOffice writes <basename>.pdf into outputDir we then rename it to finalPdfPath.
*/
async function runLibreOfficeToPdf(
docxPath: string,
outputDir: string,
finalPdfPath: string,
libreOfficePath: string
): Promise<void> {
return new Promise((resolve, reject) => {
cp.execFile(
libreOfficePath,
['--headless', '--convert-to', 'pdf', docxPath, '--outdir', outputDir],
{ encoding: 'utf-8' },
(err, _stdout, stderr) => {
if (err) {
const details = stderr || err.message;
reject(new Error(
`LibreOffice PDF conversion failed: ${details}\n` +
'Make sure LibreOffice is installed and bindery.libreOfficePath is correct.'
));
return;
}
// LibreOffice outputs <basename-without-ext>.pdf in outputDir
const tempPdfName = path.basename(docxPath, '.docx') + '.pdf';
const tempPdfPath = path.join(outputDir, tempPdfName);
try {
fs.renameSync(tempPdfPath, finalPdfPath);
resolve();
} catch (renameErr: any) {
reject(new Error(`Failed to rename LibreOffice output: ${renameErr.message}`));
}
}
);
});
}
// ─── Format + Merge Entry Point ─────────────────────────────────────────────
/**
* Format all markdown files in a directory tree (typography).
*/
function formatDirectory(dirPath: string): number {
let count = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
count += formatDirectory(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
const formatted = updateTypography(content);
if (content !== formatted) {
fs.writeFileSync(fullPath, formatted, 'utf-8');
count++;
}
}
}
return count;
}
/**
* Main merge entry point.
*/
export async function mergeBook(options: MergeOptions): Promise<MergeResult> {
prepareUkFromEn(options.root, options.storyFolder, options.language, options.ukReplacements ?? []);
try {
const langPath = path.join(options.root, options.storyFolder, options.language.folderName);
if (!fs.existsSync(langPath)) {
throw new Error(`Language folder not found: ${langPath}`);
}
// Format files first (same as Rust version)
formatDirectory(langPath);
// Discover and order files
const files = getOrderedFiles(langPath, options.language);
if (files.length === 0) {
throw new Error(`No markdown files found in ${langPath}`);
}
// Create output directory
const outputDir = path.join(options.root, options.outputDir);
fs.mkdirSync(outputDir, { recursive: true });
const baseName = `${options.filePrefix}_${options.language.folderName}_Merged`;
const outputs: string[] = [];
const warnings: string[] = [];
// Check pandoc availability if needed (PDF also needs pandoc for the intermediate DOCX)
const needsPandoc = options.outputTypes.some(t => t === 'docx' || t === 'epub' || t === 'pdf');
if (needsPandoc) {
await checkPandoc(options.pandocPath);
}
// Determine book title
const title = options.bookTitle || getBookTitle(options.root, options.language);
for (const outputType of options.outputTypes) {
const outputPath = path.join(outputDir, `${baseName}.${outputType}`);
if (outputType === 'md') {
const content = buildMarkdownContent(files, options);
fs.writeFileSync(outputPath, content, 'utf-8');
outputs.push(outputPath);
} else if (outputType === 'pdf') {
// PDF: generate an intermediate DOCX via pandoc, then convert with LibreOffice
const libreOfficePath = options.libreOfficePath?.trim() || 'libreoffice';
const tempMdPath = path.join(outputDir, `${baseName}_pdf_temp.md`);
const tempDocxPath = path.join(outputDir, `${baseName}_pdf_temp.docx`);
const content = buildPandocContent(files, options, 'docx');
fs.writeFileSync(tempMdPath, content, 'utf-8');
try {
await runPandoc(tempMdPath, tempDocxPath, 'docx', options.root, title, options.language, options.author, options.pandocPath);
await runLibreOfficeToPdf(tempDocxPath, outputDir, outputPath, libreOfficePath);
outputs.push(outputPath);
} finally {
try { fs.unlinkSync(tempMdPath); } catch { /* ignore */ }
try { fs.unlinkSync(tempDocxPath); } catch { /* ignore */ }
}
} else {
// DOCX / EPUB: build pandoc-specific markdown and call pandoc directly
const content = buildPandocContent(files, options, outputType);
const tempPath = path.join(outputDir, `${baseName}_temp.md`);
fs.writeFileSync(tempPath, content, 'utf-8');
try {
await runPandoc(tempPath, outputPath, outputType, options.root, title, options.language, options.author, options.pandocPath);
outputs.push(outputPath);
} finally {
try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
}
}
}
return { outputs, filesMerged: files.length, warnings };
} finally {
cleanupUkTempFolder(options.root, options.storyFolder, options.language);
}
}

267
vscode-ext/src/workspace.ts Normal file
View file

@ -0,0 +1,267 @@
/**
* Bindery workspace settings reader
*
* Reads .bindery/settings.json project config (title, author, story folder, languages)
* Reads .bindery/translations.json substitution rules and glossaries per language pair
*
* Priority for all settings: workspace file VS Code settings code defaults.
* Machine-specific paths (pandoc, LibreOffice) always come from VS Code settings.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { LanguageConfig, UkReplacement } from './merge';
export const BINDERY_FOLDER = '.bindery';
export const SETTINGS_FILENAME = 'settings.json';
export const TRANSLATIONS_FILENAME = 'translations.json';
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* .bindery/settings.json
*
* bookTitle may be a plain string or a per-language map:
* "bookTitle": "The Hollow Road"
* "bookTitle": { "en": "The Hollow Road", "nl": "De Holle Weg" }
*/
export interface WorkspaceSettings {
bookTitle?: string | Record<string, string>;
author?: string;
/** Short description or tagline used when generating AI assistant files. */
description?: string;
/** Genre of the book (e.g. "sci-fi/fantasy", "mystery", "contemporary fiction"). */
genre?: string;
/** Target audience, e.g. "12+" or "adults" or "8-10". Used to calibrate AI review feedback. */
targetAudience?: string;
storyFolder?: string;
mergedOutputDir?: string;
mergeFilePrefix?: string;
formatOnSave?: boolean;
languages?: LanguageConfig[];
}
/** Type of a translation entry — determines how the extension uses its rules. */
export type TranslationType = 'substitution' | 'glossary';
/** A single from→to rule inside a translation entry. */
export interface TranslationRule {
from: string;
to: string;
}
/**
* One entry in translations.json, keyed by a language code (e.g. "en-gb", "nl").
*
* substitution applied automatically during export (word-by-word replace).
* glossary reference only; used for consistency checking, not auto-applied.
*/
export interface TranslationEntry {
label?: string;
type: TranslationType;
sourceLanguage?: string;
rules?: TranslationRule[];
ignoredWords?: string[];
}
/** The full .bindery/translations.json file. */
export type TranslationsFile = Record<string, TranslationEntry>;
// ─── Path helpers ─────────────────────────────────────────────────────────────
export function getBinderyFolder(root: string): string {
return path.join(root, BINDERY_FOLDER);
}
export function getSettingsPath(root: string): string {
return path.join(root, BINDERY_FOLDER, SETTINGS_FILENAME);
}
export function getTranslationsPath(root: string): string {
return path.join(root, BINDERY_FOLDER, TRANSLATIONS_FILENAME);
}
// ─── Readers ─────────────────────────────────────────────────────────────────
export function readWorkspaceSettings(root: string): WorkspaceSettings | null {
const p = getSettingsPath(root);
if (!fs.existsSync(p)) { return null; }
try {
return JSON.parse(fs.readFileSync(p, 'utf-8')) as WorkspaceSettings;
} catch {
return null;
}
}
export function readTranslations(root: string): TranslationsFile | null {
const p = getTranslationsPath(root);
if (!fs.existsSync(p)) { return null; }
try {
return JSON.parse(fs.readFileSync(p, 'utf-8')) as TranslationsFile;
} catch {
return null;
}
}
// ─── Writers ─────────────────────────────────────────────────────────────────
export function writeTranslations(root: string, data: TranslationsFile): void {
const p = getTranslationsPath(root);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
// ─── Accessors ────────────────────────────────────────────────────────────────
/**
* Resolve the book title for a given language code.
* Falls back to the English title if no language-specific title is found.
*/
export function getBookTitleForLang(
settings: WorkspaceSettings | null,
langCode: string
): string | undefined {
if (!settings?.bookTitle) { return undefined; }
if (typeof settings.bookTitle === 'string') {
return settings.bookTitle || undefined;
}
const code = langCode.toLowerCase();
return settings.bookTitle[code]
?? settings.bookTitle['en']
?? undefined;
}
/**
* Get substitution rules from translations.json for the given language key.
* Returns UkReplacement[] compatible with merge.ts (field names us/uk).
* Only entries with type === 'substitution' are returned.
*/
export function getSubstitutionRules(
translations: TranslationsFile | null,
langKey: string
): UkReplacement[] {
if (!translations) { return []; }
const entry = resolveEntry(translations, langKey);
if (!entry || entry.type !== 'substitution') { return []; }
return (entry.rules ?? [])
.filter(r => r.from?.trim() && r.to?.trim())
.map(r => ({ us: r.from.trim().toLowerCase(), uk: r.to.trim() }));
}
/**
* Get the ignored-words set for a given language key.
*/
export function getIgnoredWords(
translations: TranslationsFile | null,
langKey: string
): Set<string> {
if (!translations) { return new Set(); }
const entry = resolveEntry(translations, langKey);
const result = new Set<string>();
for (const word of entry?.ignoredWords ?? []) {
const w = word.trim().toLowerCase();
if (w) { result.add(w); }
}
return result;
}
// ─── Mutators ─────────────────────────────────────────────────────────────────
/**
* Add or update a substitution rule in .bindery/translations.json.
* Creates the file and entry if they do not yet exist.
*/
export function upsertSubstitutionRule(
root: string,
langKey: string,
rule: TranslationRule
): void {
const translations = readTranslations(root) ?? {};
if (!translations[langKey]) {
translations[langKey] = {
type: 'substitution',
sourceLanguage: 'en',
rules: [],
ignoredWords: [],
};
}
const entry = translations[langKey];
if (entry.type !== 'substitution') {
throw new Error(`Entry '${langKey}' has type '${entry.type}', expected 'substitution'.`);
}
const rules = entry.rules ?? [];
const idx = rules.findIndex(r => r.from.toLowerCase() === rule.from.toLowerCase());
if (idx >= 0) {
rules[idx] = rule;
} else {
rules.push(rule);
rules.sort((a, b) => a.from.localeCompare(b.from));
}
entry.rules = rules;
writeTranslations(root, translations);
}
/**
* Add words to the ignoredWords list in .bindery/translations.json.
* Returns the count of newly added words (duplicates are skipped).
*/
export function addIgnoredWords(
root: string,
langKey: string,
words: string[]
): number {
const translations = readTranslations(root) ?? {};
if (!translations[langKey]) {
translations[langKey] = {
type: 'substitution',
sourceLanguage: 'en',
rules: [],
ignoredWords: [],
};
}
const entry = translations[langKey];
const existing = new Set((entry.ignoredWords ?? []).map(w => w.toLowerCase()));
let added = 0;
for (const word of words) {
const w = word.trim().toLowerCase();
if (w && !existing.has(w)) {
existing.add(w);
added++;
}
}
entry.ignoredWords = Array.from(existing).sort();
writeTranslations(root, translations);
return added;
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function normaliseKey(key: string): string {
return key.trim().toLowerCase();
}
function isUkLike(key: string): boolean {
const k = normaliseKey(key);
return k === 'uk' || k === 'en-gb' || k === 'en-uk';
}
/**
* Look up a translation entry by language key.
* Falls back to 'en-gb' for UK-like codes.
*/
function resolveEntry(
translations: TranslationsFile,
langKey: string
): TranslationEntry | undefined {
const target = normaliseKey(langKey);
for (const [k, v] of Object.entries(translations)) {
if (normaliseKey(k) === target) { return v; }
}
// For UK-like codes, also accept an 'en-gb' entry
if (isUkLike(target)) {
for (const [k, v] of Object.entries(translations)) {
if (normaliseKey(k) === 'en-gb') { return v; }
}
}
return undefined;
}

16
vscode-ext/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"outDir": "out",
"lib": ["ES2022"],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", ".vscode-test"]
}