diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6a1a0469..6b2b9da33 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ jobs:
with:
submodules: recursive
- - name: Install LLVM 16
+ - name: Install LLVM 16 + WABT
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-16 main" | sudo tee /etc/apt/sources.list.d/llvm-16.list
@@ -38,6 +38,10 @@ jobs:
sudo apt-get install -y \
clang-16 llvm-16 llvm-16-dev libclang-16-dev \
cmake ninja-build libboost-all-dev zlib1g-dev
+ # WABT 1.0.41 (apt tem 1.0.27, API incompatível com WasmRuntimeStubs.c)
+ wget -q "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" -O /tmp/wabt.tar.gz
+ sudo tar xzf /tmp/wabt.tar.gz -C /opt
+ sudo ln -sf /opt/wabt-1.0.41/bin/wasm2c /usr/local/bin/wasm2c
- name: Configure CMake
run: |
@@ -70,7 +74,7 @@ jobs:
with:
submodules: recursive
- - name: Install LLVM 16 + cppcheck
+ - name: Install LLVM 16 + cppcheck + WABT
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-16 main" | sudo tee /etc/apt/sources.list.d/llvm-16.list
@@ -79,6 +83,10 @@ jobs:
clang-16 clang-tidy-16 llvm-16 llvm-16-dev libclang-16-dev \
cmake ninja-build libboost-all-dev zlib1g-dev \
cppcheck
+ # WABT 1.0.41 (apt tem 1.0.27, API incompatível com WasmRuntimeStubs.c)
+ wget -q "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" -O /tmp/wabt.tar.gz
+ sudo tar xzf /tmp/wabt.tar.gz -C /opt
+ sudo ln -sf /opt/wabt-1.0.41/bin/wasm2c /usr/local/bin/wasm2c
- name: Generate compile_commands.json
run: |
@@ -152,7 +160,7 @@ jobs:
with:
submodules: recursive
- - name: Install LLVM 16
+ - name: Install LLVM 16 + WABT
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-16 main" | sudo tee /etc/apt/sources.list.d/llvm-16.list
@@ -160,6 +168,10 @@ jobs:
sudo apt-get install -y \
clang-16 llvm-16 llvm-16-dev libclang-16-dev \
cmake ninja-build libboost-all-dev zlib1g-dev
+ # WABT 1.0.41 (apt tem 1.0.27, API incompatível com WasmRuntimeStubs.c)
+ wget -q "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" -O /tmp/wabt.tar.gz
+ sudo tar xzf /tmp/wabt.tar.gz -C /opt
+ sudo ln -sf /opt/wabt-1.0.41/bin/wasm2c /usr/local/bin/wasm2c
- name: Configure CMake with Sanitizers
run: |
@@ -189,3 +201,90 @@ jobs:
# Known: BTree.c:276 off-by-one (tracked for fix in Phase 2)
UBSAN_OPTIONS: "print_stacktrace=1:halt_on_error=0"
run: cd build && ctest --output-on-failure
+
+ # ===========================================================
+ # Job 4: WASM End-to-End Tests (Docker)
+ # ===========================================================
+ # Runs the full pipeline inside the published map2check-dev image,
+ # which already contains LLVM 16 + KLEE 3.1 + WABT + wasi-sdk.
+ # This catches regressions that unit tests miss (KLEE flags,
+ # isRequired, target function, wasm-rt linking, etc.).
+ e2e-wasm:
+ name: WASM E2E Tests (Docker)
+ runs-on: ubuntu-22.04
+ timeout-minutes: 45
+ needs: [build-and-test]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Free disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
+ sudo docker image prune -a -f || true
+
+ - name: Pull map2check-dev image
+ run: docker pull ghcr.io/hbgit/map2check-dev:latest
+
+ - name: Build + install Map2Check in container
+ run: |
+ docker run --rm \
+ -u root \
+ -v "${{ github.workspace }}:/workspace" \
+ -w /workspace \
+ -e CC=/usr/bin/clang-16 \
+ -e CXX=/usr/bin/clang++-16 \
+ ghcr.io/hbgit/map2check-dev:latest bash -c '
+ # Install WABT + wasi-sdk (published image lacks them)
+ if [ ! -f /opt/wabt-1.0.41/include/wasm-rt.h ]; then
+ wget -q "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" -O /tmp/wabt.tar.gz
+ tar xzf /tmp/wabt.tar.gz -C /opt && rm /tmp/wabt.tar.gz
+ ln -sf /opt/wabt-1.0.41/bin/wasm2c /usr/local/bin/wasm2c
+ fi
+ if [ ! -d /opt/wasi-sdk-33.0-x86_64-linux ]; then
+ wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz" -O /tmp/wasi-sdk.tar.gz
+ tar xzf /tmp/wasi-sdk.tar.gz -C /opt && rm /tmp/wasi-sdk.tar.gz
+ fi
+
+ # Build and install to a release-style layout
+ rm -rf build_e2e install_e2e
+ mkdir -p build_e2e && cd build_e2e
+ cmake .. -G Ninja \
+ -DLLVM_DIR=/usr/lib/llvm-16/lib/cmake/llvm \
+ -DENABLE_TEST=ON \
+ -DMAP2CHECK_DYNAMIC_LINK=ON \
+ -DCMAKE_INSTALL_PREFIX=/workspace/install_e2e
+ ninja
+ ninja install
+
+ # Symlink KLEE runtime + compiler-rt files missing from install rules
+ mkdir -p /workspace/install_e2e/lib/klee
+ ln -sf /opt/klee/lib/klee/runtime /workspace/install_e2e/lib/klee/runtime
+ ln -sf /usr/lib/llvm-16/lib/clang /workspace/install_e2e/lib/clang
+ '
+
+ - name: Run WASM integration tests
+ run: |
+ docker run --rm \
+ -u root \
+ -v "${{ github.workspace }}:/workspace" \
+ -w /workspace \
+ -e MAP2CHECK_PATH=/workspace/install_e2e \
+ -e WASI_SDK_PATH=/opt/wasi-sdk-33.0-x86_64-linux \
+ ghcr.io/hbgit/map2check-dev:latest bash -c '
+ # Ensure WABT + wasi-sdk are available in this fresh container
+ if [ ! -f /opt/wabt-1.0.41/include/wasm-rt.h ]; then
+ wget -q "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" -O /tmp/wabt.tar.gz
+ tar xzf /tmp/wabt.tar.gz -C /opt && rm /tmp/wabt.tar.gz
+ ln -sf /opt/wabt-1.0.41/bin/wasm2c /usr/local/bin/wasm2c
+ fi
+ if [ ! -d /opt/wasi-sdk-33.0-x86_64-linux ]; then
+ wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz" -O /tmp/wasi-sdk.tar.gz
+ tar xzf /tmp/wasi-sdk.tar.gz -C /opt && rm /tmp/wasi-sdk.tar.gz
+ fi
+ bash tests/integration/test_wasm_pipeline.sh
+ bash tests/integration/test_wasm_entrypoint.sh
+ '
diff --git a/.opencode/skills/book-to-skill/.github/FUNDING.yml b/.opencode/skills/book-to-skill/.github/FUNDING.yml
new file mode 100644
index 000000000..0d3015502
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [virgiliojr94]
diff --git a/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/bug_report.md b/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..5368d9c56
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,24 @@
+---
+name: Bug report
+about: Something went wrong during extraction or skill generation
+title: "[bug] "
+labels: bug
+---
+
+## What happened
+
+## What you expected
+
+## Source document
+- Format:
+- Pages / size:
+- Language:
+- Does `python3 scripts/extract.py --check` show the relevant extractor installed?
+
+## Repro
+
+
+## Environment
+- OS:
+- Python version:
+- book-to-skill version / commit:
diff --git a/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/feature_request.md b/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..b62645732
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Suggest an improvement to extraction, generation, or the skill format
+title: "[feat] "
+labels: enhancement
+---
+
+## Problem
+
+
+## Proposed change
+
+## How would we know it's better?
+
+
+## Alternatives considered
diff --git a/.opencode/skills/book-to-skill/.github/PULL_REQUEST_TEMPLATE.md b/.opencode/skills/book-to-skill/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 000000000..062ea05cc
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,70 @@
+
+
+## Summary (Required)
+
+
+
+## Type of change (Required)
+
+- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
+- [ ] ✨ Feature (non-breaking change that adds capability)
+- [ ] ⚡ Performance (faster / cheaper, no behavior change)
+- [ ] ♻️ Refactor (no behavior change)
+- [ ] 📝 Docs only
+- [ ] 🔒 Security
+- [ ] 🧹 Chore / CI / tooling
+- [ ] 💥 Breaking change (changes existing behavior or a public interface)
+
+## What it does (Required)
+
+- **Fixes:**
+- **Improves:**
+- **Adds:**
+
+## Motivation & context (Required)
+
+Closes #
+
+## How it works (Required for anything non-trivial)
+
+
+
+## Evidence (Required)
+
+- **Tests:**
+- **Before / after:**
+
+```
+
+```
+
+## Screenshots / output (Required if behavior or output changes — Optional for pure internal refactor)
+
+
+
+## Checklist (Required — all must be checked)
+- [ ] One focused change (one feature/fix per PR; not a stack of unrelated work)
+- [ ] Branch is rebased on the latest `master` and has no merge conflicts
+- [ ] Tests added/updated for behavior changes
+- [ ] `pytest -q` is green on a clean checkout
+- [ ] `ruff check .` is clean
+- [ ] `python3 tools/validate_skill.py SKILL.md` passes (if `SKILL.md` changed)
+- [ ] `CHANGELOG.md` updated under `## [Unreleased]`
+- [ ] No raw book text shipped; no net `SKILL.md` bloat without justification
+
+## Breaking changes & back-compat (Optional)
+
+
+## Notes for reviewers (Optional)
+
diff --git a/.opencode/skills/book-to-skill/.github/dependabot.yml b/.opencode/skills/book-to-skill/.github/dependabot.yml
new file mode 100644
index 000000000..811126aa6
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/dependabot.yml
@@ -0,0 +1,25 @@
+version: 2
+updates:
+ # Keep GitHub Actions fresh (and enables pinning actions to SHAs later).
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ commit-message:
+ prefix: ci
+ # One PR per week with all action bumps grouped, instead of one PR per action.
+ groups:
+ github-actions:
+ patterns: ["*"]
+
+ # Python dependencies (the pyproject extras: pdf/epub/docx/rtf/technical).
+ # Surfaces security alerts and version bumps for the optional extractors.
+ - package-ecosystem: pip
+ directory: "/"
+ schedule:
+ interval: weekly
+ commit-message:
+ prefix: deps
+ groups:
+ python:
+ patterns: ["*"]
diff --git a/.opencode/skills/book-to-skill/.github/workflows/ci.yml b/.opencode/skills/book-to-skill/.github/workflows/ci.yml
new file mode 100644
index 000000000..1195ed8b4
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/workflows/ci.yml
@@ -0,0 +1,168 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: test (py${{ matrix.python-version }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install pytest
+ run: pip install pytest
+ - name: Run test suite
+ run: pytest tests/ -q
+
+ lint:
+ name: lint (ruff)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
+ with:
+ python-version: "3.12"
+ - name: Install ruff
+ run: pip install ruff
+ - name: Ruff check
+ # High-value gate: syntax errors (E9, e.g. IndentationError) + pyflakes
+ # (F: undefined names, unused imports). Style rules are intentionally
+ # not gated yet to avoid blocking on cosmetic churn.
+ run: ruff check --select E9,F --target-version py310 book_to_skill/ scripts/ tests/ tools/
+
+ smoke:
+ name: smoke (dependency-free extraction)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
+ with:
+ python-version: "3.12"
+ - name: Extract a sample with no optional deps installed
+ run: |
+ set -euo pipefail
+ mkdir -p sample
+ printf '# Backpressure\n\nChapter 1\nBounded queues prevent overload.\n' > sample/note.md
+ export BOOK_SKILL_WORKDIR="$RUNNER_TEMP/work"
+ python3 scripts/extract.py sample/note.md --mode text --install-missing no
+ test -f "$BOOK_SKILL_WORKDIR/full_text.txt"
+ test -f "$BOOK_SKILL_WORKDIR/metadata.json"
+ grep -q "Backpressure" "$BOOK_SKILL_WORKDIR/full_text.txt"
+
+ security:
+ name: security (bandit + zizmor)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
+ with:
+ python-version: "3.12"
+ - name: Install scanners
+ run: pip install bandit zizmor
+ - name: Bandit — gate on HIGH severity
+ # Hard gate: only HIGH-severity / MEDIUM-confidence findings fail CI, to
+ # avoid blocking on the known-acceptable subprocess (pip install) calls.
+ # Ratchet down to medium once the open B314 docx-XML finding is hardened.
+ run: bandit -q -r book_to_skill scripts tools --severity-level high --confidence-level medium
+ - name: Bandit — report MEDIUM+ (informational)
+ run: bandit -q -r book_to_skill scripts tools -ll || true
+ - name: Zizmor — workflow audit (informational)
+ # Surfaces GitHub Actions risks (injection, pull_request_target misuse).
+ # Currently only flags unpinned-uses (tags vs SHA) — non-blocking until
+ # the actions are pinned to SHAs (Dependabot follow-up).
+ run: zizmor .github/workflows/ || true
+
+ validate-skill:
+ name: validate SKILL.md (Claude Code rules)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
+ with:
+ python-version: "3.12"
+ - name: Audit SKILL.md against Claude Code skill rules
+ # Fails on Claude-breaking issues (missing name/description, oversized
+ # description, a tool restriction that omits Bash while the skill shells
+ # out). Cross-agent metadata Claude ignores is reported as WARN only.
+ run: python3 tools/validate_skill.py SKILL.md
+
+ dependency-review:
+ name: dependency review (PR)
+ # Only runs on PRs: it diffs the base vs head dependency graph and flags any
+ # newly introduced dependency with a known CVE (or a denied license), so a
+ # contributor cannot slip a vulnerable/malicious package past review. The
+ # result is posted as a summary comment on the PR itself.
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write # post the vulnerability summary comment on the PR
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - name: Dependency review
+ uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+ with:
+ # Block the merge when an introduced dependency has a moderate+ CVE.
+ fail-on-severity: moderate
+ # Always leave the findings as a comment on the PR, even when clean.
+ comment-summary-in-pr: always
+
+ pr-template:
+ name: PR description check (PR)
+ # Enforces the PR template: required sections present, a type-of-change
+ # selected, and every Required-checklist item ticked. Skips Dependabot.
+ if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate PR description
+ # The body is passed via env (never interpolated into the shell) so a
+ # crafted PR description cannot inject commands.
+ env:
+ PR_BODY: ${{ github.event.pull_request.body }}
+ run: |
+ python3 - <<'PY'
+ import os, sys
+ body = os.environ.get("PR_BODY") or ""
+
+ def section(header):
+ i = body.find(header)
+ if i < 0:
+ return ""
+ rest = body[i + len(header):]
+ j = rest.find("\n## ")
+ return rest if j < 0 else rest[:j]
+
+ required = [
+ "## Summary", "## Type of change", "## What it does",
+ "## Motivation & context", "## Evidence", "## Checklist",
+ ]
+ errs = [f"missing required section: {h}" for h in required if h not in body]
+ if "- [x]" not in section("## Type of change").lower():
+ errs.append("Type of change: tick at least one box with [x]")
+ if "- [ ]" in section("## Checklist"):
+ errs.append("Checklist: every Required item must be ticked [x]")
+ if len(body.strip()) < 200:
+ errs.append("description too short, please fill the template")
+
+ if errs:
+ print("PR description is incomplete:\n")
+ for e in errs:
+ print(" - " + e)
+ print("\nFill the PR template (all (Required) sections) and push again.")
+ sys.exit(1)
+ print("PR description looks complete.")
+ PY
diff --git a/.opencode/skills/book-to-skill/.github/workflows/codeql.yml b/.opencode/skills/book-to-skill/.github/workflows/codeql.yml
new file mode 100644
index 000000000..87cdf574c
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/workflows/codeql.yml
@@ -0,0 +1,30 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+ schedule:
+ # Weekly scan so newly-disclosed query packs catch latent issues.
+ - cron: "27 4 * * 1"
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: analyze (python)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: python
+ queries: security-and-quality
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:python"
diff --git a/.opencode/skills/book-to-skill/.github/workflows/deploy-docs.yml b/.opencode/skills/book-to-skill/.github/workflows/deploy-docs.yml
new file mode 100644
index 000000000..c3b5e874b
--- /dev/null
+++ b/.opencode/skills/book-to-skill/.github/workflows/deploy-docs.yml
@@ -0,0 +1,50 @@
+name: Deploy docs
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - docs/**
+ - README.md
+ - SKILL.md
+ - BACKERS.md
+ - mkdocs.yml
+ - .github/workflows/deploy-docs.yml
+ pull_request:
+ paths:
+ - docs/**
+ - README.md
+ - SKILL.md
+ - BACKERS.md
+ - mkdocs.yml
+ - .github/workflows/deploy-docs.yml
+
+# Minimal: gh-deploy pushes the built site to the gh-pages branch.
+permissions:
+ contents: write
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+ steps:
+ # Actions pinned to commit SHAs (tags in comments) so a supply-chain
+ # swap on a moving tag cannot alter the build. Zizmor-clean.
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.12"
+ - name: Install MkDocs Material
+ run: pip install mkdocs-material
+ - name: Assemble docs sources
+ # Single-source: the Guide and Skill Reference pages are the repo-root
+ # README.md / SKILL.md, copied in (never committed under docs/). The
+ # landing page docs/index.md is committed and curated separately.
+ run: |
+ cp README.md docs/guide.md
+ cp SKILL.md docs/skill-reference.md
+ cp BACKERS.md docs/BACKERS.md
+ - name: Build
+ run: mkdocs build
+ - name: Deploy to gh-pages (master push only)
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ run: mkdocs gh-deploy --force
diff --git a/.opencode/skills/book-to-skill/BACKERS.md b/.opencode/skills/book-to-skill/BACKERS.md
new file mode 100644
index 000000000..fe52f4e8d
--- /dev/null
+++ b/.opencode/skills/book-to-skill/BACKERS.md
@@ -0,0 +1,27 @@
+# Backers & Sponsors
+
+Thank you to everyone who sponsors my open-source work. Your support funds PR reviews, multilingual edge-case fixes, releases, and documentation, and keeps these tools free, open, and privacy-first.
+
+**[Become a sponsor → github.com/sponsors/virgiliojr94](https://github.com/sponsors/virgiliojr94)**
+
+---
+
+## 🥇 Gold Sponsors
+
+_Be the first._
+
+## 🥈 Silver Sponsors
+
+_Be the first._
+
+## 🥉 Bronze Sponsors
+
+_Be the first._
+
+## 💖 Backers
+
+_Be the first._
+
+---
+
+This file is updated as sponsorships come in. If you sponsored and want a different name, link, or logo (or prefer to stay anonymous), open an issue or reply on your sponsorship and I will adjust it.
diff --git a/.opencode/skills/book-to-skill/CHANGELOG.md b/.opencode/skills/book-to-skill/CHANGELOG.md
new file mode 100644
index 000000000..003c6581f
--- /dev/null
+++ b/.opencode/skills/book-to-skill/CHANGELOG.md
@@ -0,0 +1,177 @@
+# Changelog
+
+All notable changes to **book-to-skill** are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Documentation
+- Clarified the two install paths so they are not confused: **`git clone` into a
+ skills folder** registers the `/book-to-skill` agent skill (Claude Code / Copilot
+ CLI / Amp), while **`pip install book-to-skill`** installs only the standalone
+ extraction CLI and does not register the skill. README and the docs landing now
+ show both explicitly.
+- README now leads with the measured headline (24×–51× fewer tokens than a
+ context-dump) and a 3-step "how it works", so the value lands in the first
+ screen instead of being buried mid-page.
+
+### Security
+- **DOCX XXE / Billion Laughs hardening** — the DOCX extractor now scans the
+ archive and rejects any XML part that declares a DTD or entities before
+ parsing, blocking XML external-entity and entity-expansion attacks (#53, #54).
+- **Subprocess argument-injection hardening** — file paths are absolutised
+ before being passed to `pdftotext` / `pdfinfo` / `ebook-convert`, so a filename
+ starting with `-` cannot be interpreted as a command-line option (#53, #54).
+- **Dependency CVE review on pull requests** — a `dependency-review` CI job
+ flags any newly introduced dependency carrying a moderate-or-higher CVE (or a
+ denied license) and posts the findings as a PR comment. Dependabot now also
+ covers the `pip` ecosystem.
+
+### Changed
+- **The `pdf` extra now installs `pypdf` instead of the deprecated `PyPDF2`**
+ (`pip install book-to-skill[pdf]`). `pypdf` is the maintained successor;
+ `PyPDF2` is end-of-life and no longer receives security fixes (#54).
+
+### Fixed
+- Text files (`.txt`, `.md`, `.rst`, `.adoc`, `.html`, `.rtf`) saved as UTF-16 or
+ UTF-32 (e.g. Windows Notepad "Unicode" or PowerShell output) are now decoded by
+ their byte-order mark instead of being read as `cp1252`/`latin-1` mojibake.
+- The dependency-free RTF fallback (used when `striprtf` is not installed) now
+ decodes `\uN` unicode escapes — smart quotes, dashes, accented letters — instead
+ of dropping them and leaving only the ASCII fallback character.
+- The stdlib HTML parser (the fallback for HTML files and EPUB extraction when
+ BeautifulSoup is not installed) no longer decodes HTML entities twice, so
+ double-encoded entities such as `&` survive intact.
+- The dependency-free DOCX fallback (used when `python-docx` is not installed)
+ now reconstructs tables as tab-joined rows in document order, instead of
+ flattening each cell onto its own line.
+- The dependency-free EPUB extractor (used when `ebooklib` is not installed) now
+ reads content in true spine (reading) order instead of manifest order, so
+ chapters are no longer scrambled. Content documents not listed in the spine are
+ still included (appended after the spine content).
+
+## [1.2.0] — 2026-06-17
+
+### Added
+- **Installable Python package.** The extractor is now a proper `book_to_skill`
+ package with a `pyproject.toml` (hatchling build backend), a `book-to-skill`
+ console script, and `python -m book_to_skill`. Optional extractors are exposed
+ as extras (`epub`, `pdf`, `docx`, `rtf`, `technical`, `all`); the base install
+ stays dependency-free with stdlib fallbacks. `requires-python = ">=3.9"`.
+ `scripts/extract.py` is kept as a thin shim so the existing skill flow is
+ unchanged (#34, #35, #48).
+- **Markdown / AsciiDoc heading detection.** Structure detection recognizes ATX
+ headings (`#`, `==`) as chapters when no numeric "Chapter N" headings are
+ present, fixing a zero-chapter result for `.md` / `.adoc` sources. Headings
+ inside fenced code blocks are ignored (#44).
+- **setext / reStructuredText underline headings** — a title line over a row of
+ `=` or `-` is now detected, so `.rst` and setext-style Markdown no longer
+ report zero chapters. Guarded against thematic breaks, table borders, and YAML
+ front matter (#51).
+- **More chapter languages.** Chapter-word detection now covers French, German,
+ Italian, and Dutch (`Chapitre`, `Kapitel`, `Capitolo`, `Hoofdstuk`), and
+ heading titles starting with `Ü`/`Û`/`Ý`/`Þ` (e.g. "Überblick") are accepted (#49).
+- **Multilingual table-of-contents detection** — Chinese, Japanese, French,
+ German, Italian, and Dutch (#44).
+
+### Fixed
+- **Full-width Arabic digits in CJK chapter headings** — `第1章` (U+FF10–FF19),
+ common in Japanese typesetting, is now detected like `第1章` (#46).
+- **Parser errors are no longer swallowed silently.** Unexpected exceptions in
+ any extractor are logged to stderr (extractor name + exception type) while the
+ fallback chain still returns `None` and continues, so corrupt files and
+ encoding errors are diagnosable (#47, #50).
+- **All-punctuation ATX "titles"** (e.g. a `===== =====` table border) are no
+ longer miscounted as chapters (#51).
+- **Package imports on interpreters that evaluate annotations eagerly.** Added
+ `from __future__ import annotations` to every module using PEP 604 unions
+ (`str | None`), so the package imports and runs cleanly on Python 3.9 (#34).
+
+### Security
+- **CI security scanning** — CodeQL (Python, security-and-quality + weekly
+ schedule), Bandit (gates on HIGH severity; reports MEDIUM+ informationally),
+ and Zizmor (GitHub Actions workflow audit, informational), plus a Dependabot
+ config for the `github-actions` ecosystem. Known finding to harden next:
+ Bandit B314 (`xml.etree.ElementTree.fromstring` in the DOCX parser).
+
+### Changed
+- CI test matrix now includes Python 3.9 so the import path above is guarded and
+ cannot silently re-break.
+
+## [1.1.0] — 2026-06-12
+
+### Added
+- **GitHub Copilot CLI as a first-class target** — the same `SKILL.md` now
+ discovers, installs, and runs across GitHub Copilot CLI, Amp, and Claude Code
+ via the open Agent Skills standard. Skill Locations cover 8 discovery paths and
+ the script probe walks all of them (#30).
+- **`validate_skill.py --lens claude|copilot|amp`** — audits a generated SKILL.md
+ against each host's rules; `claude` stays the default for CI back-compat (#30).
+- **Attribution banner** — `scripts/banner.txt` is printed at the start of each
+ run (best-effort, never fails the run).
+
+### Changed
+- `SKILL.md` frontmatter trimmed toward the open-standard minimum and the
+ description now names all three hosts so each agent's auto-loader picks it up (#30).
+- README headline + "Agent Skills" badge; install/usage sections cover all three
+ hosts. `docs/ARCHITECTURE.md` shows per-host destination paths (#30).
+
+### Notes
+- `allowed-tools` was dropped from the frontmatter for host-neutrality; the skill
+ is conformant on all three hosts (validated with all three lenses). If Claude
+ users hit permission-prompt friction, the Bash grant from #18 will be restored
+ with Claude-native tokens (Copilot ignores the key either way).
+
+## [1.0.0] — 2026-06-08
+
+First formally tagged release. The converter is stable, multi-format, and
+validated on real books.
+
+### Added
+- **Multi-format extraction** — PDF, EPUB, DOCX, HTML, Markdown, reStructuredText,
+ AsciiDoc, RTF, and MOBI/AZW/AZW3 (via Calibre), through a modular `extractor`
+ package with per-format parsers and graceful stdlib fallbacks.
+- **`extract.py --check`** — preflight that reports which extractors are installed
+ for every format and the exact command to install whatever is missing (#21).
+- **Adaptive per-chapter depth** — token budget scales with `BOOK_TYPE × DEPTH`;
+ study-depth chapters require a worked example, and the cheatsheet is generated as
+ a decision/reasoning layer (decision rules, trees, trade-offs, thresholds, tells)
+ rather than a keyword list (#20).
+- **`tools/discovery_tax.py`** — measures the "Discovery Loop Tax": tokens a
+ context-dump vs a discovery loop vs book-to-skill put into context to answer one
+ question, on a real book (#23).
+- **Update / fold-in workflow** — merge new sources into an existing skill, keeping
+ chapter index, topic index, glossary, patterns, and cheatsheet in sync.
+- **GitHub Actions CI** — lint (ruff), test matrix (py3.10–3.13), dependency-free
+ smoke test, and SKILL.md Claude-conformance validation (#15, #18).
+
+### Changed
+- **README positioning** — copyright & fair-use section, "Beyond books" use cases,
+ context-dump / RAG / 1M-window FAQ, and a measured Discovery Loop Tax + real
+ per-conversion cost table across four books (#19, #27).
+- Default output target is `~/.claude/skills/` for Claude Code, with Amp skill
+ directories also supported (#13, #14).
+
+### Fixed
+- **Chapter detection** — scans the full text (was capped at 50k chars) and counts
+ distinct explicit `Chapter N` / `Capítulo N` headings, rejecting numbered list
+ items, inline cross-references, and years; adds Portuguese support (#26).
+- **Roman-numeral headings** — `I: Loomings`, `II. The Carpet-Bag` are now detected
+ with canonical-numeral validation (#28).
+- **EPUB extraction** — resolve OPF-relative hrefs in the stdlib zipfile fallback (#11, #12).
+- **Batch resilience** — one bad source is skipped with a warning instead of aborting
+ the whole run; explicit input order is preserved (#7).
+
+### Known limitations
+- Chapter auto-detection needs explicit `Chapter N` / `Capítulo N` or Roman-numeral
+ headings. Books that head chapter bodies with bare titles (e.g. *Moby-Dick*, where
+ numerals appear only in the table of contents) or use section titles (e.g. Pro Git)
+ do not auto-segment.
+- Technical PDFs extracted in text mode may lose heading structure; use technical
+ mode (Docling) to preserve tables, code, and headings.
+
+[1.2.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.2.0
+[1.1.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.1.0
+[1.0.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.0.0
diff --git a/.opencode/skills/book-to-skill/CONTRIBUTING.md b/.opencode/skills/book-to-skill/CONTRIBUTING.md
new file mode 100644
index 000000000..df0f2032f
--- /dev/null
+++ b/.opencode/skills/book-to-skill/CONTRIBUTING.md
@@ -0,0 +1,55 @@
+# Contributing to book-to-skill
+
+Thanks for helping improve book-to-skill. This project turns books and documents
+into structured agent skills; contributions that make extraction more robust,
+generation higher-signal, or the docs clearer are all welcome.
+
+## Ground rules
+
+- **Measure, don't assert.** A change that claims a gain should show it — a test,
+ a benchmark number from `tools/discovery_tax.py`, or a before/after. PRs that add
+ weight (e.g. to `SKILL.md`, which is loaded on every run) without a demonstrated
+ benefit will be asked for evidence first.
+- **Keep `SKILL.md` lean.** It is the always-loaded converter spec. Prefer editing
+ existing steps over adding new ones; justify net additions.
+- **Never ship raw book text.** Generated skills synthesize; they never reproduce
+ long passages. Respect source licenses (see the README's Copyright section).
+
+## Development
+
+```bash
+git clone https://github.com/virgiliojr94/book-to-skill.git
+cd book-to-skill
+python3 -m venv .venv && . .venv/bin/activate
+pip install pytest ruff
+python3 scripts/extract.py --check # see which optional extractors you have
+```
+
+Run the checks the CI runs before opening a PR:
+
+```bash
+ruff check .
+pytest -q
+python3 tools/validate_skill.py SKILL.md
+```
+
+## Pull requests
+
+- One focused change per PR; small and reviewable.
+- **Conventional Commits** for titles and commits: `feat:`, `fix:`, `docs:`,
+ `chore:`, `test:`, `ci:` … (e.g. `fix(extractor): scan full text`).
+- Add or update tests for any behavior change.
+- Update `CHANGELOG.md` under an `## [Unreleased]` section.
+- CI must be green (lint, test matrix py3.10–3.13, smoke, SKILL.md validation).
+
+## Releases
+
+Maintainers cut releases with semantic versioning: tag `vX.Y.Z`, move the
+`Unreleased` changelog section under the new version with the date, and publish a
+GitHub Release using those notes.
+
+## Reporting bugs / requesting features
+
+Open an issue using the templates in `.github/ISSUE_TEMPLATE/`. For extraction
+bugs, please include the format, page count, and whether `--check` shows the
+relevant extractor installed.
diff --git a/.opencode/skills/book-to-skill/LICENSE.md b/.opencode/skills/book-to-skill/LICENSE.md
new file mode 100644
index 000000000..b000bdffe
--- /dev/null
+++ b/.opencode/skills/book-to-skill/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 virgiliojr94
+
+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.
\ No newline at end of file
diff --git a/.opencode/skills/book-to-skill/README.md b/.opencode/skills/book-to-skill/README.md
new file mode 100644
index 000000000..f188f5033
--- /dev/null
+++ b/.opencode/skills/book-to-skill/README.md
@@ -0,0 +1,472 @@
+
+
+
+
+
book-to-skill
+
+
+ Turn any technical book, document folder, or collection of sources into a unified agent skill — ready to study, reference, and use while you work in GitHub Copilot CLI, Amp, or Claude Code.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🏆 #10 Python Repository of the Day and #25 Repository of the Day on Trendshift (May 23, 2026)
+
+ 24×–51× fewer tokens than dumping the book into context to answer one question, measured on real books (how it's measured).
+
+
+**How it works, in 3 steps:**
+
+1. **Point** it at a file, folder, or glob — `/book-to-skill ./my-book.pdf`
+2. **It distills** the book into a skill — frameworks, decision rules, anti-patterns, and per-chapter files. Structure, not a summary.
+3. **Your agent loads it on demand** — ask `/my-book replication` and it reads the right chapter and answers from the real content, no hallucination.
+
+---
+
+## 🤔 Why
+
+You buy a great technical book. You read it once. Three months later you can't remember chapter 7 existed.
+
+The usual workarounds don't help:
+- 📄 "Let me just search the PDF" → you get a list of pages, not answers
+- 🧠 "I'll ask the agent about this book" → it either hallucinates or says it doesn't have the content
+- 📝 "I'll take notes as I read" → you end up with a 200-line doc you never open again
+
+**book-to-skill solves this by turning the book into a structured skill your agent loads on demand.**
+
+Once installed, you just type `/your-book-slug replication` and the agent reads the right chapter and answers from the actual content. No hallucination. No digging through PDFs. The book becomes part of your workflow.
+
+Works with any host that supports the open [Agent Skills](https://github.com/agentskills/agentskills) standard — GitHub Copilot CLI, Amp, and Claude Code all read the same `SKILL.md` format.
+
+---
+
+## 📦 What it generates
+
+Running `/book-to-skill your-book.pdf` (or a folder, glob, or list of files) creates a full skill in your agent's skills directory (`~/.copilot/skills//` for Copilot CLI, `~/.agents/skills//` for Amp or cross-agent, `~/.claude/skills//` for Claude Code):
+
+| File | Purpose | Size |
+|------|---------|------|
+| `SKILL.md` | Core mental models + chapter index | ~4,000 tokens |
+| `chapters/ch01-*.md` … | One file per chapter, loaded on-demand | ~1,000 tokens each |
+| `glossary.md` | Every key term, alphabetically sorted with chapter refs | ~1,500 tokens |
+| `patterns.md` | All techniques, algorithms, and design patterns | ~2,000 tokens |
+| `cheatsheet.md` | Decision tables and quick-reference rules | ~1,000 tokens |
+
+**Chapter files are loaded on-demand** — they don't count against the skill budget until you ask about that topic.
+
+---
+
+## 🏢 Beyond books
+
+The name says "book", but the input is any structured prose. The same extraction works on knowledge you own and re-read constantly:
+
+- **Internal documentation** — architecture decision records, runbooks, onboarding guides. Fold a whole `docs/` folder into one skill and ask it while you code.
+- **Brand & design systems** — voice guidelines, tone-of-voice docs, component principles. Turn a brand book into a skill your team queries instead of skimming a 60-page PDF.
+- **Research clusters** — a stack of papers plus your own notes, merged into a single unified skill and updated as new material lands (see [Update / fold-in](#-usage)).
+- **Specs & standards** — RFCs, API contracts, compliance docs you reference but never memorize.
+
+If you re-open a document often enough to wish you'd memorized it, it's a candidate.
+
+---
+
+## 🚀 Usage
+
+```
+/book-to-skill ... [skill-name-slug]
+```
+
+Supported document formats: PDF, EPUB, DOCX, TXT, Markdown, reStructuredText, AsciiDoc, HTML, RTF, MOBI/AZW/AZW3.
+
+**Examples:**
+
+```bash
+# Process several files together into a unified skill
+/book-to-skill ~/papers/paper1.pdf ~/notes/export.txt unified-research
+
+# Process all supported files in a folder together
+/book-to-skill ~/workspace/project-docs/ project-knowledge
+
+# Process files matching a glob pattern
+/book-to-skill "~/books/*.epub" my-library
+
+# Update/fold new material into an existing skill folder
+/book-to-skill ~/articles/new-paper.pdf ~/.claude/skills/project-knowledge
+```
+
+After the skill is created, use it like any other agent skill:
+
+```bash
+/designing-data-intensive-apps # load core mental models
+/designing-data-intensive-apps replication # find and explain a topic
+/designing-data-intensive-apps ch05 # dive into chapter 5
+/designing-data-intensive-apps "what chapters do you have?"
+```
+
+In GitHub Copilot CLI you may need to run `/skills reload` after the file is written so the new skill appears in `/skills list`. Claude Code and Amp pick it up on the next session.
+
+---
+
+## 🔧 Requirements
+
+The extractor tries tools in order per format and uses the first available. If nothing is installed, it tells you which command to run. Plain text, Markdown, reStructuredText and AsciiDoc need no extra deps.
+
+> **Check your setup in one command:** `python3 scripts/extract.py --check` prints which extractors are installed for every format and the exact command to install anything missing — no file needed.
+
+**PDF — choose by book type:**
+
+| Book type | Tool | Install | Speed |
+|-----------|------|---------|-------|
+| Text-heavy (prose, few tables) | `pdftotext` (poppler) | `sudo apt install poppler-utils` | ⚡ instant |
+| Text-heavy fallback | `pypdf` | `pip3 install pypdf` | ⚡ instant |
+| Text-heavy fallback | `pdfminer.six` | `pip3 install pdfminer.six` | ⚡ instant |
+| **Technical (code, tables, formulas)** | **`docling`** | `pip3 install docling` | ~1.5s/page |
+
+> Before extraction begins, the skill asks you whether the book is **technical** or **text-heavy** and picks the right tool automatically. Docling preserves markdown tables and code blocks; pdftotext is faster for prose-only books.
+
+**EPUB:**
+
+| Tool | Install | Quality |
+|------|---------|---------|
+| `ebooklib` + `beautifulsoup4` | `pip3 install ebooklib beautifulsoup4` | ⭐⭐⭐ Best |
+| stdlib `zipfile` | built-in — no install needed | ⭐⭐ Always available |
+
+**Other formats:**
+
+| Format | Tool | Install |
+|--------|------|---------|
+| DOCX | `python-docx` (fallback: stdlib ZIP/XML) | `pip3 install python-docx` |
+| HTML | `beautifulsoup4` (fallback: stdlib `html.parser`) | `pip3 install beautifulsoup4` |
+| RTF | `striprtf` (fallback: regex) | `pip3 install striprtf` |
+| MOBI / AZW / AZW3 | Calibre `ebook-convert` (external app, not pip) | https://calibre-ebook.com/download |
+| TXT / Markdown / reStructuredText / AsciiDoc | built-in | — |
+
+---
+
+## ⚙️ How it works
+
+```
+One file · a folder · a glob · a list of paths
+ │
+ ▼
+Step 1.5 — "Technical or text-heavy book?"
+ │
+ ├── technical → Docling (tables + code blocks as markdown, ~1.5s/page)
+ └── text → pdftotext → pypdf → pdfminer (instant)
+ │
+ ▼
+scripts/extract.py --mode
+ per source: PDF → pdftotext/Docling · EPUB → ebooklib → stdlib zipfile · DOCX/HTML/RTF/…
+ (one bad source is skipped with a warning; the rest still process)
+ │
+ ├── /tmp/book_skill_work/full_text.txt (all sources merged, with source markers)
+ └── /tmp/book_skill_work/metadata.json (aggregated stats + per-source array)
+ │
+ ▼
+ Claude analyzes structure
+ (title, author, chapters, ToC — spanning all sources)
+ ── or, if targeting an existing skill: folds new content in (Mode 4)
+ │
+ ▼
+ Generates per-chapter summaries (800–1,200 tokens each)
+ technical → includes Code Examples + Reference Tables sections
+ Generates glossary, patterns, cheatsheet
+ Generates master SKILL.md with core mental models
+ │
+ ▼
+ Skill written to one of:
+ ~/.copilot/skills// (GitHub Copilot CLI)
+ ~/.agents/skills// (Copilot CLI or Amp, cross-agent)
+ ~/.claude/skills// (Claude Code)
+ /tmp/book_skill_work/ 🗑️ cleaned up
+```
+
+**Extraction benchmark** (103-page technical book, CPU only):
+
+| Method | Time | Tokens | Tables | Code blocks |
+|--------|------|--------|--------|-------------|
+| pdftotext | 0.1s | 27K | 0 | 0 |
+| Docling | 164s | 27K (+1.2%) | 48 | 36 |
+
+**Real conversions** (measured: pages, extracted tokens, chapters auto-detected,
+estimated one-pass cost on Claude Sonnet 4.5 at \$3/\$15 per MTok):
+
+| Book | Format | Pages | Tokens | Chapters | ~Cost |
+|------|--------|------:|-------:|---------:|------:|
+| Think Python 2 | PDF | 244 | 119K | 19 | \$0.88 |
+| Working Backwards | PDF | 371 | 175K | 10 | \$0.96 |
+| Pro Git | PDF | 501 | 229K | — † | \$1.23 |
+| Moby-Dick | EPUB | — | 301K | — † | \$1.42 |
+
+† Chapter auto-detection needs explicit `Chapter N` / `Capítulo N` headings. Pro Git
+uses section titles and Moby-Dick uses chapter *titles* / roman numerals, so neither
+auto-segments — extraction and conversion still work, but you point at sections
+manually. A full skill costs roughly **\$1 per book**; far less than re-reading the
+PDF every session.
+
+
+Design principles (click to expand)
+
+1. **Density over completeness** — a 1,000-token summary beats a 10,000-token excerpt
+2. **Practitioner voice** — "Use X when Y", not "The book explains X"
+3. **Front-loaded SKILL.md** — compaction keeps the first ~5,000 tokens; the most important content comes first
+4. **On-demand chapters** — the topic index tells Claude which file to read; chapters load only when needed
+5. **Never raw text** — always synthesize, summarize, extract signal from the source
+
+
+
+---
+
+## 🧾 The Discovery Loop Tax
+
+A PDF-reading agent doesn't just read — it *navigates*. Ask it one question and it
+fetches the table of contents, notices a term it can't define, pulls more pages,
+backtracks. Every one of those hops lands in the conversation history and gets
+**re-processed on every subsequent turn**. To stay inside its budget, a sub-agent
+is then forced to compress what it read at brutal ratios, handing the main agent a
+**degraded summary it can't fact-check** against the source.
+
+book-to-skill pays the navigation cost **once, at compile time**. At runtime the
+assistant loads a small resident core plus the one pre-compiled chapter it needs —
+no discovery loop, no compress-to-fit, and the full extracted source stays on disk
+for verification.
+
+**Measured, not asserted.** Running [`tools/discovery_tax.py`](tools/discovery_tax.py)
+on three real books — tokens entering context to answer a single targeted question
+(book-to-skill = resident core + one compiled chapter ≈ 5,000 tokens):
+
+| Book (size) | Context-dump | Discovery loop | book-to-skill | vs dump / loop |
+|-------------|-------------:|---------------:|--------------:|:--------------:|
+| Think Python 2 (119K, small chapters) | 119,264 | 12,152 | ~5,000 | 24× / **2.4×** |
+| Working Backwards (175K, medium chapters) | 175,253 | 33,444 | ~5,000 | 35× / 6.7× |
+| AI Engineering (256K, large chapters) | 256,287 | 77,866 | ~5,000 | 51× / **15.6×** |
+
+The advantage **scales with chapter size**: against a context-dump it's consistently
+24–51× (and that cost recurs *every turn*); against a one-time discovery loop it
+ranges from a modest 2.4× on a book of small chapters to 15.6× on one of large
+chapters. Reproduce on your own book:
+
+```bash
+python3 tools/discovery_tax.py --full-text /tmp/book_skill_work/full_text.txt --target-chapter 5
+```
+
+> **Honest caveats:** (1) the discovery figures are a one-time cost and a *model*
+> using the book's real ToC/chapter sizes — a well-tuned agent lands nearer the best
+> case; the context-dump cost, by contrast, recurs on **every** turn. (2) The tool
+> needs explicit `Chapter N` / `Capítulo N` headings to segment a book; titles-only
+> or roman-numeral books (and EPUBs extracted without `ebooklib`) won't segment
+> cleanly. book-to-skill wins when you return to the knowledge repeatedly; for a
+> single one-off read, a plain PDF agent is fine.
+
+---
+
+## ❓ FAQ
+
+**"Can't I just dump the PDF/EPUB into my Claude project context?"**
+
+You can — but every conversation will burn that token budget upfront. A 400-page book is ~200K tokens. With a skill, only the chapters relevant to your question load — typically a SKILL.md core (~4K) plus the one chapter you asked about (~1K). The rest stays on disk until you need it.
+
+The economics are amortization, not size. Pasting the book pays the full token bill **on every turn of every session, forever**. book-to-skill pays the extraction cost **once** and every future conversation loads only the slice it needs. The bigger your context window, the more this matters — a large window makes the dump *possible*, not *cheap*.
+
+More importantly: raw text injection is retrieval. A skill is reasoning. When you load a chapter file, Claude isn't searching for keyword matches — it's working with pre-extracted named frameworks, principles, and mental models structured for application, not for reading.
+
+---
+
+**"Claude has a 1M-token context window now — can't I just keep the whole book loaded?"**
+
+A bigger window changes what *fits*, not what's *smart*. Three reasons it isn't a substitute:
+
+- **You pay per token, per call.** A 1M window doesn't make those tokens free — it makes a large, recurring bill possible. The skill loads kilobytes, not megabytes.
+- **Recall degrades with fill.** Models lose precision retrieving a specific fact buried in a near-full context ("lost in the middle"). A 1K curated chapter beats 200K of raw prose for answering one question.
+- **Window ≠ structure.** A full book in context is still raw text the model must re-parse every turn. The skill ships pre-extracted frameworks — reasoning, not retrieval.
+
+Use the big window for what it's good at: a one-off pass over material you'll never need again. Use a skill for knowledge you'll reach for repeatedly.
+
+---
+
+**"Isn't this just RAG?"**
+
+RAG works at query time: chunk the book → embed everything → find similar vectors → inject into prompt. It's optimized for "find me the part that talks about X."
+
+book-to-skill works at compile time: one deep analysis run extracts the author's actual frameworks, names them, describes when to use each, captures the anti-patterns. The output is structure the author spent years building — not a similarity search over their sentences.
+
+RAG answers: *"here are chunks close to your query."*
+A skill answers: *"here are the 12 frameworks this author built, ready to reason with."*
+
+Pick by shape of the job:
+
+- **Wide and shallow** — a library of dozens of books, "find the part that mentions X" → a RAG tool (e.g. CandleKeep) wins.
+- **Narrow and deep** — one book or a tight cluster of related sources, frameworks you apply while you work → book-to-skill wins.
+
+They're complementary, not competing: RAG indexes a shelf, book-to-skill masters a spine.
+
+---
+
+**"Popular books are already in Claude's training data. Why bother?"**
+
+For widely-known books (Clean Code, DDIA, Pragmatic Programmer), Claude has general knowledge — but it's compressed, averaged across the entire internet's discussion of the book, and may hallucinate specific quotes or chapter locations.
+
+book-to-skill works from your actual copy. Every framework name, every anti-pattern list, every chapter number is grounded in the text you provided. No training data drift, no hallucinated chapter titles.
+
+It also shines for books Claude doesn't know at all: niche technical references, internal company documentation, recent publications, translated works.
+
+---
+
+**"NotebookLM handles multiple books better."**
+
+Absolutely true — if your workflow is "I have 80 separate books and I want to search across all of them," NotebookLM is the right tool.
+
+book-to-skill is built for a different job: you want to go deep on a specific topic or library, having multiple related documents (papers, chapters, notes) folded into a single unified skill, and even updating it over time as new material arrives! This integrates your customized knowledge base right into your coding or writing workflow, rather than in a separate browser tab.
+
+---
+
+## 📥 Install
+
+> **Two ways to use it, do not confuse them:**
+> - **As an agent skill** (the `/book-to-skill` command in Claude Code, Copilot CLI, or Amp) → **`git clone` into your skills folder** (below). This is what gives you the slash command and the full convert-a-book flow.
+> - **As a standalone CLI** (just the text extractor) → `pip install book-to-skill`, then `book-to-skill --help`. This does **not** register the agent skill; it only installs the extraction engine. See [the CLI section](#standalone-cli-pip).
+
+The skill follows the open [Agent Skills](https://github.com/agentskills/agentskills) standard, so a single install works for any compatible host.
+
+**GitHub Copilot CLI** (personal skill):
+
+```bash
+git clone https://github.com/virgiliojr94/book-to-skill.git ~/.copilot/skills/book-to-skill
+# then, in a `copilot` session:
+/skills reload
+/skills info book-to-skill
+```
+
+Or the cross-agent path that Copilot CLI and Amp both discover:
+
+```bash
+git clone https://github.com/virgiliojr94/book-to-skill.git ~/.agents/skills/book-to-skill
+```
+
+**Claude Code**:
+
+Copy this into your Claude Code session:
+
+```
+Install book-to-skill: https://raw.githubusercontent.com/virgiliojr94/book-to-skill/master/SKILL.md
+```
+
+Or manually using standard `git clone` (ensures modular engine files are fetched correctly):
+
+```bash
+git clone https://github.com/virgiliojr94/book-to-skill.git ~/.claude/skills/book-to-skill
+```
+
+Then in any agent session:
+
+```bash
+/book-to-skill ~/path/to/your-book.pdf
+# or
+/book-to-skill ~/path/to/your-book.epub
+```
+
+### Standalone CLI (pip)
+
+`pip install book-to-skill` is a **separate, optional** path. It installs only the
+text-extraction engine as a CLI, for scripting or to grab the optional extractors;
+it does **not** register the `/book-to-skill` agent skill (use the `git clone` above
+for that).
+
+```bash
+pip install "book-to-skill[pdf,epub,docx]" # engine + optional extractors
+book-to-skill ~/path/to/book.pdf --mode text # or: python -m book_to_skill ...
+book-to-skill --check # report which extractors are installed
+```
+
+---
+
+## 📁 Repository structure
+
+```
+book-to-skill/
+├── SKILL.md # Skill definition + step-by-step instructions (the generator spec)
+├── scripts/
+│ ├── extract.py # Thin entrypoint wrapper
+│ └── extractor/ # Modular extraction package
+│ ├── config.py # Extensions, paths, dependency constants
+│ ├── dependencies.py # optional-dep probing + --check
+│ ├── exceptions.py # ExtractionError (per-source failures, batch-safe)
+│ ├── utils.py # CLI parsing, multi-source resolution, chapter detection, runner
+│ └── parsers/ # Format-specific parsers (pdf, epub, docx, html, rtf, calibre, text)
+├── tools/
+│ ├── discovery_tax.py # measures token cost vs context-dump / discovery loop
+│ └── validate_skill.py # checks a generated SKILL.md against host rules (--lens claude|copilot|amp)
+├── tests/ # pytest suite (extraction, detection, discovery tax)
+├── docs/
+│ ├── PERFORMANCE.md # measured benchmarks, discovery tax, cost
+│ └── ARCHITECTURE.md # pipeline + component map
+├── CHANGELOG.md # release history (semver)
+├── CONTRIBUTING.md # dev setup, PR conventions, release process
+├── SECURITY.md # vulnerability reporting
+└── README.md # This file
+```
+
+---
+
+## ⚖️ Copyright & fair use
+
+book-to-skill ships **no book content** — not a single page. It's a converter you point at files you already own.
+
+- **Processing is local.** Extraction and analysis run on your machine. Your files are never uploaded by this tool. (If your agent's model runs in the cloud, the text you feed it follows that provider's normal data terms — same as any prompt.)
+- **You use your own copy.** Bring a book you bought, docs your company owns, or papers you have the right to read.
+- **The output is your notes.** A generated skill is a structured, synthesized derivative — framework names, definitions, takeaways — not a reproduction of the text. The skill explicitly never copies raw passages (see Quality Rule #7). Treat it like handwritten study notes: yours, for personal use.
+- **Don't redistribute.** Publishing or sharing a generated skill of a copyrighted work can infringe the rights holder. Keep skills of third-party books private. Internal docs, your own writing, and openly-licensed material are fine to share within the bounds of their license.
+
+When in doubt, follow the license or terms of the source document. This project is a tool; how you use it is on you.
+
+---
+
+## 💖 Sponsors
+
+book-to-skill is free and MIT-licensed, maintained on personal time. If it saves you tokens or study hours, consider sponsoring its upkeep: PR reviews, multilingual fixes, releases, and docs.
+
+**[Become a sponsor → github.com/sponsors/virgiliojr94](https://github.com/sponsors/virgiliojr94)**
+
+Every sponsor is listed in [BACKERS.md](BACKERS.md). Thank you for keeping open, privacy-first tooling alive. ✨
+
+## License
+
+MIT — applies to the converter (code + skill definition) in this repository, **not** to any book or document you process with it.
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/.opencode/skills/book-to-skill/SECURITY.md b/.opencode/skills/book-to-skill/SECURITY.md
new file mode 100644
index 000000000..3489896a5
--- /dev/null
+++ b/.opencode/skills/book-to-skill/SECURITY.md
@@ -0,0 +1,33 @@
+# Security Policy
+
+## Scope
+
+book-to-skill is a local conversion tool. It reads document files you point it at
+and writes skill files to your skills directory. It does **not** upload your files,
+phone home, or run a network service. The main security surface is:
+
+- the Python extraction code (parsing untrusted document files), and
+- the optional dependencies it can install on request (`pip install …` when you
+ choose `--install-missing yes`).
+
+## Supported versions
+
+The latest released `1.x` version receives fixes. Please reproduce issues against
+the most recent tag before reporting.
+
+## Reporting a vulnerability
+
+Please **do not** open a public issue for a security problem. Instead use GitHub's
+private vulnerability reporting:
+
+- Go to the repository's **Security** tab → **Report a vulnerability**.
+
+Include: affected version, a minimal reproduction (ideally a small sample file or
+crafted input), and the impact you observed. We aim to acknowledge within a few days.
+
+## Good practices for users
+
+- Run `python3 scripts/extract.py --check` to see exactly which extractors are in
+ use; install dependencies yourself if you prefer to control what is added.
+- Only convert documents you trust and have the right to process (see the README's
+ Copyright & fair-use section).
diff --git a/.opencode/skills/book-to-skill/SKILL.md b/.opencode/skills/book-to-skill/SKILL.md
new file mode 100644
index 000000000..e7fef571b
--- /dev/null
+++ b/.opencode/skills/book-to-skill/SKILL.md
@@ -0,0 +1,634 @@
+---
+name: book-to-skill
+description: "Converts books and documents (PDF, EPUB, DOCX, HTML, Markdown, plain text, RTF, MOBI/AZW with Calibre) into structured agent skills, extracting frameworks, mental models, principles, techniques, and anti-patterns. Use when the user wants to study a document through GitHub Copilot CLI, Amp, or Claude Code, apply an author's frameworks while working, or build a reusable knowledge base from a file."
+---
+
+
+
+# Book-to-Skill Converter
+
+Transform written knowledge into actionable agent skills by extracting structure — not producing summaries.
+
+## Philosophy
+
+Books contain crystallized expertise: frameworks, principles, and techniques that took years to develop. This skill extracts that knowledge into a format GitHub Copilot CLI, Amp, Claude Code, or another compatible agent can leverage repeatedly.
+
+**Extract structure, not summaries.** A skill isn't a book report. It's a toolkit of:
+- Named frameworks (mental models with clear application)
+- Actionable principles (rules that guide decisions)
+- Techniques (step-by-step methods)
+- Anti-patterns (what to avoid and why)
+- Voice calibration (how the author thinks and communicates)
+
+**Preserve the author's precision.** Frameworks often have specific names for reasons. "The 5 Whys" isn't interchangeable with "ask why multiple times." Capture the exact formulation.
+
+**Layer depth appropriately.** Simple books → simple skills. Complex books with 10+ frameworks → skills with reference files and on-demand chapters.
+
+---
+
+## Modes of Operation
+
+Four paths available. Route based on what the user asks:
+
+### 1. Full Conversion (Default)
+**Trigger:** User provides one or more document/directory/glob paths without special instructions
+**Action:** Run all steps below (Steps 0–9)
+**Output:** Complete skill with SKILL.md, chapters/, glossary, patterns, cheatsheet
+
+### 2. Analyze Only
+**Trigger:** User says "analyze", "just extract", or "I want to review before generating"
+**Action:** Run Steps 0–3, then produce a structured extraction report (frameworks, principles, techniques found). Stop — do NOT generate skill files.
+**Output:** Analysis report for user review
+
+### 3. Generate from Prior Analysis
+**Trigger:** User has existing analysis notes or previously ran analyze-only
+**Action:** Skip Steps 0–3, use the provided analysis as input, run Steps 4–9
+**Output:** Skill files from the provided analysis
+
+### 4. Update / Fold-in (Existing Skill)
+**Trigger:** User provides one or more new source paths and indicates they want to update an existing skill (either by pointing to the existing skill folder, providing a skill slug that already exists in `SKILLS_HOME`, or explicitly requesting an update).
+**Action:** Run Step 0 (out-of-scope check), Step 1 (validate inputs), Step 1.5 (identify book type), and Step 2 (extract new files). Then skip to Step 5 (identify/detect existing skill path) and run the **Update / Fold-in Workflow** to merge the new content into the existing skill files.
+**Output:** Updated existing skill with new/revised chapter summaries and merged indexes/glossaries.
+
+---
+
+## Skill Locations
+
+This converter can run from multiple skill systems. When looking for this converter's helper script or writing the generated book skill, prefer these locations in order:
+
+1. GitHub Copilot CLI personal skills: `~/.copilot/skills/`
+2. Cross-agent personal skills (Copilot + Amp): `~/.agents/skills/`
+3. Claude Code personal skills: `~/.claude/skills/`
+4. Project-local Copilot skills: `.github/skills/`
+5. Project-local Claude skills: `.claude/skills/`
+6. Project-local Amp / Copilot skills: `.agents/skills/`
+7. Amp global skills: `~/.config/agents/skills/`
+8. Amp legacy global skills: `~/.config/amp/skills/`
+
+For **generated** book skills, pick a destination that the user's host agent can actually discover (see Step 5). When more than one valid root exists, ask the user once and remember the answer for the session — do not silently default.
+
+---
+
+## Step 0 — Out-of-scope check
+
+If no arguments are provided, stop and respond:
+> "book-to-skill requires a supported document path, folder, or glob pattern. Usage: `book-to-skill ... [skill-name-slug]`"
+
+Throughout the workflow:
+- Identify the input paths and the optional skill slug.
+- If the last argument is not a file, folder, or glob that exists or matches any files, and it looks like a skill slug (e.g. lowercase hyphens, alphanumeric), treat it as `SKILL_NAME`.
+- Treat all other arguments as the list of `INPUT_PATHS`.
+- If any input path is an existing skill directory (contains `SKILL.md` and a `chapters/` sub-folder), or if `SKILL_NAME` matches an existing skill slug in `SKILLS_HOME`, flag this run as an **Update/Fold-in** operation (Mode 4).
+
+---
+
+## Step 1 — Validate input
+
+Verify that there is at least one supported file, directory, or glob pattern among the `INPUT_PATHS`.
+For directories and globs, expand them to find matching supported files (`.pdf`, `.epub`, `.docx`, `.txt`, `.md`, `.markdown`, `.rst`, `.adoc`, `.html`, `.htm`, `.rtf`, `.mobi`, `.azw`, `.azw3`).
+
+If no supported files are found, stop with a clear error message.
+
+---
+
+## Step 1.5 — Identify content type
+
+Before extracting, ask the user:
+
+> "What kind of content do these sources have? This helps me choose the best extraction method.
+>
+> 1. **Technical** — has code blocks, tables, formulas, diagrams (e.g. programming books, academic papers, architecture guides)
+> 2. **Text-heavy** — mostly prose, few or no tables/code (e.g. management, productivity, narrative non-fiction)
+> 3. **Not sure** — I'll use the fast method and warn you if quality seems limited"
+
+Store the answer as `BOOK_TYPE`:
+- Option 1 → `BOOK_TYPE=technical`
+- Option 2 → `BOOK_TYPE=text`
+- Option 3 → `BOOK_TYPE=text`
+
+**If `BOOK_TYPE=technical`**, inform the user before proceeding:
+> "📐 Technical mode selected — using Docling for structure-aware extraction (tables, code blocks, formulas preserved as markdown). This takes ~1.5s per page, so expect a few minutes for longer sources. Starting now…"
+
+**If `BOOK_TYPE=text`**, inform:
+> "📄 Text mode selected — using the fastest suitable extractor for each file type. Plain text/Markdown/HTML are usually ready in seconds; PDFs use pdftotext when available."
+
+---
+
+## Step 2 — Extract text from the source documents
+
+Run the extraction script, passing the input paths:
+
+```bash
+SCRIPT_PATH=""
+for candidate in \
+ "$HOME/.copilot/skills/book-to-skill/scripts/extract.py" \
+ "$HOME/.agents/skills/book-to-skill/scripts/extract.py" \
+ "$HOME/.claude/skills/book-to-skill/scripts/extract.py" \
+ ".github/skills/book-to-skill/scripts/extract.py" \
+ ".claude/skills/book-to-skill/scripts/extract.py" \
+ ".agents/skills/book-to-skill/scripts/extract.py" \
+ "$HOME/.config/agents/skills/book-to-skill/scripts/extract.py" \
+ "$HOME/.config/amp/skills/book-to-skill/scripts/extract.py"
+do
+ if [ -f "$candidate" ]; then
+ SCRIPT_PATH="$candidate"
+ break
+ fi
+done
+
+if [ -z "$SCRIPT_PATH" ]; then
+ echo "Could not find scripts/extract.py for book-to-skill" >&2
+ exit 1
+fi
+
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ PYTHON_BIN="python"
+fi
+
+"$PYTHON_BIN" "$SCRIPT_PATH" $INPUT_PATHS --mode --install-missing ask
+```
+
+Before extraction, the script checks optional Python packages needed for the detected format. If a better extractor is missing, it prompts the user with the available fallback. Non-interactive sessions default to fallback unless install mode is explicitly `yes`.
+
+**Tip — preflight the environment:** run `"$PYTHON_BIN" "$SCRIPT_PATH" --check` to print a per-format report of which extractors are installed and the exact command to install whatever is missing, without processing any file. Useful when a user reports a setup or quality problem.
+
+This creates:
+- `/book_skill_work/full_text.txt` — combined extracted text of all sources with clear visually demarcated boundaries.
+- `/book_skill_work/metadata.json` — overall combined size, words, pages, token counts, and a detailed list of individual processed `sources`.
+
+Read `/book_skill_work/metadata.json` to inspect the results.
+
+---
+
+## Step 2.5 — Pre-flight cost estimate
+
+Read `/book_skill_work/metadata.json` and present the user with an estimate **before doing any generation**:
+
+```
+📖 Sources detected: source(s)
+
+📄 Combined Pages/Sections: ~ | Words: ~ | Total tokens: ~K
+
+💰 Estimated token cost (Full Conversion / Update):
+ Input (reading + prompts): ~K tokens
+ Output (skill files generated/updated): ~K tokens
+ Total: ~K tokens
+
+ Reference prices (as of 2025):
+ Claude Sonnet 4.5 → ~$ USD
+ Claude Haiku 4.5 → ~$ USD
+
+ ⏱ Estimated time: ~ minutes
+
+📁 Files to be generated/updated:
+ SKILL.md + chapter files + glossary + patterns + cheatsheet
+
+➡ Proceed with Full Conversion / Update? (or type "analyze only" to preview first)
+```
+
+**How to estimate:**
+- Input tokens ≈ `estimated_tokens` from metadata × 1.3 (prompts overhead per chapter pass)
+- Output tokens ≈ chapters × per-chapter budget + 4,000 (SKILL.md) + 4,500 (glossary + patterns + cheatsheet)
+ - Per-chapter budget midpoint by `BOOK_TYPE` (DEPTH is decided later in Step 4 and can raise it): `text` ≈ 1,000, `technical` ≈ 1,800. If the user has already indicated reference-only vs deep study, use the matching row of the Step 7 matrix.
+- Price: Sonnet input=$3/MTok output=$15/MTok — Haiku input=$0.80/MTok output=$4/MTok
+
+Wait for the user to confirm before proceeding. If they say "analyze only", switch to Mode 2.
+
+---
+
+## Step 2.6 — REPL-style access for large books (> 50k tokens)
+
+Inspired by the Recursive Language Model (RLM) paradigm: treat `full_text.txt` as a queryable corpus, not a single read. Loading the whole file into context burns budget you will need later for generation.
+
+For books over ~50k tokens, prefer programmatic probes over `Read(full_text.txt)` without bounds:
+
+```bash
+# Size check before any Read
+wc -w "$FULL_TEXT_PATH"
+
+# Find chapter offsets without loading the whole file
+grep -n -E "^\s*(Chapter|CHAPTER)\s+[0-9]+" "$FULL_TEXT_PATH" | head -40
+
+# Pull only the chapter you need (lines start..end inclusive)
+sed -n ',p' "$FULL_TEXT_PATH"
+
+# Verify a framework is actually mentioned before claiming it in SKILL.md
+grep -c -i "westrum\|dora" "$FULL_TEXT_PATH"
+
+# Targeted Read with offset/limit avoids dumping the full file
+# Read(file_path=full_text.txt, offset=, limit=)
+```
+
+Use this approach for Step 3 (structure analysis), Step 7 (per-chapter summaries), and Step 8 (glossary / patterns extraction). On books under 50k tokens, a single `Read` is fine.
+
+Why this matters: a 200-page book is ~75k tokens. Re-reading it once per chapter (28 passes) costs ~2M input tokens; using grep + sed to pull only relevant slices keeps generation cost proportional to the output, not the source.
+
+---
+
+## Step 3 — Analyze book structure
+
+Read the first 8,000 characters of the extracted `full_text.txt` to identify:
+- Book **title** and **author(s)**
+- **Chapter structure** (look for "Chapter N", "PART I", numbered headings, table of contents)
+- **Core themes** and subject domain
+- Approximate number of chapters
+
+Then read the Table of Contents section if present to map all chapters.
+
+**If mode is "Analyze Only":** produce the extraction report now and stop. Structure:
+```
+## Extraction Report —
+
+### Author's Core Frameworks
+- ****:
+
+### Key Principles
+- :
+
+### Techniques & Methods
+- :
+
+### Anti-patterns
+- :
+
+### Suggested Skill Name
+`{author-lastname}-{core-concept}` — e.g. `cialdini-influence`
+
+### Chapters Detected
+| # | Title | Main Frameworks |
+```
+
+---
+
+## Step 4 — Ask purpose (Full Conversion only)
+
+Before generating, ask the user:
+
+> "What should this skill help you do? (Pick one or more)
+> 1. Apply the author's frameworks while working
+> 2. Think with the author's mental models
+> 3. Reference specific chapters and concepts
+> 4. All of the above"
+
+Use the answer to weight what gets highlighted in the SKILL.md Core section.
+
+**Derive `DEPTH` from the answer (no extra prompt):**
+- Answer is **only** option 3 (reference) → `DEPTH=reference` — lean, fast-lookup chapters.
+- Answer includes option 1, 2, or 4 → `DEPTH=study` — deeper chapters with more worked detail, examples, and reasoning.
+
+`DEPTH` and `BOOK_TYPE` together set the per-chapter token budget in Step 7. Do **not** ask a separate "study vs reference" question — it is inferred here. (In Modes 2/3, where Step 4 is skipped, default `DEPTH=study`.)
+
+---
+
+## Step 5 — Determine skill name
+
+If `SKILL_NAME` was provided, use it as the skill slug.
+Otherwise, propose two options and let the user choose:
+- **By author-concept**: `{author-lastname}-{core-concept}` (e.g. `cialdini-influence`, `meadows-systems`)
+- **By title**: lowercase hyphens from book title (e.g. `designing-data-intensive-apps`)
+
+Default to author-concept format if the book has a strong methodological identity.
+
+Choose the destination skill root (`SKILLS_HOME`). Probe the user's filesystem for existing skill homes and pick by **the host the user is running in**:
+
+| Host agent | Personal skill root (probe in order) | Project-local root |
+|---|---|---|
+| **GitHub Copilot CLI** | `~/.copilot/skills` → `~/.agents/skills` | `.github/skills` → `.claude/skills` → `.agents/skills` |
+| **Amp** | `~/.agents/skills` → `~/.config/agents/skills` → `~/.config/amp/skills` | `.agents/skills` |
+| **Claude Code** | `~/.claude/skills` | `.claude/skills` |
+
+Selection rules:
+1. If **exactly one** of the host's candidate roots exists on disk, use it without asking.
+2. If **none** exist (fresh machine), ask the user which root to create — present the host-appropriate options and remember the choice for the session. Do not silently pick.
+3. If the user explicitly asked for project-local output, prefer the project-local row.
+4. If you cannot identify the host, ask: "Which agent are you running this in — GitHub Copilot CLI, Amp, or Claude Code?"
+
+Set `SKILLS_HOME` to the selected root and check if `$SKILLS_HOME//` already exists.
+If it does, prompt the user to choose:
+1. **Update / Fold-in** (Mode 4) — integrate new files/content into the existing skill components.
+2. **Overwrite** — delete and regenerate the skill from scratch.
+3. **Rename** — append `-2` or use a different custom slug.
+
+If the user selects **Update / Fold-in**, proceed immediately to the **Update / Fold-in Workflow** section after Step 2.5 (skipping Steps 3, 4, 6, 7, 8, 9).
+
+---
+
+## Step 6 — Create skill directory structure
+
+```bash
+mkdir -p "$SKILLS_HOME//chapters"
+```
+
+---
+
+## Step 7 — Generate chapter summaries
+
+**TOKEN BUDGET RULE — CRITICAL (adaptive):**
+
+The per-chapter budget scales with `BOOK_TYPE` and `DEPTH`. Technical chapters need room for code and tables; study depth needs room for worked reasoning. Pick the budget from this matrix:
+
+| | `DEPTH=reference` | `DEPTH=study` |
+|---|---|---|
+| `BOOK_TYPE=text` | 800–1,200 tokens | 1,000–1,800 tokens |
+| `BOOK_TYPE=technical` | 1,200–1,800 tokens | 2,000–3,000 tokens |
+
+- These are per-file targets, not hard caps — a dense chapter may run over, a thin one under. Density still beats length (Quality Rule #3): never pad to hit a number.
+- Files are loaded on-demand, so a larger chapter only costs tokens when that chapter is actually read.
+- When in doubt between two cells (e.g. mixed-content book), use the lower budget and let depth come from precision, not volume.
+
+**`DEPTH=study` is earned with content, not a bigger number.** The standard section template (Core Idea → Connects To) naturally lands a dense prose chapter around 700–900 tokens. To reach the study budget *honestly* — not by padding — a study-depth chapter must add concrete material:
+- **Reproduce one worked example or artifact** from the chapter (e.g. the example press release, a sample dialogue, a filled-in template, a decision the author walks through) under a `## Worked Example` section. This is the single biggest lever and the main thing a learner returns for.
+- **Expand the "How" of each framework** into explicit steps or criteria, not a one-liner.
+- **Add a short "Why it works / failure mode" note** to the top 1–2 frameworks.
+
+If a chapter genuinely has no worked example and resists expansion, let it land below the study floor rather than padding — and note that the chapter is thin in its Core Idea. A `reference`-depth chapter, by contrast, deliberately omits worked examples and keeps only the decision-ready essentials.
+
+For EACH chapter/major section identified in Step 3:
+
+Read the corresponding section of the extracted `full_text.txt` (use character offsets or grep for chapter headings).
+
+Create `$SKILLS_HOME//chapters/ch-.md` using the structure below.
+
+**Adapt emphasis based on `BOOK_TYPE`:**
+- `technical` → prioritize "Code Examples", "Reference Tables", and "Commands & APIs" sections; preserve exact syntax
+- `text` → prioritize "Frameworks Introduced", "Mental Models", and "Key Takeaways"; skip empty technical sections
+
+```markdown
+# Chapter N:
+
+## Core Idea
+<1–2 sentences: the single most important thing this chapter teaches>
+
+## Frameworks Introduced
+- ****:
+ - When to use:
+ - How:
+
+## Key Concepts
+- ****:
+(5–10 most important terms from this chapter)
+
+## Mental Models
+<2–4 frameworks or thinking tools. Write as "Use X when Y" or "Think of X as Y">
+
+## Anti-patterns
+- ****:
+
+## Code Examples *(technical books only — omit if BOOK_TYPE=text)*
+
+```
+
+```
+- **What it demonstrates**:
+
+## Reference Tables *(technical books only — omit if BOOK_TYPE=text)*
+
+
+## Worked Example *(DEPTH=study only — omit for DEPTH=reference)*
+
+
+## Key Takeaways
+1.
+2.
+3.
+(3–7 takeaways a practitioner must remember)
+
+## Connects To
+- **Ch N**:
+- ****:
+```
+
+---
+
+## Step 8 — Generate supporting files
+
+### glossary.md
+Create `$SKILLS_HOME//glossary.md`:
+- Every significant term from the book, alphabetically sorted
+- Format: `**Term** — definition (Ch N)`
+- Max 1,500 tokens
+
+### patterns.md
+Create `$SKILLS_HOME//patterns.md`:
+- All concrete techniques, design patterns, algorithms from the book
+- Format: `## Pattern Name\n**When to use**: ...\n**How**: ...\n**Trade-offs**: ...`
+- Max 2,000 tokens
+
+### cheatsheet.md
+Create `$SKILLS_HOME//cheatsheet.md`:
+
+**This is the most differentiated layer of the skill — treat it as a reasoning aid, not a keyword list.** Anyone can grep the glossary for a term. The cheatsheet captures the author's *judgment*: the decisions they'd make and why. It's the file that turns "I know the words" into "I'd act the way the author would".
+
+Prioritize, in order:
+1. **Decision rules** — "When X, do Y, because Z." The if/then logic the author applies, stated so the reader can apply it without re-reading the book.
+2. **Decision trees / flowcharts** (as nested bullets or a small table) — for choices with more than two branches.
+3. **Trade-off matrices** — competing options scored on the dimensions the author cares about, so the reader can pick under their own constraints.
+4. **Thresholds & defaults** — the specific numbers, ratios, or rules of thumb the author commits to (e.g. "keep functions under ~20 lines", "alert when error budget < 10%").
+5. **Tells & smells** — fast heuristics for recognizing a situation ("if you see X, you're probably in trouble Y").
+
+Avoid: bare term→definition rows (that's the glossary), and prose paragraphs (that's the chapters). Every line should help the reader *decide* something.
+
+- Format mostly as compact tables and decision rules; the content you'd want on a single printed page kept beside you while working.
+- Max 1,200 tokens.
+
+---
+
+## Step 9 — Generate the master SKILL.md
+
+**CRITICAL TOKEN BUDGET: Keep SKILL.md body under 4,000 tokens.**
+Compaction truncates from the END — put the most important content FIRST.
+
+Create `$SKILLS_HOME//SKILL.md`:
+
+```markdown
+---
+name:
+description: "Knowledge base from \"\" by . Use when applying 's frameworks for , studying the book, or referencing its concepts."
+---
+
+
+
+#
+**Author**: | **Pages**: ~ | **Chapters**: | **Generated**:
+
+## How to Use This Skill
+
+- **Without arguments** — load core frameworks for reference
+- **With a topic** — ask about `replication`, `pricing`, or another indexed topic; I find and read the relevant chapter
+- **With chapter** — ask for `ch05`; I load that specific chapter
+- **Browse** — ask "what chapters do you have?" to see the full index
+
+When you ask about a topic not covered in Core Frameworks below, I will read
+the relevant chapter file before answering.
+
+---
+
+## Core Frameworks & Mental Models
+
+
+
+
+---
+
+## Chapter Index
+
+| # | Title | Key Frameworks |
+|---|-------|----------------|
+| [ch01](chapters/ch01-.md) | | , |
+| [ch02](chapters/ch02-.md) | | , |
+...
+
+## Topic Index
+
+
+- **** → ch[, ch]
+- **** → ch
+
+## Supporting Files
+
+- [glossary.md](glossary.md) — all key terms with definitions
+- [patterns.md](patterns.md) — all techniques and design patterns
+- [cheatsheet.md](cheatsheet.md) — quick reference tables and decision guides
+
+---
+
+## Scope & Limits
+
+This skill covers the book content only. For hands-on implementation in your codebase,
+combine with project-specific tools. For topics beyond this book, check related skills
+or ask the agent directly.
+```
+
+---
+
+## Step 10 — Cleanup and report
+
+```bash
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ PYTHON_BIN="python"
+fi
+
+"$PYTHON_BIN" - <<'PY'
+import os
+import shutil
+import tempfile
+from pathlib import Path
+shutil.rmtree(
+ os.environ.get("BOOK_SKILL_WORKDIR", Path(tempfile.gettempdir()) / "book_skill_work"),
+ ignore_errors=True,
+)
+PY
+```
+
+Then report to the user:
+
+```
+✅ Skill created: $SKILLS_HOME//
+
+📚 Book: —
+📄 Pages: ~ | Chapters:
+
+Files generated:
+ SKILL.md — core frameworks + index (~X tokens)
+ chapters/ — chapter summaries (~X tokens each, ~X total)
+ glossary.md — key terms (~X tokens)
+ patterns.md — techniques & patterns (~X tokens)
+ cheatsheet.md — quick reference (~X tokens)
+ ─────────────────────────────────────────────────────
+ Total skill size: ~X tokens (loaded on-demand, not all at once)
+
+💡 Tip: check your agent's session cost/usage command to see actual token usage.
+
+Usage:
+ Ask for → load core frameworks
+ Ask about → find and explain a topic
+ Ask for ch → dive into a specific chapter
+
+Reload (if your agent doesn't auto-detect new skills):
+ GitHub Copilot CLI: /skills reload
+ Claude Code: restart the session
+ Amp: restart the session
+
+Share this skill (Copilot ecosystem, optional):
+ gh skill publish $SKILLS_HOME/
+```
+
+---
+
+## Update / Fold-in Workflow
+
+When performing an Update/Fold-in operation on an existing skill at `$SKILLS_HOME//`:
+
+### 1. Read Existing Skill Structure
+Read and parse the existing skill's files:
+- Read `$SKILLS_HOME//SKILL.md` to parse the existing **Chapter Index**, **Topic Index**, metadata (author, total chapters), and **Core Frameworks**.
+- List all files in `$SKILLS_HOME//chapters/` to find the highest chapter number (e.g. `ch12`).
+- Read `$SKILLS_HOME//glossary.md`, `$SKILLS_HOME//patterns.md`, and `$SKILLS_HOME//cheatsheet.md` to see what terms and frameworks are already indexed.
+
+### 2. Match Content & Identify Revisions vs. Additions
+Analyze the new extracted text in `/book_skill_work/full_text.txt` to identify if the new content represents:
+- **Updates/Revisions to existing chapters**: If a section of the new content directly updates or expands an existing chapter's topic, read the existing chapter file, merge the new details into it, and rewrite the file.
+- **New additions**: If the content introduces new chapters, papers, or separate sections, create **new chapter summary files** under `chapters/`. Start numbering these files after the highest existing chapter number (e.g. if the existing chapters stop at `ch12`, create `ch13-*.md`, `ch14-*.md`, etc.).
+
+### 3. Generate or Update Chapter Summary Files
+For each new or revised chapter:
+- Read the corresponding section of the extracted new text.
+- Follow the formatting guidelines in **Step 7** to build the summary.
+- Write/update the file in `$SKILLS_HOME//chapters/`.
+
+### 4. Merge Supporting Files
+- **Merge glossary.md**:
+ - Read the existing `$SKILLS_HOME//glossary.md`.
+ - Extract all new terms and definitions from the new content (Step 8 glossary guidelines).
+ - Combine and alphabetize the list of existing and new terms.
+ - If a term already exists, append the new chapter/source references to it (e.g. `**Term** — definition (Ch 4, Ch 13)`).
+ - Rewrite `$SKILLS_HOME//glossary.md` with the fully merged, alphabetized list.
+- **Merge patterns.md**:
+ - Read existing `$SKILLS_HOME//patterns.md`.
+ - Extract any new techniques, algorithms, or patterns from the new content.
+ - Append the new patterns, ensuring consistent formatting, and keeping the total length concise (under 2,500 tokens).
+- **Merge cheatsheet.md**:
+ - Read existing `$SKILLS_HOME//cheatsheet.md`.
+ - Extract new comparison rules, decision tables, or parameter guides.
+ - Integrate them cleanly into the cheatsheet structure.
+
+### 5. Re-generate the Master SKILL.md
+Update the master skill file `$SKILLS_HOME//SKILL.md`:
+- **Metadata**: Increment the chapter count, update the estimated page count, and add the new source names if appropriate. Update the `Generated` date to the current date.
+- **Core Frameworks**: Fold in the most high-impact mental models or principles from the new content (ensuring the overall file remains under 4,000 tokens).
+- **Chapter Index**: Append the new chapters to the index table, linking to the newly created files.
+- **Topic Index**: Merge the new topics alphabetically. If an existing topic is also covered in the new chapters, append the new chapter links to its line (e.g. `- **Topic** → ch05, ch13`).
+
+### 6. Cleanup and Proceed to Step 10
+Once the files are successfully written and merged, skip to **Step 10** to perform cleanup and print a custom update report summarizing the newly added chapters, merged glossary terms, and updated indices.
+
+---
+
+## Quality Rules
+
+1. **Extract structure, not summaries** — capture named frameworks, exact formulations, anti-patterns; not chapter recaps
+2. **Preserve the author's precision** — "The 5 Whys" ≠ "ask why multiple times"; keep exact naming
+3. **Density over completeness** — a 1,000-token summary beats a 10,000-token excerpt
+4. **Practitioner voice** — write "Use X when Y", not "The book explains X"
+5. **Front-load SKILL.md** — compaction keeps the first 5,000 tokens; most important content comes first
+6. **Chapter files are on-demand** — they don't count against skill budget until loaded
+7. **Never copy raw book text** — always synthesize, summarize, extract signal
+8. **Topic index is critical** — it's how the agent navigates to the right chapter file
diff --git a/.opencode/skills/book-to-skill/book_to_skill/__init__.py b/.opencode/skills/book-to-skill/book_to_skill/__init__.py
new file mode 100644
index 000000000..d770d75e8
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/__init__.py
@@ -0,0 +1,4 @@
+from book_to_skill.utils import resolve_input_files, extract_single_file, main
+from book_to_skill.exceptions import ExtractionError
+
+__all__ = ["resolve_input_files", "extract_single_file", "main", "ExtractionError"]
diff --git a/.opencode/skills/book-to-skill/book_to_skill/__main__.py b/.opencode/skills/book-to-skill/book_to_skill/__main__.py
new file mode 100644
index 000000000..42140f4c7
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/__main__.py
@@ -0,0 +1,4 @@
+from book_to_skill.cli import main
+
+if __name__ == "__main__":
+ main()
diff --git a/.opencode/skills/book-to-skill/book_to_skill/cli.py b/.opencode/skills/book-to-skill/book_to_skill/cli.py
new file mode 100644
index 000000000..439bb8d1f
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/cli.py
@@ -0,0 +1,16 @@
+import sys
+from book_to_skill.utils import main as utils_main
+
+def main():
+ # Force UTF-8 stdout/stderr to avoid UnicodeEncodeError on Windows console
+ for _stream in (sys.stdout, sys.stderr):
+ try:
+ _stream.reconfigure(encoding="utf-8")
+ except (AttributeError, ValueError):
+ # Ignore if the stream does not support reconfigure (e.g. mock streams during testing)
+ pass
+ utils_main()
+
+# Expose main for packaging console scripts entry points
+if __name__ == "__main__":
+ main()
diff --git a/.opencode/skills/book-to-skill/book_to_skill/config.py b/.opencode/skills/book-to-skill/book_to_skill/config.py
new file mode 100644
index 000000000..0d4a9fca2
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/config.py
@@ -0,0 +1,38 @@
+import os
+import tempfile
+from pathlib import Path
+
+OUTPUT_DIR = Path(
+ os.environ.get(
+ "BOOK_SKILL_WORKDIR",
+ str(Path(tempfile.gettempdir()) / "book_skill_work"),
+ )
+)
+OUTPUT_TEXT = OUTPUT_DIR / "full_text.txt"
+OUTPUT_META = OUTPUT_DIR / "metadata.json"
+
+WORDS_PER_TOKEN = 0.75 # approximate
+
+TEXT_EXTENSIONS = {".txt", ".text", ".md", ".markdown", ".rst", ".adoc", ".asciidoc"}
+HTML_EXTENSIONS = {".html", ".htm", ".xhtml"}
+CALIBRE_EBOOK_EXTENSIONS = {".mobi", ".azw", ".azw3"}
+SUPPORTED_EXTENSIONS = {
+ ".pdf", ".epub", ".docx", ".rtf",
+ *TEXT_EXTENSIONS,
+ *HTML_EXTENSIONS,
+ *CALIBRE_EBOOK_EXTENSIONS,
+}
+
+PYTHON_DEPENDENCIES = {
+ "docling": "docling",
+ "pypdf": "pypdf",
+ "pdfminer": "pdfminer.six",
+ "ebooklib": "ebooklib",
+ "bs4": "beautifulsoup4",
+ "docx": "python-docx",
+ "striprtf": "striprtf",
+}
+
+
+def supported_formats_message() -> str:
+ return ", ".join(sorted(SUPPORTED_EXTENSIONS))
diff --git a/.opencode/skills/book-to-skill/book_to_skill/dependencies.py b/.opencode/skills/book-to-skill/book_to_skill/dependencies.py
new file mode 100644
index 000000000..903d7a6c0
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/dependencies.py
@@ -0,0 +1,289 @@
+from __future__ import annotations
+
+import importlib.util
+import os
+import shutil
+import subprocess
+import sys
+from book_to_skill.config import PYTHON_DEPENDENCIES, HTML_EXTENSIONS
+
+
+# Ordered groups for the --check preflight report. Each entry describes one
+# format and what it needs. `modules` are optional Python packages (any one is
+# enough unless noted); `system` are external commands resolved via PATH.
+DEPENDENCY_GROUPS = [
+ {
+ "label": "PDF (text-heavy)",
+ "modules": ["pypdf", "pdfminer"],
+ "any_of_modules": True,
+ "any_tool_suffices": True,
+ "system": [("pdftotext", "poppler-utils", "sudo apt install poppler-utils")],
+ "note": "any one of pdftotext / pypdf / pdfminer is enough",
+ },
+ {
+ "label": "PDF (technical: tables, code, formulas)",
+ "modules": ["docling"],
+ "any_of_modules": True,
+ "system": [],
+ "note": "needed only for --mode technical; otherwise falls back to the text chain",
+ },
+ {
+ "label": "EPUB",
+ "modules": ["ebooklib", "bs4"],
+ "any_of_modules": False,
+ "system": [],
+ "note": "falls back to a stdlib zipfile parser if missing",
+ },
+ {
+ "label": "DOCX",
+ "modules": ["docx"],
+ "any_of_modules": True,
+ "system": [],
+ "note": "falls back to a stdlib ZIP/XML parser if missing",
+ },
+ {
+ "label": "HTML",
+ "modules": ["bs4"],
+ "any_of_modules": True,
+ "system": [],
+ "note": "falls back to the stdlib html.parser if missing",
+ },
+ {
+ "label": "RTF",
+ "modules": ["striprtf"],
+ "any_of_modules": True,
+ "system": [],
+ "note": "falls back to a basic regex cleanup if missing",
+ },
+ {
+ "label": "MOBI / AZW / AZW3",
+ "modules": [],
+ "any_of_modules": True,
+ "required": True,
+ "system": [
+ ("ebook-convert", "Calibre", "install Calibre: https://calibre-ebook.com/download"),
+ ],
+ "note": "no fallback — Calibre is required for these formats",
+ },
+]
+
+
+def python_module_available(module_name: str) -> bool:
+ return importlib.util.find_spec(module_name) is not None
+
+
+def missing_python_packages(module_names: list[str]) -> list[str]:
+ missing = []
+ for module_name in module_names:
+ if not python_module_available(module_name):
+ missing.append(PYTHON_DEPENDENCIES[module_name])
+ return missing
+
+
+def install_python_packages(packages: list[str]) -> bool:
+ if not packages:
+ return True
+
+ print(f"Installing missing Python package(s): {', '.join(packages)}")
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", *packages],
+ text=True,
+ timeout=600,
+ )
+ except Exception as exc:
+ print(f"Package installation failed: {exc}", file=sys.stderr)
+ return False
+
+ importlib.invalidate_caches()
+ return result.returncode == 0
+
+
+def normalize_install_mode(argv: list[str]) -> str:
+ mode = os.environ.get("BOOK_SKILL_INSTALL_MISSING", "ask").lower()
+ if "--no-install-missing" in argv:
+ return "no"
+ if "--install-missing" in argv:
+ idx = argv.index("--install-missing")
+ if idx + 1 < len(argv) and not argv[idx + 1].startswith("--"):
+ mode = argv[idx + 1].lower()
+ else:
+ mode = "yes"
+ if mode in {"1", "true", "y", "yes", "install"}:
+ return "yes"
+ if mode in {"0", "false", "n", "no", "fallback", "skip"}:
+ return "no"
+ return "ask"
+
+
+def offer_dependency_install(
+ *,
+ feature: str,
+ module_names: list[str],
+ fallback: str | None,
+ install_mode: str,
+) -> None:
+ packages = missing_python_packages(module_names)
+ if not packages:
+ return
+
+ message = f"{feature} uses {', '.join(packages)} if installed"
+ if fallback:
+ message += f", otherwise {fallback}"
+ message += "."
+ print(message)
+
+ should_install = False
+ if install_mode == "yes":
+ should_install = True
+ elif install_mode == "ask" and sys.stdin.isatty():
+ answer = input("Missing package(s) detected. Do you want to install? y=install, n=fallback: ").strip().lower()
+ should_install = answer in {"y", "yes", "install"}
+ else:
+ if fallback:
+ print("Non-interactive mode or install disabled; using fallback.")
+ else:
+ print("Non-interactive mode or install disabled; installation skipped.")
+
+ if not should_install:
+ if fallback:
+ print(f"Using fallback: {fallback}.")
+ return
+
+ if install_python_packages(packages):
+ still_missing = missing_python_packages(module_names)
+ if not still_missing:
+ print("Package installation complete.")
+ return
+ print(f"Package installation incomplete; still missing: {', '.join(still_missing)}", file=sys.stderr)
+ else:
+ print("Package installation failed.", file=sys.stderr)
+
+ if fallback:
+ print(f"Using fallback: {fallback}.")
+
+
+def prepare_dependencies(ext: str, extraction_mode: str, install_mode: str) -> None:
+ if ext == ".pdf" and extraction_mode == "technical":
+ offer_dependency_install(
+ feature="Technical PDF extraction",
+ module_names=["docling"],
+ fallback="the PDF text fallback chain",
+ install_mode=install_mode,
+ )
+
+ if ext == ".pdf" and not shutil.which("pdftotext"):
+ offer_dependency_install(
+ feature="PDF text extraction",
+ module_names=["pypdf", "pdfminer"],
+ fallback="any installed Python PDF parser; extraction fails if none are available",
+ install_mode=install_mode,
+ )
+
+ if ext == ".epub":
+ offer_dependency_install(
+ feature="EPUB extraction",
+ module_names=["ebooklib", "bs4"],
+ fallback="a stdlib ZIP/HTML parser",
+ install_mode=install_mode,
+ )
+
+ if ext in HTML_EXTENSIONS:
+ offer_dependency_install(
+ feature="HTML extraction",
+ module_names=["bs4"],
+ fallback="a stdlib HTML parser",
+ install_mode=install_mode,
+ )
+
+ if ext == ".docx":
+ offer_dependency_install(
+ feature="DOCX extraction",
+ module_names=["docx"],
+ fallback="a stdlib ZIP/XML parser",
+ install_mode=install_mode,
+ )
+
+ if ext == ".rtf":
+ offer_dependency_install(
+ feature="RTF extraction",
+ module_names=["striprtf"],
+ fallback="a basic regex cleanup fallback",
+ install_mode=install_mode,
+ )
+
+
+def run_dependency_check() -> int:
+ """Scan every optional dependency across all formats and print a status
+ report plus the exact command to install whatever is missing.
+
+ Returns a process exit code: 0 always (a missing optional dep is not an
+ error — most formats degrade to a fallback). Intended for `extract.py --check`.
+ """
+ print("book-to-skill — dependency check\n")
+
+ missing_pip_packages: list[str] = []
+ missing_system: list[tuple[str, str]] = [] # (name, install hint)
+
+ for group in DEPENDENCY_GROUPS:
+ print(f" {group['label']}")
+
+ present_modules = [m for m in group["modules"] if python_module_available(m)]
+ absent_modules = [m for m in group["modules"] if not python_module_available(m)]
+ system_present = [c for c, _, _ in group["system"] if shutil.which(c)]
+ system_absent = [c for c, _, _ in group["system"] if not shutil.which(c)]
+
+ for module_name in group["modules"]:
+ pip_name = PYTHON_DEPENDENCIES.get(module_name, module_name)
+ ok = module_name in present_modules
+ print(f" {'✓' if ok else '✗'} python: {pip_name}")
+ if not ok:
+ missing_pip_packages.append(pip_name)
+
+ for cmd, pretty, hint in group["system"]:
+ ok = cmd in system_present
+ print(f" {'✓' if ok else '✗'} system: {cmd} ({pretty})")
+ if not ok:
+ missing_system.append((pretty, hint))
+
+ # Satisfaction semantics:
+ # - any_tool_suffices: any single extractor (module OR system) is enough
+ # - any_of_modules: at least one module present
+ # - otherwise: every listed module present
+ # - system tools that aren't alternatives are always required
+ if group.get("any_tool_suffices"):
+ satisfied = bool(present_modules) or bool(system_present)
+ else:
+ if group["modules"]:
+ satisfied = bool(present_modules) if group["any_of_modules"] else not absent_modules
+ else:
+ satisfied = True
+ if system_absent:
+ satisfied = False
+
+ if satisfied:
+ status = "ready"
+ elif group.get("required"):
+ status = "MISSING — required, no fallback"
+ else:
+ status = "fallback available (install for best quality)"
+ print(f" → {status} — {group['note']}\n")
+
+ # Deduplicate while preserving order
+ missing_pip_packages = list(dict.fromkeys(missing_pip_packages))
+ missing_system = list(dict.fromkeys(missing_system))
+
+ if not missing_pip_packages and not missing_system:
+ print("All optional dependencies are installed. You're ready for every format.")
+ return 0
+
+ print("To enable the best extractor for every format, install the missing pieces:\n")
+ if missing_pip_packages:
+ print(f" {sys.executable} -m pip install {' '.join(missing_pip_packages)}")
+ for pretty, hint in missing_system:
+ print(f" # {pretty}: {hint}")
+ print(
+ "\nNote: missing Python packages are optional — most formats fall back to a "
+ "stdlib parser. Calibre is the only hard requirement, and only for MOBI/AZW files."
+ )
+ return 0
diff --git a/.opencode/skills/book-to-skill/book_to_skill/exceptions.py b/.opencode/skills/book-to-skill/book_to_skill/exceptions.py
new file mode 100644
index 000000000..14a3f5559
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/exceptions.py
@@ -0,0 +1,2 @@
+class ExtractionError(Exception):
+ """Raised when a single file cannot be extracted (non-fatal in batch mode)."""
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/__init__.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/__init__.py
new file mode 100644
index 000000000..a07ec637c
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/__init__.py
@@ -0,0 +1 @@
+# Parsers package
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/calibre.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/calibre.py
new file mode 100644
index 000000000..e02b3d8c0
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/calibre.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+from book_to_skill.config import OUTPUT_DIR
+
+
+def extract_with_ebook_convert(input_path: str) -> str | None:
+ if not shutil.which("ebook-convert"):
+ return None
+ output_path = OUTPUT_DIR / "ebook-convert-output.txt"
+ try:
+ input_path = os.path.abspath(input_path)
+ result = subprocess.run(
+ ["ebook-convert", input_path, str(output_path)],
+ capture_output=True, text=True, timeout=300
+ )
+ if result.returncode == 0 and output_path.exists():
+ text = output_path.read_text(encoding="utf-8", errors="replace")
+ if text.strip():
+ return text
+ except Exception as e:
+ print(f" [warn] extract_with_ebook_convert failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/docx.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/docx.py
new file mode 100644
index 000000000..879d8d145
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/docx.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import zipfile
+import sys
+from book_to_skill.exceptions import ExtractionError
+
+
+def extract_docx_with_python_docx(docx_path: str) -> str | None:
+ try:
+ import docx
+ document = docx.Document(docx_path)
+ parts = [paragraph.text for paragraph in document.paragraphs if paragraph.text]
+ for table in document.tables:
+ for row in table.rows:
+ cells = [cell.text.strip() for cell in row.cells]
+ if any(cells):
+ parts.append("\t".join(cells))
+ return "\n".join(parts)
+ except ImportError:
+ return None
+ except Exception as e:
+ print(f" [warn] extract_docx_with_python_docx failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def extract_docx_with_zipfile(docx_path: str) -> str | None:
+ try:
+ import xml.etree.ElementTree as ET
+
+ with zipfile.ZipFile(docx_path) as zf:
+ xml_bytes = zf.read("word/document.xml")
+ root = ET.fromstring(xml_bytes)
+ ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+ parts: list[str] = []
+
+ def emit_block(elem) -> None:
+ # Walk block content in document order. Paragraphs join their runs;
+ # tables emit one tab-joined line per row (same row format as the
+ # python-docx path, but order-preserving — python-docx appends all
+ # tables last). Unknown wrappers (e.g. content controls) are
+ # recursed into so their paragraphs/tables are not lost; and
+ # are NOT recursed into, so table-cell paragraphs are not
+ # double-counted. Cell text concatenates the cell's runs; nested
+ # tables fold into the parent cell and are also emitted standalone
+ # (rare; best-effort).
+ for child in elem:
+ tag = child.tag
+ if tag == f"{ns}p":
+ texts = [t.text for t in child.iter(f"{ns}t") if t.text]
+ if texts:
+ parts.append("".join(texts))
+ elif tag == f"{ns}tbl":
+ for row in child.iter(f"{ns}tr"):
+ cells = []
+ for cell in row.iter(f"{ns}tc"):
+ cell_texts = [t.text for t in cell.iter(f"{ns}t") if t.text]
+ cells.append("".join(cell_texts).strip())
+ if any(cells):
+ parts.append("\t".join(cells))
+ else:
+ emit_block(child)
+
+ body = root.find(f"{ns}body")
+ emit_block(body if body is not None else root)
+ return "\n".join(parts) if parts else None
+ except Exception as e:
+ print(f" [warn] extract_docx_with_zipfile failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def validate_docx_xml_safety(docx_path: str) -> None:
+ """Scan all XML files in the DOCX zip archive to prevent XML Entity Expansion (Billion Laughs) and XXE injections."""
+ try:
+ with zipfile.ZipFile(docx_path) as zf:
+ for name in zf.namelist():
+ if name.endswith(".xml") or name.endswith(".rels"):
+ xml_bytes = zf.read(name)
+ for encoding in ("utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32"):
+ try:
+ content = xml_bytes.decode(encoding, errors="ignore").upper()
+ except LookupError:
+ continue
+ if " tuple[str, str]:
+ validate_docx_xml_safety(docx_path)
+ print("Trying python-docx...", end=" ", flush=True)
+ text = extract_docx_with_python_docx(docx_path)
+ if text and text.strip():
+ print("OK")
+ return text, "python-docx"
+
+ print("not available")
+ print("Trying stdlib DOCX parser...", end=" ", flush=True)
+ text = extract_docx_with_zipfile(docx_path)
+ if text and text.strip():
+ print("OK")
+ return text, "zipfile-docx"
+
+ print("FAILED")
+ raise ExtractionError(
+ "Could not extract text from DOCX.\n"
+ "Install python-docx for best results:\n"
+ " pip3 install python-docx"
+ )
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/epub.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/epub.py
new file mode 100644
index 000000000..eedfd8a65
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/epub.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import posixpath
+import re
+import zipfile
+import sys
+from book_to_skill.parsers.html import _HTMLTextExtractor
+
+
+def extract_with_ebooklib(epub_path: str) -> str | None:
+ try:
+ import ebooklib
+ from ebooklib import epub
+ from bs4 import BeautifulSoup
+
+ book = epub.read_epub(epub_path)
+ parts = []
+ for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
+ soup = BeautifulSoup(item.get_content(), "html.parser")
+ parts.append(soup.get_text(separator="\n"))
+ return "\n\n".join(parts)
+ except ImportError:
+ return None
+ except Exception as e:
+ print(f" [warn] extract_with_ebooklib failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def _find_opf_path(zf: zipfile.ZipFile) -> str | None:
+ """Locate the OPF package document inside an EPUB archive.
+
+ First tries ``META-INF/container.xml`` (the spec-defined entry point),
+ then falls back to scanning the archive for any ``.opf`` file.
+ """
+ # Spec-defined: read container.xml for the rootfile path
+ try:
+ container = zf.read("META-INF/container.xml").decode("utf-8", errors="replace")
+ match = re.search(r'full-path=["\']([^"\']+\.opf)["\']', container)
+ if match:
+ return match.group(1)
+ except (KeyError, Exception):
+ pass
+
+ # Fallback: glob for any .opf file
+ opf_files = [n for n in zf.namelist() if n.endswith(".opf")]
+ return opf_files[0] if opf_files else None
+
+
+def extract_with_zipfile(epub_path: str) -> str | None:
+ """stdlib-only EPUB extractor: unzip → parse HTML files."""
+ try:
+ with zipfile.ZipFile(epub_path) as zf:
+ names = zf.namelist()
+
+ # Locate OPF and determine its directory for resolving relative hrefs
+ opf_path = _find_opf_path(zf)
+ opf_dir = posixpath.dirname(opf_path) if opf_path else ""
+
+ # Build reading order from the OPF spine (not the manifest's href
+ # order), then append any remaining content docs as a safety net.
+ spine_order: list[str] = []
+ seen: set[str] = set()
+ if opf_path:
+ opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
+
+ # Manifest: item id -> resolved href. Parse each opening
+ # tag so attribute order (id before/after href) does not matter;
+ # both self-closing and forms work
+ # because all attributes live in the opening tag.
+ manifest: dict[str, str] = {}
+ for item_tag in re.findall(r"]*?/?>", opf_text):
+ id_m = re.search(r'\bid=["\']([^"\']+)["\']', item_tag)
+ href_m = re.search(r'\bhref=["\']([^"\']+)["\']', item_tag)
+ if id_m and href_m:
+ href = href_m.group(1)
+ resolved = posixpath.normpath(posixpath.join(opf_dir, href)) if opf_dir else href
+ manifest[id_m.group(1)] = resolved
+
+ # Spine: ordered idrefs -> hrefs (true reading order).
+ for idref in re.findall(r']*?\bidref=["\']([^"\']+)["\']', opf_text):
+ href = manifest.get(idref)
+ if href and href not in seen:
+ spine_order.append(href)
+ seen.add(href)
+
+ # Safety net: append remaining manifest content documents (e.g. a
+ # nav doc not in the spine) in manifest order, so nothing is lost.
+ for href in manifest.values():
+ if href.endswith((".html", ".xhtml")) and href not in seen:
+ spine_order.append(href)
+ seen.add(href)
+
+ html_files = spine_order or sorted(
+ n for n in names if n.endswith((".html", ".xhtml"))
+ )
+ if not html_files:
+ return None
+
+ parts = []
+ for name in html_files:
+ try:
+ raw = zf.read(name).decode("utf-8", errors="replace")
+ parser = _HTMLTextExtractor()
+ parser.feed(raw)
+ parts.append(parser.get_text())
+ except Exception:
+ continue
+ return "\n\n".join(parts) if parts else None
+ except Exception as e:
+ print(f" [warn] extract_with_zipfile failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def count_epub_chapters(epub_path: str) -> int:
+ """Count spine items (approximate chapter count) without dependencies."""
+ try:
+ with zipfile.ZipFile(epub_path) as zf:
+ opf_path = _find_opf_path(zf)
+ if not opf_path:
+ return 0
+ opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
+ return len(re.findall(r' str:
+ # HTMLParser(convert_charrefs=True) already decoded entities in
+ # handle_data; do NOT unescape again or double-encoded entities
+ # (e.g. "&") collapse incorrectly.
+ return "".join(self._parts)
+
+
+def extract_html_content(raw_html: str) -> str:
+ try:
+ from bs4 import BeautifulSoup
+ soup = BeautifulSoup(raw_html, "html.parser")
+ for element in soup(["script", "style", "head"]):
+ element.decompose()
+ return soup.get_text(separator="\n")
+ except ImportError:
+ parser = _HTMLTextExtractor()
+ parser.feed(raw_html)
+ return parser.get_text()
+
+
+def extract_html_file(path: str) -> str | None:
+ raw = read_text_file(path)
+ if raw is None:
+ return None
+ return extract_html_content(raw)
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/pdf.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/pdf.py
new file mode 100644
index 000000000..b9432fe1e
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/pdf.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+
+
+def extract_with_pdftotext(pdf_path: str) -> str | None:
+ if not shutil.which("pdftotext"):
+ return None
+ try:
+ pdf_path = os.path.abspath(pdf_path)
+ result = subprocess.run(
+ ["pdftotext", "-layout", pdf_path, "-"],
+ capture_output=True, text=True, timeout=120
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout
+ except Exception as e:
+ print(f" [warn] extract_with_pdftotext failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def extract_with_pypdf(pdf_path: str) -> str | None:
+ try:
+ import pypdf
+ text_parts = []
+ with open(pdf_path, "rb") as f:
+ reader = pypdf.PdfReader(f)
+ for page in reader.pages:
+ try:
+ text_parts.append(page.extract_text() or "")
+ except Exception:
+ text_parts.append("")
+ return "\n".join(text_parts)
+ except ImportError:
+ return None
+ except Exception as e:
+ print(f" [warn] extract_with_pypdf failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def extract_with_pdfminer(pdf_path: str) -> str | None:
+ try:
+ from pdfminer.high_level import extract_text
+ return extract_text(pdf_path)
+ except ImportError:
+ return None
+ except Exception as e:
+ print(f" [warn] extract_with_pdfminer failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def extract_with_docling(pdf_path: str) -> str | None:
+ """Layout-aware extraction using Docling. Best for technical books with tables and code."""
+ try:
+ from docling.document_converter import DocumentConverter
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
+ from docling.datamodel.base_models import InputFormat
+ from docling.document_converter import PdfFormatOption
+
+ pipeline_options = PdfPipelineOptions()
+ pipeline_options.do_ocr = False
+ pipeline_options.do_table_structure = True
+
+ converter = DocumentConverter(
+ format_options={
+ InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
+ }
+ )
+ result = converter.convert(pdf_path)
+ return result.document.export_to_markdown()
+ except ImportError:
+ return None
+ except Exception as e:
+ print(f" [warn] extract_with_docling failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+
+def count_pages(pdf_path: str) -> int:
+ # Try pdfinfo first
+ if shutil.which("pdfinfo"):
+ try:
+ pdf_path = os.path.abspath(pdf_path)
+ result = subprocess.run(
+ ["pdfinfo", pdf_path], capture_output=True, text=True, timeout=15
+ )
+ for line in result.stdout.splitlines():
+ if line.startswith("Pages:"):
+ return int(line.split(":")[1].strip())
+ except Exception:
+ pass
+ # Fallback: count pages with pypdf
+ try:
+ import pypdf
+ with open(pdf_path, "rb") as f:
+ return len(pypdf.PdfReader(f).pages)
+ except Exception:
+ return 0
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/rtf.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/rtf.py
new file mode 100644
index 000000000..e7f66604d
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/rtf.py
@@ -0,0 +1,47 @@
+import html
+import re
+import sys
+from book_to_skill.parsers.text import read_text_file
+from book_to_skill.exceptions import ExtractionError
+
+
+# RTF unicode escape: \uN (signed decimal) followed by its fallback char(s).
+# Decode the code point and drop the standard single fallback — a \'XX hex byte
+# or a literal "?". Assumes the default \uc1 (one fallback char); \ucN directives
+# and multi-char/group fallbacks are not parsed (best-effort fallback only).
+_RTF_UNICODE = re.compile(r"\\u(-?\d+)[ ]?(?:\\'[0-9a-fA-F]{2}|\?)?")
+
+
+def _rtf_unicode_repl(match: re.Match) -> str:
+ cp = int(match.group(1)) % 0x10000 # RTF uses signed 16-bit; wrap negatives
+ if cp == 0 or 0xD800 <= cp <= 0xDFFF: # NUL and lone surrogates: unwanted in text
+ return ""
+ return chr(cp)
+
+
+def strip_rtf_fallback(raw: str) -> str:
+ raw = _RTF_UNICODE.sub(_rtf_unicode_repl, raw) # decode \uN escapes first
+ raw = re.sub(r"\\'[0-9a-fA-F]{2}", " ", raw)
+ raw = re.sub(r"\\par[d]?", "\n", raw)
+ raw = re.sub(r"\\tab", "\t", raw)
+ raw = re.sub(r"\\[a-zA-Z]+-?\d* ?", "", raw)
+ raw = raw.replace("{", "").replace("}", "")
+ return html.unescape(raw)
+
+
+def extract_rtf(rtf_path: str) -> tuple[str, str]:
+ raw = read_text_file(rtf_path)
+ if raw is None:
+ raise ExtractionError(f"Could not read RTF file: {rtf_path}")
+
+ try:
+ from striprtf.striprtf import rtf_to_text
+ text = rtf_to_text(raw)
+ if text.strip():
+ return text, "striprtf"
+ except ImportError:
+ pass
+ except Exception as e:
+ print(f" [warn] extract_rtf/striprtf failed: {type(e).__name__}: {e}", file=sys.stderr)
+
+ return strip_rtf_fallback(raw), "rtf-regex"
diff --git a/.opencode/skills/book-to-skill/book_to_skill/parsers/text.py b/.opencode/skills/book-to-skill/book_to_skill/parsers/text.py
new file mode 100644
index 000000000..4055c5a35
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/parsers/text.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+import sys
+from pathlib import Path
+
+# Byte-order marks, longest first: the UTF-32 LE BOM ("ff fe 00 00") starts with
+# the UTF-16 LE BOM ("ff fe"), so UTF-32 must be checked before UTF-16.
+_BOMS = (
+ (b"\xef\xbb\xbf", "utf-8-sig"),
+ (b"\xff\xfe\x00\x00", "utf-32"),
+ (b"\x00\x00\xfe\xff", "utf-32"),
+ (b"\xff\xfe", "utf-16"),
+ (b"\xfe\xff", "utf-16"),
+)
+
+
+def read_text_file(path: str) -> str | None:
+ try:
+ data = Path(path).read_bytes()
+ except Exception as e:
+ print(f" [warn] read_text_file failed: {type(e).__name__}: {e}", file=sys.stderr)
+ return None
+
+ # Decode by BOM when present (the utf-16/utf-32 codecs strip the BOM and
+ # auto-select byte order).
+ for bom, encoding in _BOMS:
+ if data.startswith(bom):
+ try:
+ return data.decode(encoding)
+ except (UnicodeDecodeError, LookupError):
+ break
+
+ # No (usable) BOM: fall back to the prior chain for BOM-less files.
+ for encoding in ("utf-8", "cp1252", "latin-1"):
+ try:
+ return data.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return None
diff --git a/.opencode/skills/book-to-skill/book_to_skill/utils.py b/.opencode/skills/book-to-skill/book_to_skill/utils.py
new file mode 100644
index 000000000..4a4fedc83
--- /dev/null
+++ b/.opencode/skills/book-to-skill/book_to_skill/utils.py
@@ -0,0 +1,678 @@
+from __future__ import annotations
+
+import glob
+import json
+import os
+import re
+import sys
+import shutil
+import zipfile
+from pathlib import Path
+
+from book_to_skill.exceptions import ExtractionError
+
+from book_to_skill.config import (
+ OUTPUT_DIR,
+ OUTPUT_TEXT,
+ OUTPUT_META,
+ WORDS_PER_TOKEN,
+ SUPPORTED_EXTENSIONS,
+ TEXT_EXTENSIONS,
+ HTML_EXTENSIONS,
+ CALIBRE_EBOOK_EXTENSIONS,
+ supported_formats_message,
+)
+from book_to_skill.dependencies import (
+ normalize_install_mode,
+ prepare_dependencies,
+ run_dependency_check,
+)
+from book_to_skill.parsers.text import read_text_file
+from book_to_skill.parsers.html import extract_html_file
+from book_to_skill.parsers.docx import extract_docx
+from book_to_skill.parsers.rtf import extract_rtf
+from book_to_skill.parsers.calibre import extract_with_ebook_convert
+from book_to_skill.parsers.pdf import (
+ extract_with_docling,
+ extract_with_pdftotext,
+ extract_with_pypdf,
+ extract_with_pdfminer,
+ count_pages,
+)
+from book_to_skill.parsers.epub import (
+ extract_with_ebooklib,
+ extract_with_zipfile,
+ count_epub_chapters,
+)
+
+
+def estimate_tokens(text: str) -> int:
+ return int(len(text.split()) / WORDS_PER_TOKEN)
+
+
+# Explicit chapter heading: "Chapter 5", "Capítulo 5: ...", "Chapter 1. Intro".
+# Also French/German/Italian/Dutch chapter words (chapitre/kapitel/capitolo/
+# hoofdstuk), matching the ToC languages added alongside. "ch.?" stays last so
+# the longer words match in full. Captures the number (bounded to 1..99 — drops
+# years like "2025.") and whatever follows it on the line, so we can reject prose.
+_EXPLICIT_CHAPTER = re.compile(
+ r"^\s*(?:chapter|chapitre|kapitel|cap[ií]tulo|capitolo|hoofdstuk|ch\.?)\s*(\d{1,2})\b(?P.*)$",
+ re.IGNORECASE,
+)
+# A heading's number is followed by end-of-line, punctuation (“. : - —“), or a
+# Capitalized title word. A lowercase continuation (“Chapter 6 explores...”,
+# “Chapter 8 are relevant...”) is prose / a cross-reference, not a heading.
+# The uppercase class is À-Þ so titles starting with Ü/Û (common in German, e.g. “Überblick”) are recognized.
+_HEADING_TAIL = re.compile(r"^\s*$|^\s*[.:\-—–]|^\s+[A-ZÀ-Þ0-9\"“(]")
+
+# Roman-numeral chapter heading: "I: Loomings", "II. The Carpet-Bag".
+# Requires a separator (":" or ".") and a Capitalized title after it, so a bare
+# "I" or "V." (a page divider / list marker) is not mistaken for a chapter.
+_ROMAN_HEAD = re.compile(r"^\s*([IVXLCDM]+)\s*[:.]\s+[A-ZÀ-Þ\"“(]")
+_ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
+
+# Chinese chapter headings. Two common styles:
+# 1. explicit "第N章" / "第 3 回" / "第十二节" / "第一讲" — 第 + numeral + a
+# chapter classifier (章回卷节篇讲);
+# 2. a Markdown heading led by a CJK ordinal and a separator, e.g.
+# "## 一 · 缘起" or "## 第一讲" — common in CJK ebooks and lecture notes.
+# Scoped to CJK numerals, so Latin/Roman detection above is completely unaffected
+# (e.g. "## 5 Setup" is still not treated as a heading here). detect_structure()
+# dedupes by number, so a "##" heading and a repeated "###" sub-ordinal collapse
+# to a single chapter.
+_CN_NUM_VALUES = {
+ "〇": 0, "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
+ "六": 6, "七": 7, "八": 8, "九": 9,
+}
+_CN_NUM_UNITS = {"十": 10, "百": 100, "千": 1000}
+_CN_NUM_CLASS = "〇零一二两三四五六七八九十百千"
+# Full-width Arabic digits (U+FF10–U+FF19) are common in Japanese typesetting,
+# e.g. "第1章". int() already parses them (str.isdigit() is True), so only the
+# regex character classes need to accept them.
+_FW_DIGITS = "0-9"
+_CN_CHAPTER = re.compile(rf"^\s*第\s*([0-9{_FW_DIGITS}{_CN_NUM_CLASS}]+)\s*[章回卷节篇讲]")
+_MD_CN_HEADING = re.compile(rf"^#{{1,6}}\s+第?\s*([{_FW_DIGITS}{_CN_NUM_CLASS}]+)\s*[·、.::章回卷节篇讲]")
+
+# Table-of-contents header lines across common languages. Anchored to a whole
+# line (^\s*X\s*$) so an inline "the contents of this chapter" never matches.
+_TOC_HEADERS = (
+ "table of contents", "contents", "índice", "sumário", # EN / ES / PT
+ "目录", "目錄", "目次", # Chinese / Japanese
+ "table des matières", # French
+ "inhaltsverzeichnis", # German
+ "indice", "sommario", # Italian (no accent — distinct from índice above)
+ "inhoudsopgave", # Dutch
+)
+_TOC_PATTERN = re.compile(
+ r"^\s*(?:" + "|".join(re.escape(h) for h in _TOC_HEADERS) + r")\s*$",
+ re.IGNORECASE | re.MULTILINE,
+)
+
+# ATX-style heading: "# Title", "## Section", AsciiDoc "= Title", "== Section".
+# The required space after the marker distinguishes an AsciiDoc "== X" from a
+# reStructuredText underline "=====" (no space) — the latter is intentionally
+# ignored (RST underline headings are out of scope).
+_ATX_HEADING = re.compile(r"^(#{1,6}|={1,6})\s+(.+?)\s*#*$")
+# Setext/RST underline: a full line of "=" (level 1) or "-" (level 2), length
+# >= 2. Marks the line directly above it as a heading title.
+_SETEXT_UNDERLINE = re.compile(r"^(={2,}|-{2,})$")
+
+
+def _structural_chapter_count(text: str) -> int:
+ """Count chapter-like structural headings in Markdown/AsciiDoc/RST sources.
+
+ Recognizes ATX headings ("# Title", "== Section") and setext/RST underline
+ headings (a title line directly above a row of "=" or "-"). Groups distinct
+ (case-normalized) titles by depth and returns the count at the shallowest
+ depth with >= 2 distinct titles — this selects the real chapter level in the
+ common "# Book Title / ## Chapter" layout where the top level appears once.
+
+ Guards against false positives: headings inside fenced code blocks are
+ skipped; an ATX title starting with a bare digit ("## 5 Setup") or made only
+ of punctuation ("=====" table borders) is rejected; a setext underline counts
+ only when it sits directly under a non-blank title line at least as long as
+ the underline (so thematic breaks, table borders, and front-matter "---" do
+ not match).
+ """
+ levels: dict[int, set[str]] = {}
+ in_fence = False
+ prev = "" # previous non-fence line (stripped); a setext title candidate
+ for line in text.splitlines():
+ s = line.strip()
+ if s.startswith("```") or s.startswith("~~~"):
+ in_fence = not in_fence
+ prev = ""
+ continue
+ if in_fence:
+ prev = ""
+ continue
+ # Setext/RST underline: "=" (level 1) or "-" (level 2) directly under a
+ # title line at least as long as the underline.
+ if (
+ _SETEXT_UNDERLINE.match(s)
+ and prev
+ and not _SETEXT_UNDERLINE.match(prev)
+ and len(s) >= len(prev)
+ ):
+ depth = 1 if s[0] == "=" else 2
+ levels.setdefault(depth, set()).add(prev.lower())
+ prev = ""
+ continue
+ # ATX heading ("# Title", "== Section").
+ m = _ATX_HEADING.match(s)
+ if m:
+ title = m.group(2).strip().lower()
+ # Reject empty, bare-digit-led ("## 5 Setup"), and all-punctuation
+ # ("=====" table-border) titles — none are real chapter headings.
+ if title and not title[0].isdigit() and re.search(r"\w", title):
+ levels.setdefault(len(m.group(1)), set()).add(title)
+ # An ATX heading line is not a setext title for the next line.
+ prev = ""
+ continue
+ prev = s
+ if not levels:
+ return 0
+ for depth in sorted(levels):
+ if len(levels[depth]) >= 2:
+ return len(levels[depth])
+ # No level has >= 2 distinct headings: a thin doc (e.g. one heading per
+ # level). Count them all — this path runs only as a fallback when numeric
+ # chapter detection already found zero, so it cannot inflate real books.
+ return sum(len(titles) for titles in levels.values())
+
+
+def _cn_numeral_to_int(s: str) -> int | None:
+ """Parse a Chinese (or ASCII-digit) chapter numeral into an int (1..999)."""
+ if s.isdigit():
+ n = int(s)
+ return n if 1 <= n <= 999 else None
+ section = current = 0
+ for ch in s:
+ if ch in _CN_NUM_VALUES:
+ current = _CN_NUM_VALUES[ch]
+ elif ch in _CN_NUM_UNITS:
+ section += (current or 1) * _CN_NUM_UNITS[ch]
+ current = 0
+ else:
+ return None
+ total = section + current
+ return total if 1 <= total <= 999 else None
+
+
+def _int_to_roman(n: int) -> str:
+ table = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"),
+ (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"),
+ (5, "V"), (4, "IV"), (1, "I")]
+ out = []
+ for val, sym in table:
+ while n >= val:
+ out.append(sym)
+ n -= val
+ return "".join(out)
+
+
+def _roman_to_int(s: str) -> int | None:
+ """Convert a Roman numeral to int, returning None if it isn't canonical."""
+ s = s.upper()
+ total = prev = 0
+ for ch in reversed(s):
+ v = _ROMAN_VALUES.get(ch)
+ if v is None:
+ return None
+ total += -v if v < prev else v
+ prev = max(prev, v)
+ if total == 0 or total > 200:
+ return None
+ # Reject non-canonical forms ("IIII", "VV") by round-tripping.
+ return total if _int_to_roman(total) == s else None
+
+
+def _chapter_number(line: str) -> int | None:
+ """Return the chapter number if the line is a genuine chapter heading.
+
+ Handles Arabic ("Chapter 5", "Capítulo 5: ..."), Roman-numeral
+ ("I: Loomings", "II. The Carpet-Bag") and Chinese ("第三章 …", "## 一 · …",
+ "## 第一讲") heading styles.
+ """
+ s = line.strip()
+ if len(s) > 80:
+ return None
+ m = _EXPLICIT_CHAPTER.match(s)
+ if m and _HEADING_TAIL.match(m.group("rest")):
+ return int(m.group(1))
+ rm = _ROMAN_HEAD.match(s)
+ if rm:
+ return _roman_to_int(rm.group(1))
+ cm = _CN_CHAPTER.match(s) or _MD_CN_HEADING.match(s)
+ if cm:
+ return _cn_numeral_to_int(cm.group(1))
+ return None
+
+
+def detect_structure(text: str) -> dict:
+ """Detect chapter count and table of contents presence.
+
+ Scans the whole text (not just the head) and counts DISTINCT chapter numbers
+ from explicit "Chapter N"/"Capítulo N" headings, rejecting prose
+ cross-references and numbered list items. Counting distinct numbers means a
+ ToC entry and its body heading are not double-counted.
+ """
+ lines = text.splitlines()
+
+ headings = []
+ numbers = set()
+ for line in lines:
+ num = _chapter_number(line)
+ if num is not None:
+ numbers.add(num)
+ headings.append(line.strip())
+ numeric_count = len(numbers)
+ # Fall back to structural (Markdown/AsciiDoc) headings only when no numeric
+ # "Chapter N" headings were found, so books with real chapters are unaffected.
+ chapters_detected = (
+ numeric_count if numeric_count > 0 else _structural_chapter_count(text)
+ )
+
+ # Look for ToC indicators in the first ~30k chars (multilingual; see _TOC_PATTERN)
+ has_toc = bool(_TOC_PATTERN.search(text[:30000]))
+
+ return {
+ "chapters_detected": chapters_detected,
+ "chapter_headings_sample": headings[:10],
+ "has_toc": has_toc,
+ }
+
+
+def parse_arguments(argv: list[str]) -> tuple[list[str], str, str]:
+ """Parse argv into (input_paths, extraction_mode, install_mode)."""
+ input_paths = []
+ extraction_mode = "text"
+
+ args = argv[1:]
+ i = 0
+ while i < len(args):
+ arg = args[i]
+ if arg == "--mode":
+ if i + 1 < len(args):
+ extraction_mode = args[i+1].lower()
+ i += 2
+ else:
+ i += 1
+ elif arg == "--install-missing":
+ if i + 1 < len(args) and not args[i+1].startswith("--"):
+ i += 2
+ else:
+ i += 1
+ elif arg == "--no-install-missing":
+ i += 1
+ elif arg.startswith("-"):
+ i += 1
+ else:
+ input_paths.append(arg)
+ i += 1
+
+ install_mode = normalize_install_mode(argv)
+ if extraction_mode not in ("technical", "text"):
+ extraction_mode = "text"
+
+ return input_paths, extraction_mode, install_mode
+
+
+def resolve_input_files(paths: list[str]) -> list[Path]:
+ """Resolve paths including files, directories, and glob patterns to Path objects.
+
+ User-given order is preserved for explicit file arguments. Expanded
+ results (directories, globs) are sorted deterministically so repeated
+ runs produce the same output.
+ """
+ resolved = []
+ for path_str in paths:
+ # Check if it has glob wildcards
+ if any(char in path_str for char in ("*", "?", "[")):
+ glob_matches = glob.glob(path_str, recursive=True)
+ # Sort expanded glob results deterministically
+ expanded = []
+ for match in glob_matches:
+ p = Path(match)
+ if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
+ expanded.append(p.resolve())
+ expanded.sort(key=lambda x: str(x).lower())
+ resolved.extend(expanded)
+ else:
+ p = Path(path_str)
+ if p.is_dir():
+ # Sort expanded directory results deterministically
+ dir_files = []
+ for root, _, files in os.walk(p):
+ for file in files:
+ file_path = Path(root) / file
+ if file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
+ dir_files.append(file_path.resolve())
+ dir_files.sort(key=lambda x: str(x).lower())
+ resolved.extend(dir_files)
+ else:
+ # Keep even if it doesn't exist so the error check can report it
+ resolved.append(p.resolve())
+
+ # Deduplicate while preserving insertion order (user order for explicit files)
+ seen = set()
+ unique_paths = []
+ for path in resolved:
+ resolved_path = path.resolve() if path.exists() else path
+ if resolved_path not in seen:
+ seen.add(resolved_path)
+ unique_paths.append(resolved_path)
+
+ return unique_paths
+
+
+def extract_single_file(input_path: Path, extraction_mode: str, install_mode: str) -> dict:
+ """Extract text and metadata from a single file path."""
+ input_str = str(input_path)
+
+ if not input_path.exists():
+ raise ExtractionError(f"File not found: {input_str}")
+
+ ext = input_path.suffix.lower()
+ document_format = ext.lstrip(".")
+
+ # Sniff magic bytes if suffix is not supported
+ if ext not in SUPPORTED_EXTENSIONS:
+ with open(input_str, "rb") as f:
+ header = f.read(8)
+ if header[:4] == b"%PDF":
+ ext = ".pdf"
+ document_format = "pdf"
+ elif header[:2] == b"PK":
+ try:
+ with zipfile.ZipFile(input_str) as zf:
+ names = set(zf.namelist())
+ if "mimetype" in names and zf.read("mimetype").startswith(b"application/epub"):
+ ext = ".epub"
+ document_format = "epub"
+ elif "word/document.xml" in names:
+ ext = ".docx"
+ document_format = "docx"
+ else:
+ raise ExtractionError(
+ f"Unsupported ZIP-based format '{input_path.name}'. Supported: {supported_formats_message()}"
+ )
+ except (zipfile.BadZipFile, KeyError, OSError):
+ raise ExtractionError(
+ f"Unsupported ZIP-based format '{input_path.name}'. Supported: {supported_formats_message()}"
+ )
+ else:
+ raise ExtractionError(
+ f"Unsupported format '{ext or ''}'. Supported: {supported_formats_message()}"
+ )
+
+ prepare_dependencies(ext, extraction_mode, install_mode)
+
+ if ext in CALIBRE_EBOOK_EXTENSIONS and not shutil.which("ebook-convert"):
+ raise ExtractionError(
+ "MOBI/AZW/AZW3 extraction requires Calibre's ebook-convert command. "
+ "Install Calibre and ensure ebook-convert is on PATH, then rerun this command."
+ )
+
+ text = ""
+ method = ""
+ pages = 0
+ pages_label = "sections"
+
+ if ext == ".epub":
+ print(f"Extracting EPUB: {input_str}")
+ text = extract_with_ebooklib(input_str)
+ if text and text.strip():
+ method = "ebooklib"
+ else:
+ print("ebooklib not available")
+ print("Trying stdlib zipfile parser...", end=" ", flush=True)
+ text = extract_with_zipfile(input_str)
+ if text and text.strip():
+ print("OK")
+ method = "zipfile"
+ else:
+ print("FAILED")
+ raise ExtractionError(
+ "Could not extract text from EPUB.\n"
+ "Install ebooklib + beautifulsoup4 for best results:\n"
+ " pip3 install ebooklib beautifulsoup4"
+ )
+ pages = count_epub_chapters(input_str)
+ pages_label = "spine_items"
+ elif ext == ".pdf":
+ print(f"Extracting PDF: {input_str}")
+ if extraction_mode == "technical":
+ print("Mode: technical — using Docling (layout-aware)...", end=" ", flush=True)
+ text = extract_with_docling(input_str)
+ if text:
+ method = "docling"
+ print("OK")
+ else:
+ print("not available, falling back to pdftotext")
+ extraction_mode = "text"
+
+ if extraction_mode == "text" or not text:
+ print("Mode: text — using pdftotext...")
+ print("Trying pdftotext...", end=" ", flush=True)
+ text = extract_with_pdftotext(input_str)
+
+ if text:
+ method = "pdftotext"
+ print("OK")
+ else:
+ print("not available")
+ print("Trying pypdf...", end=" ", flush=True)
+ text = extract_with_pypdf(input_str)
+ if text:
+ method = "pypdf"
+ print("OK")
+ else:
+ print("not available")
+ print("Trying pdfminer.six...", end=" ", flush=True)
+ text = extract_with_pdfminer(input_str)
+ if text:
+ method = "pdfminer"
+ print("OK")
+ else:
+ print("FAILED")
+ raise ExtractionError(
+ "Could not extract text from PDF.\n"
+ "Install one of: poppler-utils (pdftotext), pypdf, or pdfminer.six\n"
+ " sudo apt install poppler-utils\n"
+ " pip3 install pypdf\n"
+ " pip3 install pdfminer.six"
+ )
+
+
+ pages = count_pages(input_str)
+ pages_label = "pages"
+ elif ext in TEXT_EXTENSIONS:
+ print(f"Extracting text document: {input_str}")
+ text = read_text_file(input_str)
+ if text is None or not text.strip():
+ raise ExtractionError(f"Could not read text document: {input_path.name}")
+ method = "plain-text"
+ pages = 0
+ pages_label = "sections"
+ elif ext in HTML_EXTENSIONS:
+ print(f"Extracting HTML: {input_str}")
+ text = extract_html_file(input_str)
+ if text is None or not text.strip():
+ raise ExtractionError(f"Could not extract text from HTML: {input_path.name}")
+ method = "html-parser"
+ pages = 0
+ pages_label = "sections"
+ elif ext == ".docx":
+ print(f"Extracting DOCX: {input_str}")
+ text, method = extract_docx(input_str)
+ pages = 0
+ pages_label = "sections"
+ elif ext == ".rtf":
+ print(f"Extracting RTF: {input_str}")
+ text, method = extract_rtf(input_str)
+ pages = 0
+ pages_label = "sections"
+ elif ext in CALIBRE_EBOOK_EXTENSIONS:
+ print(f"Extracting ebook with Calibre: {input_str}")
+ text = extract_with_ebook_convert(input_str)
+ if text is None or not text.strip():
+ raise ExtractionError(
+ f"Could not extract text from {ext}. Install Calibre and ensure ebook-convert is on PATH."
+ )
+ method = "ebook-convert"
+ pages = 0
+ pages_label = "sections"
+
+ tokens = estimate_tokens(text)
+ structure = detect_structure(text)
+ file_size_mb = os.path.getsize(input_str) / (1024 * 1024)
+
+ return {
+ "source_file": str(input_path.resolve()),
+ "filename": input_path.name,
+ "format": document_format,
+ "extraction_method": method,
+ "file_size_mb": round(file_size_mb, 2),
+ pages_label: pages,
+ "pages_label": pages_label,
+ "pages": pages,
+ "chars": len(text),
+ "words": len(text.split()),
+ "estimated_tokens": tokens,
+ "text": text,
+ **structure,
+ }
+
+
+def print_banner() -> None:
+ """Print the attribution banner. Done here (not only in SKILL.md) so it
+ shows on every run regardless of how the agent invokes extraction."""
+ banner = Path(__file__).resolve().parent.parent / "scripts" / "banner.txt"
+ try:
+ sys.stderr.write(banner.read_text(encoding="utf-8") + "\n")
+ except Exception:
+ pass # best-effort: never block extraction on the banner
+
+
+def main():
+ print_banner()
+
+ if "--check" in sys.argv[1:]:
+ sys.exit(run_dependency_check())
+
+ if len(sys.argv) < 2:
+ print("Usage: extract.py ... [--mode technical|text] [--install-missing ask|yes|no]", file=sys.stderr)
+ print(" extract.py --check # report which extractors are installed", file=sys.stderr)
+ print(f"Supported formats: {supported_formats_message()}", file=sys.stderr)
+ sys.exit(1)
+
+ raw_input_paths, extraction_mode, install_mode = parse_arguments(sys.argv)
+
+ if not raw_input_paths:
+ print("ERROR: No input document, folder, or glob pattern specified.", file=sys.stderr)
+ sys.exit(1)
+
+ input_files = resolve_input_files(raw_input_paths)
+
+ if not input_files:
+ print(f"ERROR: No supported files found matching: {', '.join(raw_input_paths)}", file=sys.stderr)
+ sys.exit(1)
+
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ extracted_sources = []
+ combined_texts = []
+ errors = []
+
+ for file_path in input_files:
+ try:
+ res = extract_single_file(file_path, extraction_mode, install_mode)
+ except ExtractionError as exc:
+ print(f"WARNING: Skipping {file_path.name}: {exc}", file=sys.stderr)
+ errors.append((file_path, str(exc)))
+ continue
+ extracted_sources.append(res)
+
+ # Format the text with a clear boundary
+ separator = f"\n\n{'=' * 80}\nSOURCE: {res['filename']} (Path: {res['source_file']})\n{'=' * 80}\n\n"
+ combined_texts.append(separator + res["text"])
+
+ if not extracted_sources:
+ print(f"\nERROR: All {len(errors)} source(s) failed extraction:", file=sys.stderr)
+ for path, err in errors:
+ print(f" - {path.name}: {err}", file=sys.stderr)
+ sys.exit(1)
+
+ # Combine texts
+ consolidated_text = "".join(combined_texts).strip()
+
+ # Write combined text
+ OUTPUT_TEXT.write_text(consolidated_text, encoding="utf-8")
+
+ # Consolidate metadata
+ total_file_size_mb = sum(src["file_size_mb"] for src in extracted_sources)
+ total_pages = sum(src["pages"] for src in extracted_sources)
+ total_chars = len(consolidated_text)
+ total_words = len(consolidated_text.split())
+ total_tokens = estimate_tokens(consolidated_text)
+
+ # Detect structure on consolidated text
+ consolidated_structure = detect_structure(consolidated_text)
+
+ metadata = {
+ "source_file": "Consolidated from multiple sources" if len(extracted_sources) > 1 else extracted_sources[0]["source_file"],
+ "filename": "multi-source" if len(extracted_sources) > 1 else extracted_sources[0]["filename"],
+ "format": "mixed" if len(extracted_sources) > 1 else extracted_sources[0]["format"],
+ "extraction_method": "multi-method" if len(extracted_sources) > 1 else extracted_sources[0]["extraction_method"],
+ "extraction_mode": extraction_mode,
+ "file_size_mb": round(total_file_size_mb, 2),
+ "pages": total_pages,
+ "chars": total_chars,
+ "words": total_words,
+ "estimated_tokens": total_tokens,
+ "estimated_tokens_human": f"~{total_tokens // 1000}K",
+ "output_text": str(OUTPUT_TEXT),
+ "total_sources": len(extracted_sources),
+ "sources": [
+ {
+ "source_file": src["source_file"],
+ "filename": src["filename"],
+ "format": src["format"],
+ "extraction_method": src["extraction_method"],
+ "file_size_mb": src["file_size_mb"],
+ "pages": src["pages"],
+ "pages_label": src["pages_label"],
+ "chars": src["chars"],
+ "words": src["words"],
+ "estimated_tokens": src["estimated_tokens"],
+ "chapters_detected": src["chapters_detected"],
+ "has_toc": src["has_toc"]
+ }
+ for src in extracted_sources
+ ],
+ **consolidated_structure,
+ }
+
+ OUTPUT_META.write_text(json.dumps(metadata, indent=2, ensure_ascii=False))
+
+ page_line = f" Total Pages: {total_pages}"
+ print("\nExtraction complete:")
+ print(f" Sources : {len(extracted_sources)} processed")
+ print(f" Size : {total_file_size_mb:.2f} MB")
+ print(page_line)
+ print(f" Words : {total_words:,}")
+ print(f" Tokens : ~{total_tokens // 1000}K")
+ print(f" Chapters: {consolidated_structure['chapters_detected']} detected overall")
+ print(f" ToC : {'yes' if consolidated_structure['has_toc'] else 'not detected'}")
+ if not consolidated_structure["has_toc"]:
+ print(
+ " WARN : No table of contents detected — chapter mapping in Step 3 "
+ "will rely on heading scan only, which may miss or duplicate sections."
+ )
+ print(f"\n Text -> {OUTPUT_TEXT}")
+ print(f" Meta -> {OUTPUT_META}")
+ if errors:
+ print(f"\n WARNING: {len(errors)} source(s) skipped due to errors:")
+ for path, err in errors:
+ print(f" - {path.name}: {err}")
diff --git a/.opencode/skills/book-to-skill/docs/ARCHITECTURE.md b/.opencode/skills/book-to-skill/docs/ARCHITECTURE.md
new file mode 100644
index 000000000..4294997c2
--- /dev/null
+++ b/.opencode/skills/book-to-skill/docs/ARCHITECTURE.md
@@ -0,0 +1,76 @@
+# Architecture
+
+book-to-skill has two halves: a **deterministic extractor** (Python) and a
+**spec-driven generator** (the agent following `SKILL.md`). The extractor turns any
+document into clean text + metadata; the agent turns that into a structured skill.
+
+```
+ ┌─────────────────────────── EXTRACTOR (Python, deterministic) ──┐
+ documents │ scripts/extract.py → extractor/ │
+ (pdf/epub/ │ ├─ utils.py CLI parse · multi-source resolve · runner │
+ docx/...) │ ├─ config.py supported extensions · paths · deps map │
+ │ │ ├─ dependencies.py optional-dep probing · --check report │
+ ▼ │ └─ parsers/ pdf · epub · docx · html · rtf · calibre · │
+ ───────────│ text (best tool first, stdlib fallback) │
+ │ output → /book_skill_work/ │
+ │ full_text.txt (all sources merged, source-marked) │
+ │ metadata.json (pages, words, tokens, chapters, ToC) │
+ └────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+ ┌─────────────────────────── GENERATOR (agent, follows SKILL.md) ┐
+ │ Step 1.5 ask content type → BOOK_TYPE (technical | text) │
+ │ Step 2/2.5 extract · cost estimate · confirm │
+ │ Step 2.6 REPL-style probing for large books (grep/sed, no │
+ │ full re-reads) │
+ │ Step 3 analyze structure (title, author, chapters, ToC) │
+ │ Step 4 purpose → DEPTH (reference | study) │
+ │ Step 7 per-chapter summaries (budget = BOOK_TYPE × DEPTH) │
+ │ Step 8 glossary · patterns · cheatsheet (decision layer) │
+ │ Step 9/9.5 SKILL.md core + indexes │
+ └────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+ // ← chosen per host:
+ ~/.copilot/skills/ GitHub Copilot CLI
+ ~/.agents/skills/ Copilot CLI or Amp (cross-agent)
+ ~/.claude/skills/ Claude Code
+ .github|.claude|.agents/skills/ project-local
+ SKILL.md core frameworks + chapter & topic index (~4K)
+ chapters/*.md on-demand, loaded only when asked
+ glossary.md terms
+ patterns.md techniques
+ cheatsheet.md decision rules / trees / trade-offs / tells
+```
+
+## Design principles
+
+1. **Extract structure, not summaries** — named frameworks, decision rules,
+ anti-patterns; never raw passages.
+2. **Compile-time over runtime** — pay navigation/structuring once; at query time
+ load only the relevant chapter. See [PERFORMANCE.md](PERFORMANCE.md).
+3. **On-demand chapters** — `SKILL.md` stays small; chapter files cost tokens only
+ when read.
+4. **Front-loaded `SKILL.md`** — most important content first (compaction truncates
+ from the end).
+5. **Graceful degradation** — every format has a stdlib fallback; one bad source is
+ skipped, not fatal.
+
+## Key components
+
+| Path | Responsibility |
+|------|----------------|
+| `scripts/extract.py` | thin entrypoint wrapper |
+| `scripts/extractor/utils.py` | CLI parsing, multi-source resolution, chapter/ToC detection, runner |
+| `scripts/extractor/parsers/` | one module per format |
+| `scripts/extractor/dependencies.py` | optional-dependency probing + `--check` |
+| `tools/discovery_tax.py` | measures token cost vs context-dump / discovery loop |
+| `tools/validate_skill.py` | checks a generated SKILL.md against host rules (`--lens claude|copilot|amp`) |
+| `SKILL.md` | the generator spec (Steps 0–10 + fold-in workflow) |
+
+## Extending
+
+- **New format** → add `parsers/.py`, register its extension in `config.py`,
+ wire dependency probing in `dependencies.py`, branch in `utils.extract_single_file`.
+- **New generation behavior** → edit the relevant Step in `SKILL.md`; keep it lean
+ and back the change with evidence (see CONTRIBUTING.md).
diff --git a/.opencode/skills/book-to-skill/docs/CNAME b/.opencode/skills/book-to-skill/docs/CNAME
new file mode 100644
index 000000000..a7a9c8739
--- /dev/null
+++ b/.opencode/skills/book-to-skill/docs/CNAME
@@ -0,0 +1 @@
+booktoskill.is-a.dev
diff --git a/.opencode/skills/book-to-skill/docs/PERFORMANCE.md b/.opencode/skills/book-to-skill/docs/PERFORMANCE.md
new file mode 100644
index 000000000..16b190f2f
--- /dev/null
+++ b/.opencode/skills/book-to-skill/docs/PERFORMANCE.md
@@ -0,0 +1,79 @@
+# Performance & Cost
+
+All numbers below are **measured**, not estimated, using `tiktoken` (cl100k_base)
+for token counts and `tools/discovery_tax.py` for the discovery model. Reproduce
+any of them with the commands shown.
+
+## Extraction (real conversions)
+
+Measured with `pdftotext` (PDF) and `ebooklib` (EPUB):
+
+| Book | Format | Pages | Tokens | Chapters auto-detected |
+|------|--------|------:|-------:|-----------------------:|
+| Think Python 2 | PDF | 244 | 119K | 19 |
+| Working Backwards | PDF | 371 | 175K | 10 |
+| Pro Git | PDF | 501 | 229K | — † |
+| Moby-Dick | EPUB | — | 301K | 133 |
+
+† Pro Git heads chapters with section titles (no `Chapter N`), so it does not
+auto-segment. Moby-Dick's bodies use bare titles, but its Roman-numeral table of
+contents is detected (133) — see *Known limitations* in the README.
+
+**Extraction method matters for technical books.** On a 103-page technical PDF:
+
+| Method | Time | Tables | Code blocks |
+|--------|-----:|-------:|------------:|
+| pdftotext | 0.1s | 0 | 0 |
+| Docling (technical mode) | 164s | 48 | 36 |
+
+pdftotext is instant but flattens structure; Docling is ~1.5s/page but preserves
+tables and code as markdown. Pick text mode for prose, technical mode for code/tables.
+
+## The Discovery Loop Tax
+
+Tokens entering context to answer **one** targeted question. book-to-skill loads a
+resident core (~4K) plus one compiled chapter (~1K) ≈ **5,000 tokens**.
+
+| Book (chapter size) | Context-dump | Discovery loop | book-to-skill | vs dump / loop |
+|---------------------|-------------:|---------------:|--------------:|:--------------:|
+| Think Python 2 (small) | 119,264 | 12,152 | ~5,000 | 24× / 2.4× |
+| Working Backwards (medium) | 175,253 | 33,444 | ~5,000 | 35× / 6.7× |
+| AI Engineering (large) | 256,287 | 77,866 | ~5,000 | 51× / 15.6× |
+
+```bash
+python3 tools/discovery_tax.py --full-text /tmp/book_skill_work/full_text.txt --target-chapter 5
+```
+
+- The **context-dump** advantage (24–51×) is the strongest claim: that cost recurs on
+ *every conversation turn*.
+- The **discovery-loop** advantage (2.4–15.6×) is a one-time cost and a model using
+ the book's real ToC/chapter sizes; it scales with chapter size.
+
+## Generation cost
+
+One-pass full conversion, estimated from measured tokens (Claude Sonnet 4.5,
+\$3 / \$15 per MTok input/output):
+
+| Book | Input | Output | ~Cost |
+|------|------:|-------:|------:|
+| Think Python 2 | 155K | 28K | \$0.88 |
+| Working Backwards | 228K | 19K | \$0.96 |
+| Pro Git | 298K | 23K | \$1.23 |
+| Moby-Dick | 391K | 17K | \$1.42 |
+
+Roughly **\$1 per book** for a full skill — paid once. Re-reading the same PDF into
+context every session costs far more over time (see the Discovery Loop Tax above).
+
+## Generated-skill output quality
+
+A before/after of the adaptive-depth change (`v1.0.0`, #20) on one chapter:
+
+| Artifact | Old spec | New spec |
+|----------|---------:|---------:|
+| Chapter file (tokens) | 473 | 1,219 |
+| Worked example present | no | yes |
+| Cheatsheet decision rules | 0 | 32 |
+| Cheatsheet keyword/definition lines | 9 | 0 |
+
+The new spec turns the cheatsheet from a glossary into a decision layer and gives
+study-depth chapters a reproduced worked example.
diff --git a/.opencode/skills/book-to-skill/docs/assets/logo.png b/.opencode/skills/book-to-skill/docs/assets/logo.png
new file mode 100644
index 000000000..734cd9b96
Binary files /dev/null and b/.opencode/skills/book-to-skill/docs/assets/logo.png differ
diff --git a/.opencode/skills/book-to-skill/docs/index.md b/.opencode/skills/book-to-skill/docs/index.md
new file mode 100644
index 000000000..9ddc60cf7
--- /dev/null
+++ b/.opencode/skills/book-to-skill/docs/index.md
@@ -0,0 +1,98 @@
+---
+hide:
+ - navigation
+ - toc
+---
+
+# book-to-skill
+
+
+Turn any book or document into a structured, on-demand agent skill — named frameworks, decision rules, and anti-patterns. Structure, not a summary.
+
+
+- :material-file-document-multiple:{ .lg .middle } __Multi-format__
+
+ ---
+
+ PDF, EPUB, DOCX, HTML, Markdown, RTF, MOBI/AZW (via Calibre). Extraction runs
+ locally with graceful stdlib fallbacks — no upload, no lock-in.
+
+- :material-brain:{ .lg .middle } __Structure, not summaries__
+
+ ---
+
+ Named frameworks, mental models, decision rules, and anti-patterns — the
+ author's toolkit, captured with their exact terms, not a book report.
+
+- :material-flash:{ .lg .middle } __On-demand chapters__
+
+ ---
+
+ Per-chapter files load only when the topic is relevant, so a 200-page book
+ costs tokens proportional to the question, not the page count.
+
+- :material-robot-happy:{ .lg .middle } __Multi-agent__
+
+ ---
+
+ One `SKILL.md` runs on Claude Code, GitHub Copilot CLI, and Amp through the
+ open Agent Skills standard.
+
+
+
+## Install
+
+**As an agent skill** (gives you the `/book-to-skill` command in Claude Code, Copilot CLI, Amp):
+
+```bash
+git clone https://github.com/virgiliojr94/book-to-skill.git ~/.claude/skills/book-to-skill
+# then, in your agent session:
+/book-to-skill /path/to/book.pdf [skill-name]
+```
+
+**As a standalone CLI** (just the text extractor, optional):
+
+```bash
+pip install "book-to-skill[pdf,epub,docx]"
+book-to-skill /path/to/book.pdf --mode text
+```
+
+## Learn more
+
+
+
+- :material-sitemap:{ .lg .middle } __[Architecture](ARCHITECTURE.md)__
+
+ ---
+
+ How the deterministic extractor and the spec-driven generator fit together.
+
+- :material-speedometer:{ .lg .middle } __[Performance](PERFORMANCE.md)__
+
+ ---
+
+ The measured Discovery Loop Tax and real per-conversion token cost.
+
+- :material-book-open-page-variant:{ .lg .middle } __[Skill Reference](skill-reference.md)__
+
+ ---
+
+ The full `SKILL.md` spec: every step, depth budget, and quality rule.
+
+- :material-heart:{ .lg .middle } __[Sponsor](https://github.com/sponsors/virgiliojr94)__
+
+ ---
+
+ book-to-skill is free and MIT. Sponsoring funds reviews, releases, and fixes.
+
+