# v2 Phase A — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship five non-breaking quality improvements (#1, #2, #4, #5, #14) that make every existing openzim-mcp tool meaningfully smarter for smaller LLMs (Haiku-class, Llama-3-8B, Mistral-7B). Tagged as `v2.0.0a1`.

**Architecture:** A new `openzim_mcp/meta.py` module owns the `_meta` envelope helpers and prose-footer formatting. Existing tools gain `_meta` keys on dict returns and a one-line markdown footer in compact mode. Content extraction in `content_processor.py` learns to emit infobox key/value lists and replace oversized tables with placeholders in compact mode. Search snippets become query-aware via Python (libzim's Python binding doesn't expose `getSnippet()`). Existing typo fallback in `find_entry_by_title` becomes configurable, and its candidates feed structured suggestion lists.

**Tech Stack:** Python 3.12+, pydantic-settings, libzim 3.9+, BeautifulSoup4, html2text, **new: tiktoken** ≥0.7. Tests: pytest, no new test framework deps.

**Reference spec:** [`docs/superpowers/specs/2026-05-08-v2-phase-a-quality-wins-design.md`](../specs/2026-05-08-v2-phase-a-quality-wins-design.md). When this plan and the spec disagree, this plan is the source of truth — discrepancies should be raised in PR review and resolved by updating both.

---

## File structure

### New files

| Path | Responsibility |
|---|---|
| `openzim_mcp/meta.py` | `tokens_est()`, `build_meta()`, `format_footer()` — central envelope and footer helpers, used by every Phase A tool. |
| `tests/test_meta.py` | Unit tests for `meta` module: token estimation, envelope construction, footer rendering. |
| `tests/test_content_processor_infobox.py` | Unit tests for `extract_infobox` against Wikipedia-shaped, Wikiquote-shaped, and infobox-free fixture HTML. |
| `tests/test_content_processor_tables.py` | Unit tests for `replace_oversized_tables` — threshold behavior, document order, smaller-table preservation. |
| `tests/test_create_snippet_query_aware.py` | Unit tests for query-aware `create_snippet`: matched-paragraph selection, highlighting cap, fallback. |

### Modified files

| Path | Changes |
|---|---|
| `pyproject.toml` | Add `tiktoken>=0.7.0` to `[project].dependencies`. |
| `openzim_mcp/defaults.py` | Add `MetaDefaults`, `SearchDefaults` dataclasses; extend `ContentDefaults` with three table/infobox fields. |
| `openzim_mcp/config.py` | Add `MetaConfig`, `SearchConfig` `BaseModel`s; extend `ContentConfig`; wire both into `OpenZimMcpConfig`. |
| `openzim_mcp/content_processor.py` | New `extract_infobox()` and `replace_oversized_tables()` methods. Extend `create_snippet` signature with `query` keyword. Wire infobox/table extraction into `html_to_plain_text` and `html_to_plain_text_from_parsed` (compact-mode-gated). |
| `openzim_mcp/zim/content.py` | `_get_entry_snippet` accepts `query` keyword; `get_zim_entry_data` and friends attach `_meta` to returns. |
| `openzim_mcp/zim/search.py` | Pass `query` to `_get_entry_snippet` at call sites. Replace hardcoded fuzzy score and length-gate with config-driven values. Change fuzzy-trigger condition. Attach `_meta` (with `suggestions[]` and `reason`) to all dict returns. |
| `openzim_mcp/simple_tools.py` | Append prose footer to compact-mode responses. Replace empty-result prose hints with footer + structured suggestions. |
| `openzim_mcp/zim/namespace.py` | Pass `_meta` through `browse_namespace_data` and `walk_namespace_data` returns. |
| `tests/test_find_entry_by_title.py`, `tests/test_find_entry_by_title_quality.py` | Update score assertions (`0.7` → `0.85`); add config-override tests; add `_meta.suggestions[]` assertions. |
| `tests/test_search_tools.py`, `tests/test_search_all.py`, `tests/test_navigation_tools.py`, `tests/test_metadata_tools.py` | Add `_meta` shape assertions on dict returns. |
| `tests/test_simple_tools.py` | Add footer-format assertions; assert empty-result prose replaced. |
| `tests/test_content_processor.py` | Assert infobox extraction + table replacement integrate cleanly with `html_to_plain_text` in compact mode. |
| `README.md` | Document `_meta` schema, footer format, infobox/table behavior, fuzzy fallback config. |
| `CHANGELOG.md` | New `## [2.0.0a1]` section. |

### What does NOT change

- No tool signature changes.
- No tool removals.
- `compact=False` paths produce byte-identical output to v1.2.0 on golden-file fixtures (asserted in Task 26).
- `cache.py` and rate-limiter remain untouched.
- The HTTP/SSE transport surface and security middleware are untouched.

---

## Conventions

- **TDD throughout:** every implementation task starts with a failing test, then minimal code to pass it. The test code shown in each task is the assertion the engineer writes; expand for completeness as judgment dictates, but do not skip the failing-first step.
- **Commit per task:** after each task's tests pass, commit. Use the message shown in the task. Do not amend previous commits.
- **Run existing tests before each commit** with `uv run pytest -x` to confirm no unrelated regression. Fix in place if any new failure traces back to the current task; if a pre-existing failure surfaces, document it in the PR description and continue — do not silently skip.
- **Branch:** all work lands on `v2-phase-a` (off `main`). Tasks may merge to that branch via individual PRs (5 PRs grouped by item) or as one rolling branch — your call.

---

## Task 1: Add tiktoken dependency

**Files:**
- Modify: `pyproject.toml`
- Modify: `uv.lock` (regenerated)

- [ ] **Step 1: Read the current `dependencies` list**

Run:
```bash
grep -A 8 'dependencies = \[' pyproject.toml
```
Expected: 6 packages (`beautifulsoup4`, `html2text`, `libzim`, `mcp[cli]`, `pydantic`, `pydantic-settings`).

- [ ] **Step 2: Add tiktoken to the dependencies list**

Edit `pyproject.toml` to add the line `"tiktoken>=0.7.0",` immediately after `"pydantic-settings>=2.14.0",`:

```toml
dependencies = [
    "beautifulsoup4>=4.14.3",
    "html2text>=2025.4.15",
    "libzim>=3.9.0",
    "mcp[cli]>=1.27.0",
    "pydantic>=2.13.3",
    "pydantic-settings>=2.14.0",
    "tiktoken>=0.7.0",
]
```

- [ ] **Step 3: Sync the lock file**

Run:
```bash
uv sync
```
Expected: tiktoken plus a few transitives (`regex`, `requests`) added to the environment.

- [ ] **Step 4: Smoke-test the import**

Run:
```bash
uv run python -c "import tiktoken; enc = tiktoken.get_encoding('cl100k_base'); print(enc.encode('hello world'))"
```
Expected: a list of integers (e.g. `[15339, 1917]`).

- [ ] **Step 5: Commit**

```bash
git add pyproject.toml uv.lock
git commit -m "feat(v2): add tiktoken dependency for response token estimation

Used by the new openzim_mcp.meta module (Phase A item #5) to attach
tokens_est to every tool response so callers can budget context use."
```

---

## Task 2: Add Phase A defaults to `defaults.py`

**Files:**
- Modify: `openzim_mcp/defaults.py`
- Test: `tests/test_defaults.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_defaults.py`:

```python
from openzim_mcp.defaults import CONTENT, META, SEARCH


def test_meta_defaults():
    assert META.FOOTER_ENABLED is True
    assert META.TOKENIZER_ENCODING == "cl100k_base"


def test_search_defaults():
    assert SEARCH.STRUCTURED_SUGGESTIONS_LIMIT == 5
    assert SEARCH.FUZZY_TITLE_MIN_QUERY_LEN == 4
    assert SEARCH.FUZZY_TITLE_SCORE_PENALTY == 0.85


def test_content_defaults_table_extras():
    assert CONTENT.TABLE_ROW_THRESHOLD == 8
    assert CONTENT.TABLE_CHAR_THRESHOLD == 600
    assert CONTENT.INFOBOX_KV_LIMIT == 30
```

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_defaults.py -v
```
Expected: `ImportError: cannot import name 'META' from 'openzim_mcp.defaults'` (or similar AttributeError on missing fields).

- [ ] **Step 3: Add the new dataclasses and fields**

In `openzim_mcp/defaults.py`, extend `ContentDefaults` and add two new dataclasses:

```python
@dataclass(frozen=True)
class ContentDefaults:
    """Default values for content processing."""

    SNIPPET_LENGTH: int = 3000
    MAX_CONTENT_LENGTH: int = 100000
    SEARCH_LIMIT: int = 10
    MAX_BINARY_SIZE: int = 10_000_000
    MAIN_PAGE_TRUNCATION: int = 5000
    MAX_REDIRECT_DEPTH: int = 10
    # Phase A item #2 — table & infobox handling in compact mode.
    TABLE_ROW_THRESHOLD: int = 8
    TABLE_CHAR_THRESHOLD: int = 600
    INFOBOX_KV_LIMIT: int = 30


@dataclass(frozen=True)
class MetaDefaults:
    """Default values for the response `_meta` envelope (Phase A item #5)."""

    FOOTER_ENABLED: bool = True
    TOKENIZER_ENCODING: str = "cl100k_base"


@dataclass(frozen=True)
class SearchDefaults:
    """Default values for search-side knobs (Phase A items #4, #14)."""

    STRUCTURED_SUGGESTIONS_LIMIT: int = 5
    FUZZY_TITLE_MIN_QUERY_LEN: int = 4
    FUZZY_TITLE_SCORE_PENALTY: float = 0.85
```

Then at the bottom of the file, add:

```python
META = MetaDefaults()
SEARCH = SearchDefaults()
```

- [ ] **Step 4: Run the tests to confirm pass**

```bash
uv run pytest tests/test_defaults.py -v
```
Expected: all `test_meta_defaults`, `test_search_defaults`, `test_content_defaults_table_extras` pass.

- [ ] **Step 5: Run the full suite to confirm no regression**

```bash
uv run pytest -x
```
Expected: green.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/defaults.py tests/test_defaults.py
git commit -m "feat(v2/defaults): add Phase A constants for meta envelope, search, table handling"
```

---

## Task 3: Add Phase A pydantic config models

**Files:**
- Modify: `openzim_mcp/config.py`
- Test: `tests/test_config.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_config.py`:

```python
import os
from openzim_mcp.config import (
    ContentConfig,
    MetaConfig,
    OpenZimMcpConfig,
    SearchConfig,
)


def test_meta_config_defaults():
    cfg = MetaConfig()
    assert cfg.footer_enabled is True
    assert cfg.tokenizer_encoding == "cl100k_base"


def test_search_config_defaults():
    cfg = SearchConfig()
    assert cfg.structured_suggestions_limit == 5
    assert cfg.fuzzy_title_min_query_len == 4
    assert cfg.fuzzy_title_score_penalty == 0.85


def test_content_config_table_fields():
    cfg = ContentConfig()
    assert cfg.table_row_threshold == 8
    assert cfg.table_char_threshold == 600
    assert cfg.infobox_kv_limit == 30


def test_meta_env_override(monkeypatch, tmp_path):
    monkeypatch.setenv("OPENZIM_MCP_META__FOOTER_ENABLED", "false")
    monkeypatch.setenv("OPENZIM_MCP_ALLOWED_DIRECTORIES", f'["{tmp_path}"]')
    cfg = OpenZimMcpConfig()
    assert cfg.meta.footer_enabled is False
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_config.py::test_meta_config_defaults tests/test_config.py::test_search_config_defaults tests/test_config.py::test_content_config_table_fields tests/test_config.py::test_meta_env_override -v
```
Expected: `ImportError` for `MetaConfig`, `SearchConfig`.

- [ ] **Step 3: Add the new config models**

In `openzim_mcp/config.py`, near the existing `ContentConfig` definition, add:

```python
class ContentConfig(BaseModel):
    """Content processing configuration."""

    max_content_length: int = Field(default=CONTENT.MAX_CONTENT_LENGTH, ge=100)
    snippet_length: int = Field(default=CONTENT.SNIPPET_LENGTH, ge=100)
    default_search_limit: int = Field(default=CONTENT.SEARCH_LIMIT, ge=1, le=100)
    table_row_threshold: int = Field(default=CONTENT.TABLE_ROW_THRESHOLD, ge=1)
    table_char_threshold: int = Field(default=CONTENT.TABLE_CHAR_THRESHOLD, ge=50)
    infobox_kv_limit: int = Field(default=CONTENT.INFOBOX_KV_LIMIT, ge=1, le=200)


class MetaConfig(BaseModel):
    """Configuration for the response `_meta` envelope (Phase A item #5)."""

    footer_enabled: bool = Field(default=META.FOOTER_ENABLED)
    tokenizer_encoding: str = Field(default=META.TOKENIZER_ENCODING)


class SearchConfig(BaseModel):
    """Configuration for search-side knobs (Phase A items #4, #14)."""

    structured_suggestions_limit: int = Field(
        default=SEARCH.STRUCTURED_SUGGESTIONS_LIMIT, ge=1, le=20
    )
    fuzzy_title_min_query_len: int = Field(
        default=SEARCH.FUZZY_TITLE_MIN_QUERY_LEN, ge=2, le=20
    )
    fuzzy_title_score_penalty: float = Field(
        default=SEARCH.FUZZY_TITLE_SCORE_PENALTY, ge=0.0, le=1.0
    )
```

Update the `defaults` import at the top of `config.py` to include `META, SEARCH`:

```python
from .defaults import CACHE, CONTENT, META, RATE_LIMIT, SEARCH, VALID_TOOL_MODES
```

In `OpenZimMcpConfig`, add the two new sub-configs alongside the existing ones:

```python
class OpenZimMcpConfig(BaseSettings):
    # ... existing fields ...
    cache: CacheConfig = Field(default_factory=CacheConfig)
    content: ContentConfig = Field(default_factory=ContentConfig)
    logging: LoggingConfig = Field(default_factory=LoggingConfig)
    rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
    meta: MetaConfig = Field(default_factory=MetaConfig)
    search: SearchConfig = Field(default_factory=SearchConfig)
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_config.py -v
```
Expected: green, including the env-var override test.

- [ ] **Step 5: Run the full suite**

```bash
uv run pytest -x
```
Expected: green.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/config.py tests/test_config.py
git commit -m "feat(v2/config): add MetaConfig, SearchConfig, extend ContentConfig

Wires Phase A defaults into the pydantic config tree. New env vars:
OPENZIM_MCP_META__*, OPENZIM_MCP_SEARCH__*, OPENZIM_MCP_CONTENT__TABLE_*."
```

---

## Task 4: Implement `tokens_est` in `meta.py`

**Files:**
- Create: `openzim_mcp/meta.py`
- Create: `tests/test_meta.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_meta.py`:

```python
"""Tests for the openzim_mcp.meta module."""

import pytest
from openzim_mcp.meta import tokens_est


def test_tokens_est_basic():
    # tiktoken cl100k_base encodes "hello world" as 2 tokens
    assert tokens_est("hello world") == pytest.approx(2, abs=1)


def test_tokens_est_empty():
    assert tokens_est("") == 0


def test_tokens_est_unicode():
    # Multi-byte characters tokenize predictably with cl100k_base
    n = tokens_est("こんにちは世界")
    assert n > 0
    assert n < 50


def test_tokens_est_long_string_scales_linearly():
    short = tokens_est("Hello, this is a sample sentence. " * 10)
    long = tokens_est("Hello, this is a sample sentence. " * 100)
    # Linear scaling within ±5%
    assert long == pytest.approx(short * 10, rel=0.05)
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: `ModuleNotFoundError: No module named 'openzim_mcp.meta'`.

- [ ] **Step 3: Implement `tokens_est`**

Create `openzim_mcp/meta.py`:

```python
"""Response metadata helpers — `_meta` envelope, footer, token estimation.

Phase A item #5 (token & char metadata). The `_meta` envelope ships on
every dict-returning tool; the footer renders a one-line markdown
summary on prose-returning tools in compact mode. Token estimates use
tiktoken's cl100k_base encoding as a model-agnostic budget signal —
not exact for any specific model, but close enough for context
budgeting across Anthropic, OpenAI, and Llama tokenizers.
"""

from __future__ import annotations

import logging
from typing import Optional

logger = logging.getLogger(__name__)

# Lazy-loaded tokenizer; the `tiktoken.get_encoding` call downloads
# (once, then caches) the BPE merge table to ~/.cache/tiktoken or to
# a tempdir. Module-level cache means the first request pays the
# initialisation cost; subsequent requests are millions-of-tokens-per-second.
_encoder = None
_encoder_init_failed = False


def _get_encoder():
    global _encoder, _encoder_init_failed
    if _encoder is not None or _encoder_init_failed:
        return _encoder
    try:
        import tiktoken

        _encoder = tiktoken.get_encoding("cl100k_base")
    except Exception as e:  # pragma: no cover — defensive; sandboxed envs
        logger.warning(
            "tiktoken init failed; tokens_est will return 0 for this session: %s", e
        )
        _encoder_init_failed = True
    return _encoder


def tokens_est(rendered: str) -> int:
    """Estimate the token count of a rendered string using cl100k_base.

    Returns 0 on empty input or if the tokenizer failed to initialise.
    """
    if not rendered:
        return 0
    encoder = _get_encoder()
    if encoder is None:
        return 0
    return len(encoder.encode(rendered))
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/meta.py tests/test_meta.py
git commit -m "feat(meta): add tokens_est using tiktoken cl100k_base

Lazy-loaded tokenizer; module-level cache. Returns 0 on empty input
or tokenizer init failure (sandboxed env). Phase A item #5 foundation."
```

---

## Task 5: Implement `build_meta` in `meta.py`

**Files:**
- Modify: `openzim_mcp/meta.py`
- Modify: `tests/test_meta.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_meta.py`:

```python
from openzim_mcp.meta import build_meta


def test_build_meta_basic_dict():
    meta = build_meta(rendered='{"results": []}')
    assert meta["chars"] == len('{"results": []}')
    assert meta["tokens_est"] >= 1
    assert meta["truncated"] is False
    assert "more_at_offset" not in meta
    assert "suggestions" not in meta


def test_build_meta_truncated():
    meta = build_meta(
        rendered="X" * 100,
        total_chars=10_000,
        current_offset=0,
        truncated=True,
    )
    assert meta["truncated"] is True
    assert meta["more_at_offset"] == 100  # at the end of the current slice
    assert meta["total_chars"] == 10_000


def test_build_meta_with_suggestions():
    meta = build_meta(
        rendered="No results found.",
        suggestions=[
            {"type": "alt_spelling", "value": "Photosynthesis"},
            {"type": "alt_archive", "value": "wikipedia_en_all"},
        ],
        reason="0_hits",
    )
    assert meta["suggestions"][0]["type"] == "alt_spelling"
    assert meta["reason"] == "0_hits"


def test_build_meta_omits_empty_suggestions():
    meta = build_meta(rendered="content", suggestions=[])
    assert "suggestions" not in meta


def test_build_meta_pads_token_count_for_envelope():
    # Pad must be applied — tokens_est on raw rendered should be lower
    # than what build_meta returns.
    rendered = "X" * 1000
    meta = build_meta(rendered=rendered)
    raw_tokens = tokens_est(rendered)
    assert meta["tokens_est"] >= raw_tokens
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: `ImportError: cannot import name 'build_meta'`.

- [ ] **Step 3: Implement `build_meta`**

Append to `openzim_mcp/meta.py`:

```python
from typing import Any, Dict, List


def build_meta(
    *,
    rendered: str,
    total_chars: Optional[int] = None,
    current_offset: int = 0,
    truncated: bool = False,
    suggestions: Optional[List[Dict[str, str]]] = None,
    reason: Optional[str] = None,
) -> Dict[str, Any]:
    """Construct a `_meta` envelope for a tool response.

    Args:
        rendered: The string the model will see (markdown body for prose
            tools, JSON-serialised payload for dict tools — minus the
            `_meta` key itself to avoid self-reference).
        total_chars: Total length of the source content. Required when
            `truncated` is True; ignored otherwise.
        current_offset: Character offset where this slice begins.
        truncated: True iff the response was cut to fit a `max_*` limit.
        suggestions: Structured suggestion list. Empty/None → field omitted.
        reason: Machine-readable reason on empty / low-confidence results.
            One of: `no_xapian_index`, `0_hits`, `low_relevance`,
            `bad_namespace`, `bad_query`. None → field omitted.

    Returns:
        A `_meta` dict with `tokens_est`, `chars`, `truncated`, plus
        optional `more_at_offset`, `total_chars`, `suggestions`,
        `reason` keys when applicable. Adds 5% pad to `tokens_est` to
        account for the envelope's own contribution.
    """
    chars = len(rendered)
    raw_tokens = tokens_est(rendered)
    # 5% pad covers the JSON-encoded envelope's contribution itself.
    padded_tokens = int(raw_tokens * 1.05) + 1 if raw_tokens else 0

    meta: Dict[str, Any] = {
        "tokens_est": padded_tokens,
        "chars": chars,
        "truncated": truncated,
    }
    if truncated:
        meta["more_at_offset"] = current_offset + chars
        if total_chars is not None:
            meta["total_chars"] = total_chars
    if suggestions:
        meta["suggestions"] = suggestions
    if reason is not None:
        meta["reason"] = reason
    return meta
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/meta.py tests/test_meta.py
git commit -m "feat(meta): add build_meta for response _meta envelope

Always emits tokens_est+chars+truncated; conditionally emits
more_at_offset, total_chars, suggestions, reason. 5% token pad for
envelope cost. Phase A item #5."
```

---

## Task 6: Implement `format_footer` in `meta.py`

**Files:**
- Modify: `openzim_mcp/meta.py`
- Modify: `tests/test_meta.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_meta.py`:

```python
from openzim_mcp.meta import format_footer


def test_footer_basic():
    meta = {"tokens_est": 4283, "chars": 17034, "truncated": False}
    footer = format_footer(meta, footer_enabled=True)
    assert footer.startswith("> ")
    assert "~4.3K tokens" in footer  # 4283 → 4.3K (one-decimal K-scale)
    assert "more" not in footer  # not truncated → no offset hint
    assert "chars" not in footer  # untruncated responses skip char count


def test_footer_truncated():
    meta = {
        "tokens_est": 4283,
        "chars": 17034,
        "truncated": True,
        "more_at_offset": 17034,
        "total_chars": 87421,
    }
    footer = format_footer(meta, footer_enabled=True)
    assert "17K of 87K chars" in footer
    assert "offset=17034" in footer


def test_footer_empty_results():
    meta = {
        "tokens_est": 50,
        "chars": 200,
        "truncated": False,
        "reason": "0_hits",
        "suggestions": [
            {"type": "alt_spelling", "value": "Photosynthesis"},
            {"type": "alt_archive", "value": "wikipedia_en_all"},
        ],
    }
    footer = format_footer(meta, footer_enabled=True)
    assert footer.startswith("> No results.")
    assert "Photosynthesis" in footer
    assert "wikipedia_en_all" in footer


def test_footer_disabled():
    meta = {"tokens_est": 100, "chars": 400, "truncated": False}
    assert format_footer(meta, footer_enabled=False) == ""


def test_footer_caps_visible_suggestions_at_three():
    meta = {
        "tokens_est": 50,
        "chars": 200,
        "truncated": False,
        "reason": "0_hits",
        "suggestions": [
            {"type": "alt_spelling", "value": f"alt{i}"} for i in range(7)
        ],
    }
    footer = format_footer(meta, footer_enabled=True)
    # Three visible separators implies max three values
    assert footer.count("·") <= 3
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: `ImportError: cannot import name 'format_footer'`.

- [ ] **Step 3: Implement `format_footer`**

Append to `openzim_mcp/meta.py`:

```python
def _humanize_count(n: int) -> str:
    """Format an integer as ~XK with one decimal for K-scale, integer otherwise."""
    if n < 1000:
        return str(n)
    k = n / 1000
    if k < 10:
        return f"{k:.1f}K"
    return f"{int(k)}K"


def format_footer(meta: Dict[str, Any], *, footer_enabled: bool) -> str:
    """Render a one-line markdown blockquote footer from a `_meta` envelope.

    Returns the empty string when `footer_enabled` is False or when the
    envelope is empty.

    Two shapes:
      * Empty/zero-result: `> No results. Try: ...`
      * Successful: `> ~4.2K tokens · 17K of 87K chars · pass offset=N for more`
    """
    if not footer_enabled or not meta:
        return ""

    if meta.get("reason") in {"0_hits", "low_relevance", "bad_query"}:
        suggestions = meta.get("suggestions") or []
        visible = suggestions[:3]
        if not visible:
            return "> No results. Try a shorter or differently-spelled query."
        bits: List[str] = []
        for item in visible:
            t = item.get("type")
            v = item.get("value", "")
            if t == "alt_spelling":
                bits.append(f"`suggestions for {v}`")
            elif t == "alt_archive":
                bits.append(f"or try ZIM `{v}`")
            elif t == "broader":
                bits.append(f"`search {v}` (broader)")
            elif t == "narrower":
                bits.append(f"`search {v}` (narrower)")
            else:
                bits.append(f"`{v}`")
        return "> No results. Try: " + " · ".join(bits)

    parts: List[str] = [f"~{_humanize_count(meta['tokens_est'])} tokens"]
    if meta.get("truncated"):
        chars_label = _humanize_count(meta["chars"])
        total_label = _humanize_count(meta.get("total_chars", meta["chars"]))
        parts.append(f"{chars_label} of {total_label} chars")
        if "more_at_offset" in meta:
            parts.append(f"pass `offset={meta['more_at_offset']}` for more")
    return "> " + " · ".join(parts)
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_meta.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/meta.py tests/test_meta.py
git commit -m "feat(meta): add format_footer for compact-mode prose responses

Successful: '> ~4.2K tokens · 17K of 87K chars · pass offset=N for more'
Empty: '> No results. Try: <three suggestion bits>'.
Phase A item #5."
```

---

## Task 7: Wire `_meta` into `search_zim_file_data`

**Files:**
- Modify: `openzim_mcp/zim/search.py:181-337`
- Modify: `tests/test_search_tools.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_search_tools.py` (find the section that tests `search_zim_file_data`; if a fixture-archive helper exists, reuse it):

```python
def test_search_zim_file_data_attaches_meta(zim_operations):
    """search_zim_file_data should attach a `_meta` envelope."""
    result = zim_operations.search_zim_file_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        query="climate",
        limit=5,
    )
    assert "_meta" in result
    assert result["_meta"]["tokens_est"] > 0
    assert result["_meta"]["chars"] > 0
    assert result["_meta"]["truncated"] is False
```

(If `zim_operations` and `fixture_zim_path` aren't existing fixtures, use whatever pattern the file already uses — check `conftest.py` and the surrounding test file for the established setup.)

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_search_tools.py::test_search_zim_file_data_attaches_meta -v
```
Expected: `KeyError: '_meta'`.

- [ ] **Step 3: Add the temporary `_attach_meta` helper to `meta.py`**

Append to `openzim_mcp/meta.py` (this helper is hoisted to a public name in Task 9; defining it here lets Task 7 land independently):

```python
import json as _json


def attach_meta(
    payload: Dict[str, Any],
    *,
    truncated: bool = False,
    total_chars: Optional[int] = None,
    current_offset: int = 0,
    suggestions: Optional[List[Dict[str, str]]] = None,
    reason: Optional[str] = None,
) -> Dict[str, Any]:
    """Attach a `_meta` envelope built from the JSON-rendered payload (sans _meta).

    Mutates and returns `payload`. Forwards build_meta kwargs.
    """
    rendered = _json.dumps(
        {k: v for k, v in payload.items() if k != "_meta"},
        ensure_ascii=False,
    )
    payload["_meta"] = build_meta(
        rendered=rendered,
        truncated=truncated,
        total_chars=total_chars,
        current_offset=current_offset,
        suggestions=suggestions,
        reason=reason,
    )
    return payload
```

- [ ] **Step 3b: Wire `attach_meta` into `search_zim_file_data`**

At the top of `openzim_mcp/zim/search.py` add the import:

```python
from openzim_mcp.meta import attach_meta
```

In `search_zim_file_data` (around line 181), modify the cached-return branch to backfill `_meta` if missing (older cache entries) and the fresh-payload branch to call `attach_meta` before returning:

```python
cached_result = self.cache.get(cache_key)
if cached_result is not None:
    logger.debug(f"Returning cached search dict for query: {query}")
    if "_meta" not in cached_result:
        cached_result = attach_meta(dict(cached_result))
    return cached_result
```

```python
# After self.cache.set(cache_key, payload):
return attach_meta(payload)
```

- [ ] **Step 4: Run the test to confirm pass**

```bash
uv run pytest tests/test_search_tools.py::test_search_zim_file_data_attaches_meta -v
```
Expected: green.

- [ ] **Step 5: Run the full test file to catch any cache-related regression**

```bash
uv run pytest tests/test_search_tools.py -v
```
Expected: green. If a test fails because it asserts the exact dict shape (`assert result == {...}`), update it to assert subset shape using `assert result["results"] == [...]` or pop `_meta` before comparison.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_search_tools.py
git commit -m "feat(search): attach _meta envelope to search_zim_file_data returns"
```

---

## Task 8: Wire `_meta` into entry-fetch returns

**Files:**
- Modify: `openzim_mcp/zim/content.py` (`get_zim_entry_data`, `get_zim_entries`, `get_main_page_data`, `get_entry_summary`)
- Modify: `tests/test_content_tools.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_content_tools.py`:

```python
def test_get_zim_entry_attaches_meta(zim_operations):
    payload = zim_operations.get_zim_entry_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        entry_path="A/Photosynthesis",
        max_content_length=500,
    )
    # When max_content_length forces truncation, _meta must reflect it
    assert "_meta" in payload
    assert payload["_meta"]["tokens_est"] > 0
    if payload["_meta"]["truncated"]:
        assert "more_at_offset" in payload["_meta"]
        assert "total_chars" in payload["_meta"]
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_content_tools.py::test_get_zim_entry_attaches_meta -v
```
Expected: `KeyError: '_meta'`.

- [ ] **Step 3: Add `attach_meta` import and identify the `_data` methods**

In `openzim_mcp/zim/content.py`, add the import at the top:

```python
from openzim_mcp.meta import attach_meta
```

Run `grep -n "_data\|def get_" openzim_mcp/zim/content.py` to locate the dict-returning methods. The file should have (at minimum): `get_zim_entry_data`, `get_zim_entries` (batch shape), `get_main_page_data`, `get_entry_summary` (or `get_entry_summary_data`). If a method returns a string and there's no `_data` sibling yet, create one that returns the dict; the string method becomes a thin wrapper that pops `_meta` and renders the rest.

- [ ] **Step 3b: Wire `attach_meta` into `get_zim_entry_data`**

Find the return path of `get_zim_entry_data`. The payload should already have `path`, `title`, `content` keys. Modify the return:

```python
truncated = total_chars > effective_max_length
payload = {
    "path": resolved_path,
    "title": title,
    "content": content_slice,
}
return attach_meta(
    payload,
    truncated=truncated,
    total_chars=total_chars if truncated else None,
    current_offset=content_offset,
)
```

Where `total_chars` is the length of the full extracted body before truncation, and `effective_max_length` is the resolved cap.

- [ ] **Step 3c: Wire `attach_meta` into the other dict-returning entry methods**

Apply the same pattern to:

- `get_zim_entries` (batch). At the end of the batch loop, before returning, call `attach_meta(payload)` with `truncated=False` (per-entry truncation flags live inside the batch payload).
- `get_main_page_data`. Same as `get_zim_entry_data` but with the main-page truncation cap (`MAIN_PAGE_TRUNCATION = 5000`).
- `get_entry_summary` (or its `_data` sibling). Wrap return with `attach_meta(payload)`.

Each call site replaces a bare `return payload` with `return attach_meta(payload, …)` where the kwargs reflect that method's truncation state.

- [ ] **Step 4: Run the test to confirm pass**

```bash
uv run pytest tests/test_content_tools.py::test_get_zim_entry_attaches_meta -v
```
Expected: green.

- [ ] **Step 5: Run the full content-test suite**

```bash
uv run pytest tests/test_content_tools.py tests/test_batch_get_entries.py -v
```
Expected: green. Update any byte-precise dict-shape assertions to ignore `_meta`.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/content.py tests/test_content_tools.py tests/test_batch_get_entries.py
git commit -m "feat(content): attach _meta envelope to entry-fetch returns

get_zim_entry_data, get_zim_entries, get_main_page_data, get_entry_summary
all carry tokens_est + chars + truncated + (when truncated) more_at_offset
and total_chars."
```

---

## Task 9: Wire `_meta` into namespace and metadata returns

**Files:**
- Modify: `openzim_mcp/zim/namespace.py` (`browse_namespace_data`, `walk_namespace_data`)
- Modify: `openzim_mcp/zim/search.py` (`find_entry_by_title_data`, `search_all`, `get_search_suggestions`)
- Modify: `openzim_mcp/zim/content.py` (`get_zim_metadata` if dict-returning)
- Modify: existing tests in `tests/test_navigation_tools.py`, `tests/test_metadata_tools.py`, `tests/test_search_all.py`

- [ ] **Step 1: Write failing tests**

Append a `_meta` shape assertion to each of:

```python
# tests/test_navigation_tools.py
def test_browse_namespace_attaches_meta(zim_operations):
    payload = zim_operations.browse_namespace_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        namespace="A",
        limit=10,
    )
    assert "_meta" in payload
    assert payload["_meta"]["tokens_est"] > 0


# tests/test_search_all.py
def test_search_all_attaches_meta(zim_operations):
    payload = zim_operations.search_all_data(
        query="climate", limit_per_file=2
    )
    assert "_meta" in payload


# tests/test_find_entry_by_title.py
def test_find_entry_by_title_attaches_meta(zim_operations):
    payload = zim_operations.find_entry_by_title_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        title="Photosynthesis",
    )
    assert "_meta" in payload
```

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_search_all.py tests/test_find_entry_by_title.py -v -k attaches_meta
```
Expected: all three fail with `KeyError: '_meta'`.

- [ ] **Step 3: Apply `attach_meta` to remaining `*_data` methods**

`attach_meta` is already in `openzim_mcp/meta.py` from Task 7. In each of the following files, add the import and wrap the return statement of every dict-returning method:

```python
from openzim_mcp.meta import attach_meta
```

Methods to wrap (one return statement each, identical pattern):
- `openzim_mcp/zim/namespace.py`: `browse_namespace_data`, `walk_namespace_data`, `list_namespaces_data` (if dict-returning)
- `openzim_mcp/zim/search.py`: `search_all_data`, `find_entry_by_title_data`, `get_search_suggestions_data`, `search_with_filters_data`
- `openzim_mcp/zim/content.py`: `get_zim_metadata` (if dict-returning), `get_article_structure_data`, `extract_article_links_data`, `get_table_of_contents_data`, `get_related_articles_data`, `get_binary_entry_data`

Use `grep -n "def .*_data" openzim_mcp/zim/*.py` to enumerate; wrap each return with `return attach_meta(payload)`. Pagination-bearing methods (`browse_namespace_data`, `walk_namespace_data`) don't need `truncated=True` (their pagination state is already in the payload's `pagination` key); just attach the envelope.

- [ ] **Step 4: Run all *_meta tests**

```bash
uv run pytest -v -k attaches_meta
```
Expected: green for all.

- [ ] **Step 5: Run the full suite**

```bash
uv run pytest -x
```
Expected: green. Fix shape-strict assertions in older tests by ignoring `_meta`.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/meta.py openzim_mcp/zim/namespace.py openzim_mcp/zim/search.py openzim_mcp/zim/content.py tests/
git commit -m "feat(zim): attach _meta envelope to all dict-returning ops

Hoists attach_meta helper to openzim_mcp.meta. Every *_data method now
emits _meta on success. Phase A item #5 plumbing complete."
```

---

## Task 10: Append prose footer to compact-mode responses

**Files:**
- Modify: `openzim_mcp/simple_tools.py` (the path that renders compact-mode responses)
- Modify: `tests/test_simple_tools.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_simple_tools.py`:

```python
def test_compact_response_appends_footer(simple_tool_runner):
    out = simple_tool_runner.zim_query("tell me about climate", options={"compact": True})
    # Successful response should end with a one-line blockquote footer
    last_line = out.rstrip().splitlines()[-1]
    assert last_line.startswith("> ")
    assert "tokens" in last_line


def test_compact_response_footer_disabled(simple_tool_runner_no_footer):
    out = simple_tool_runner_no_footer.zim_query("tell me about climate", options={"compact": True})
    assert "> ~" not in out  # no footer when META_FOOTER_ENABLED=false


def test_non_compact_response_omits_footer(simple_tool_runner):
    out = simple_tool_runner.zim_query("tell me about climate", options={"compact": False})
    last_line = out.rstrip().splitlines()[-1]
    assert not last_line.startswith("> ~")  # footer suppressed in verbose mode
```

The `simple_tool_runner_no_footer` fixture should construct an `OpenZimMcpConfig` with `meta=MetaConfig(footer_enabled=False)`.

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_simple_tools.py -v -k footer
```
Expected: all three fail.

- [ ] **Step 3: Wire footer into compact-mode rendering**

In `openzim_mcp/simple_tools.py`, find the function that produces compact-mode output (look for `_truncate_search_snippets`, `_strip_markdown_links`, or similar in the compact pipeline). After the body is finalized but before return, compute `_meta` and append the footer:

```python
from openzim_mcp.meta import build_meta, format_footer

# At the end of the compact-mode response builder:
def _wrap_compact_with_footer(
    self, body: str, *, total_chars: Optional[int] = None,
    current_offset: int = 0, truncated: bool = False,
    suggestions: Optional[list] = None, reason: Optional[str] = None,
) -> str:
    meta = build_meta(
        rendered=body,
        total_chars=total_chars,
        current_offset=current_offset,
        truncated=truncated,
        suggestions=suggestions,
        reason=reason,
    )
    footer = format_footer(meta, footer_enabled=self.config.meta.footer_enabled)
    if not footer:
        return body
    return body + "\n\n" + footer
```

Apply `_wrap_compact_with_footer` only when `compact=True` and only at the end of the response. In `compact=False` paths, return the body unchanged.

- [ ] **Step 4: Run the tests to confirm pass**

```bash
uv run pytest tests/test_simple_tools.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/simple_tools.py tests/test_simple_tools.py
git commit -m "feat(simple): append prose footer to compact-mode responses

One-line blockquote: '> ~4.2K tokens · 17K of 87K chars · ...'.
Suppressed in compact=False mode and when META_FOOTER_ENABLED=false.
Phase A item #5 surface."
```

---

## Task 11: Extend `create_snippet` with query-aware paragraph selection

**Files:**
- Modify: `openzim_mcp/content_processor.py:432-459`
- Create: `tests/test_create_snippet_query_aware.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_create_snippet_query_aware.py`:

```python
"""Tests for query-aware create_snippet (Phase A item #1)."""

import pytest

from openzim_mcp.content_processor import ContentProcessor


@pytest.fixture
def processor() -> ContentProcessor:
    return ContentProcessor(snippet_length=400)


SAMPLE = (
    "Photosynthesis is a biological process used by plants.\n\n"
    "It converts light energy into chemical energy.\n\n"
    "Chlorophyll, the green pigment in leaves, absorbs sunlight.\n\n"
    "The process requires water and carbon dioxide as inputs."
)


def test_query_match_selects_matching_paragraph(processor):
    snippet = processor.create_snippet(SAMPLE, query="chlorophyll")
    # Matched paragraph (3rd) should be present
    assert "Chlorophyll" in snippet
    # The first paragraph should NOT lead the snippet (we jumped to the match)
    assert not snippet.startswith("Photosynthesis is")


def test_query_no_match_falls_back_to_lead(processor):
    snippet = processor.create_snippet(SAMPLE, query="quantum")
    # No match → lead-paragraph behavior
    assert snippet.startswith("Photosynthesis is")


def test_query_highlights_match(processor):
    snippet = processor.create_snippet(SAMPLE, query="chlorophyll")
    # First match wrapped in **bold**
    assert "**Chlorophyll**" in snippet or "**chlorophyll**" in snippet


def test_query_highlight_capped_at_five(processor):
    dense = "alpha alpha alpha alpha alpha alpha alpha alpha"
    snippet = processor.create_snippet(dense, query="alpha")
    assert snippet.count("**alpha**") <= 5


def test_query_aware_respects_snippet_length(processor):
    long = "Para about chlorophyll and biology. " * 200
    snippet = processor.create_snippet(long, query="chlorophyll")
    assert len(snippet) <= 400


def test_no_query_preserves_legacy_behavior(processor):
    snippet = processor.create_snippet(SAMPLE)
    assert snippet.startswith("Photosynthesis is")
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_create_snippet_query_aware.py -v
```
Expected: all fail (the query parameter doesn't exist yet).

- [ ] **Step 3: Implement query-aware snippet**

In `openzim_mcp/content_processor.py`, replace `create_snippet` with the query-aware version:

```python
import re
import unicodedata


def create_snippet(
    self,
    content: str,
    *,
    query: Optional[str] = None,
    max_paragraphs: int = 2,
) -> str:
    """Create a snippet from content, optionally query-aware.

    When `query` is supplied, locate the first paragraph that contains a
    whole-word match for any term in the query (case-insensitive,
    diacritic-folded) and start the snippet there. Otherwise, take the
    leading paragraphs (legacy behavior).

    Up to 5 occurrences of any matched term inside the returned slice are
    wrapped in `**bold**` for visibility.

    Args:
        content: Full content text.
        query: Optional query string for query-aware snippet selection.
        max_paragraphs: Maximum number of paragraphs to include.

    Returns:
        Content snippet, length-capped at `self.snippet_length`.
    """
    if not content:
        return ""

    paragraphs = content.split("\n\n")
    start_idx = 0

    if query:
        terms = [t for t in _split_query_terms(query) if len(t) >= 3]
        if terms:
            folded_paragraphs = [_fold(p) for p in paragraphs]
            for i, p in enumerate(folded_paragraphs):
                if any(_word_in(p, t) for t in terms):
                    start_idx = i
                    break

    selected = paragraphs[start_idx : start_idx + max_paragraphs]
    snippet_text = " ".join(selected) if len(selected) > 1 else (
        selected[0] if selected else ""
    )

    if len(snippet_text) > self.snippet_length:
        cap = max(self.snippet_length - 3, 0)
        snippet_text = snippet_text[:cap].rstrip() + "..."

    if query:
        snippet_text = _highlight_terms(snippet_text, query, max_hits=5)

    return snippet_text
```

Ensure `unicodedata` is imported at the top of `openzim_mcp/content_processor.py` (alongside the existing `import re`):

```python
import re
import unicodedata
```

Add module-level helpers above `ContentProcessor`:

```python
def _fold(text: str) -> str:
    """Lowercase + strip diacritics."""
    nf = unicodedata.normalize("NFKD", text)
    return "".join(c for c in nf if not unicodedata.combining(c)).lower()


def _split_query_terms(query: str) -> list:
    """Split a query string into individual terms; drop punctuation."""
    return [t for t in re.split(r"\W+", _fold(query)) if t]


def _word_in(folded_paragraph: str, term: str) -> bool:
    """Whole-word match of `term` in already-folded paragraph text."""
    return bool(re.search(rf"\b{re.escape(term)}\b", folded_paragraph))


def _highlight_terms(text: str, query: str, *, max_hits: int) -> str:
    """Wrap the first `max_hits` occurrences of any query term in **bold**.

    Case-insensitive, preserves original casing of the matched substring.
    """
    terms = [t for t in _split_query_terms(query) if len(t) >= 3]
    if not terms:
        return text
    pattern = re.compile(
        r"\b(" + "|".join(re.escape(t) for t in terms) + r")\b",
        flags=re.IGNORECASE,
    )
    hits = [0]

    def repl(m: re.Match) -> str:
        if hits[0] >= max_hits:
            return m.group(0)
        hits[0] += 1
        return f"**{m.group(0)}**"

    return pattern.sub(repl, text)
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_create_snippet_query_aware.py tests/test_content_processor.py -v
```
Expected: green for new tests; existing tests still pass since the `query` parameter defaults to `None`.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/content_processor.py tests/test_create_snippet_query_aware.py
git commit -m "feat(content): query-aware create_snippet with bold-highlighted matches

When the search query is threaded through, the snippet starts at the
first paragraph containing a whole-word query match and bolds up to
five occurrences. Falls back to lead-paragraph when no match found.
Phase A item #1."
```

---

## Task 12: Thread query through `_get_entry_snippet` and search call sites

**Files:**
- Modify: `openzim_mcp/zim/content.py:59-72` (`_get_entry_snippet`)
- Modify: `openzim_mcp/zim/search.py:297, 715` (call sites)
- Modify: `tests/test_search_tools.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_search_tools.py`:

```python
def test_search_returns_query_aware_snippet(zim_operations):
    """A search snippet must contain context from the query, not just the article lead."""
    payload = zim_operations.search_zim_file_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        query="chlorophyll",
        limit=3,
    )
    matched_snippets = [r["snippet"] for r in payload["results"] if r.get("snippet")]
    assert any("chlorophyll" in s.lower() for s in matched_snippets), (
        "expected at least one query-aware snippet to mention 'chlorophyll'"
    )
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_search_tools.py::test_search_returns_query_aware_snippet -v
```
Expected: fail (snippets are still lead-paragraph).

- [ ] **Step 3: Update `_get_entry_snippet` signature**

In `openzim_mcp/zim/content.py:59`:

```python
def _get_entry_snippet(self, entry: Any, query: Optional[str] = None) -> str:
    """Get content snippet for search result, optionally query-aware."""
    try:
        item = entry.get_item()
        if item.mimetype.startswith("text/"):
            content = self.content_processor.process_mime_content(
                bytes(item.content), item.mimetype
            )
            return self.content_processor.create_snippet(content, query=query)
        else:
            return f"(Unsupported content type: {item.mimetype})"
    except Exception as e:
        logger.warning(f"Error getting content snippet: {e}")
        return "(Unable to get content preview)"
```

Update the type-checking declaration at `openzim_mcp/zim/search.py:145` to match:

```python
# Resolve via the content mixin; declared here for type checking.
def _get_entry_snippet(
    self, entry: Any, query: Optional[str] = None
) -> str:
    """Resolve via ``_ContentMixin`` on the concrete coordinator."""
```

- [ ] **Step 4: Pass query at search call sites**

In `openzim_mcp/zim/search.py:297` (inside `_perform_search`), change:

```python
snippet = self._get_entry_snippet(entry, query=query)
```

In `openzim_mcp/zim/search.py:715` (inside `search_with_filters`), do the same:

```python
snippet = self._get_entry_snippet(entry, query=query)
```

- [ ] **Step 5: Run the tests to confirm pass**

```bash
uv run pytest tests/test_search_tools.py -v
```
Expected: green for the new test and all existing.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/content.py openzim_mcp/zim/search.py tests/test_search_tools.py
git commit -m "feat(search): pass query into _get_entry_snippet for query-aware snippets

Search now returns the matching passage instead of the article lead.
Phase A item #1 surface."
```

---

## Task 13: Implement `extract_infobox` in `content_processor.py`

**Files:**
- Modify: `openzim_mcp/content_processor.py`
- Create: `tests/test_content_processor_infobox.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_content_processor_infobox.py`:

```python
"""Tests for extract_infobox (Phase A item #2)."""

import pytest
from bs4 import BeautifulSoup

from openzim_mcp.content_processor import ContentProcessor


@pytest.fixture
def processor() -> ContentProcessor:
    return ContentProcessor()


WIKI_INFOBOX_HTML = """
<div>
  <table class="infobox vcard">
    <tr><th colspan="2">Albert Einstein</th></tr>
    <tr><th>Born</th><td>14 March 1879</td></tr>
    <tr><th>Died</th><td>18 April 1955</td></tr>
    <tr><th>Nationality</th><td>German, Swiss, American</td></tr>
  </table>
  <p>Albert Einstein was a theoretical physicist.</p>
</div>
"""

INFOBOX_FREE_HTML = """
<div><p>Some plain prose with no infobox at all.</p></div>
"""


def test_extract_basic_infobox(processor):
    soup = BeautifulSoup(WIKI_INFOBOX_HTML, "html.parser")
    rows = processor.extract_infobox(soup)
    labels = [r["label"] for r in rows]
    assert "Born" in labels
    assert "Died" in labels
    assert "Nationality" in labels


def test_extract_infobox_removes_table_from_soup(processor):
    soup = BeautifulSoup(WIKI_INFOBOX_HTML, "html.parser")
    processor.extract_infobox(soup)
    # Table should be gone after extraction
    assert soup.find("table", class_="infobox") is None
    # The article paragraph should still be present
    assert "theoretical physicist" in soup.get_text()


def test_extract_infobox_empty_when_absent(processor):
    soup = BeautifulSoup(INFOBOX_FREE_HTML, "html.parser")
    assert processor.extract_infobox(soup) == []


def test_extract_infobox_capped_at_kv_limit():
    proc = ContentProcessor()
    rows_html = "".join(
        f"<tr><th>Field{i}</th><td>Value{i}</td></tr>" for i in range(50)
    )
    html = f'<table class="infobox">{rows_html}</table>'
    soup = BeautifulSoup(html, "html.parser")
    rows = proc.extract_infobox(soup)
    # Default INFOBOX_KV_LIMIT is 30
    assert len(rows) == 30
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_content_processor_infobox.py -v
```
Expected: `AttributeError: 'ContentProcessor' object has no attribute 'extract_infobox'`.

- [ ] **Step 3: Implement `extract_infobox`**

Add to `ContentProcessor` in `openzim_mcp/content_processor.py`:

```python
INFOBOX_SELECTORS = [
    "table.infobox",
    "table.vcard",
    ".infobox",
    ".vcard",
]


def extract_infobox(
    self, soup: BeautifulSoup, *, kv_limit: int = 30
) -> List[Dict[str, str]]:
    """Extract the first matching infobox from `soup` as a list of KV rows.

    Returns `[{"label": str, "value": str}]`. Mutates `soup` to remove the
    extracted infobox so it doesn't get pipe-soup'd by html2text downstream.
    Capped at `kv_limit` rows.
    """
    for selector in INFOBOX_SELECTORS:
        node = soup.select_one(selector)
        if node is None:
            continue
        rows: List[Dict[str, str]] = []
        for tr in node.select("tr"):
            th = tr.find("th")
            td = tr.find("td")
            if th and td:
                label = " ".join(th.get_text().split())
                value = " ".join(td.get_text().split())
                if label and value:
                    rows.append({"label": label, "value": value})
                    if len(rows) >= kv_limit:
                        break
        # Remove the extracted node so html2text doesn't see it
        node.decompose()
        return rows
    return []
```

Make sure `from typing import Dict, List` is in scope (likely already imported).

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_content_processor_infobox.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/content_processor.py tests/test_content_processor_infobox.py
git commit -m "feat(content): add extract_infobox for compact-mode KV extraction

Detects .infobox/.vcard/table.infobox containers and returns label/value
pairs. Mutates soup to remove the extracted node. Capped at INFOBOX_KV_LIMIT.
Phase A item #2."
```

---

## Task 14: Implement `replace_oversized_tables`

**Files:**
- Modify: `openzim_mcp/content_processor.py`
- Create: `tests/test_content_processor_tables.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_content_processor_tables.py`:

```python
"""Tests for replace_oversized_tables (Phase A item #2)."""

import pytest
from bs4 import BeautifulSoup

from openzim_mcp.content_processor import ContentProcessor


@pytest.fixture
def processor() -> ContentProcessor:
    return ContentProcessor()


def _table_html(rows: int, cols: int) -> str:
    body = "".join(
        "<tr>" + "".join(f"<td>cell</td>" for _ in range(cols)) + "</tr>"
        for _ in range(rows)
    )
    return f"<table>{body}</table>"


def test_small_table_preserved(processor):
    html = f"<div>{_table_html(rows=3, cols=2)}</div>"
    soup = BeautifulSoup(html, "html.parser")
    processor.replace_oversized_tables(soup, row_threshold=8, char_threshold=600)
    assert soup.find("table") is not None
    assert "Table:" not in soup.get_text()


def test_large_row_count_replaced(processor):
    html = f"<div>{_table_html(rows=20, cols=4)}</div>"
    soup = BeautifulSoup(html, "html.parser")
    processor.replace_oversized_tables(soup, row_threshold=8, char_threshold=600)
    assert soup.find("table") is None
    txt = soup.get_text()
    assert "Table 1:" in txt
    assert "20 rows" in txt
    assert "compact=False" in txt


def test_large_char_count_replaced(processor):
    # 5 rows × 5 cols × ~30 chars per cell ~> 750 chars
    html = (
        "<table>"
        + "".join("<tr>" + "<td>" + "padding-text-" * 3 + "</td>" * 5 + "</tr>"
                 for _ in range(5))
        + "</table>"
    )
    soup = BeautifulSoup(html, "html.parser")
    processor.replace_oversized_tables(soup, row_threshold=8, char_threshold=600)
    assert soup.find("table") is None


def test_multiple_tables_indexed_in_document_order(processor):
    html = (
        f"<div>{_table_html(rows=20, cols=2)}{_table_html(rows=3, cols=2)}"
        f"{_table_html(rows=15, cols=3)}</div>"
    )
    soup = BeautifulSoup(html, "html.parser")
    processor.replace_oversized_tables(soup, row_threshold=8, char_threshold=600)
    tables = soup.find_all("table")
    assert len(tables) == 1  # only the small one survives
    txt = soup.get_text()
    assert "Table 1:" in txt
    assert "Table 3:" in txt
    assert "Table 2:" not in txt
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_content_processor_tables.py -v
```
Expected: `AttributeError: 'ContentProcessor' object has no attribute 'replace_oversized_tables'`.

- [ ] **Step 3: Implement `replace_oversized_tables`**

Add to `ContentProcessor`:

```python
def replace_oversized_tables(
    self,
    soup: BeautifulSoup,
    *,
    row_threshold: int,
    char_threshold: int,
) -> None:
    """Replace oversized `<table>` elements with a placeholder paragraph.

    A table is "oversized" if `len(rows) > row_threshold` OR
    `len(text) > char_threshold`. Walks all tables in document order;
    each one is given a 1-based index for the placeholder text so callers
    can reference 'Table N' even when some tables are kept.

    Mutates `soup` in place.
    """
    for index, table in enumerate(soup.find_all("table"), start=1):
        rows = table.find_all("tr")
        text = table.get_text()
        if len(rows) <= row_threshold and len(text) <= char_threshold:
            continue
        cols = max(
            (len(row.find_all(["th", "td"])) for row in rows),
            default=0,
        )
        placeholder = soup.new_tag("p")
        placeholder.string = (
            f"[Table {index}: {len(rows)} rows × {cols} cols — "
            f"pass compact=False to expand]"
        )
        table.replace_with(placeholder)
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_content_processor_tables.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/content_processor.py tests/test_content_processor_tables.py
git commit -m "feat(content): replace oversized tables with placeholder in compact mode

Tables exceeding row or character threshold collapse to
'[Table N: rows × cols — pass compact=False to expand]'. Smaller tables
preserved. Document order indexing. Phase A item #2."
```

---

## Task 15: Wire infobox + table extraction into `html_to_plain_text`

**Files:**
- Modify: `openzim_mcp/content_processor.py` (`html_to_plain_text`, `html_to_plain_text_from_parsed`)
- Modify: `tests/test_content_processor.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_content_processor.py`:

```python
def test_html_to_plain_text_compact_extracts_infobox():
    proc = ContentProcessor()
    html = (
        '<div>'
        '<table class="infobox">'
        '<tr><th>Born</th><td>1879</td></tr>'
        '<tr><th>Died</th><td>1955</td></tr>'
        '</table>'
        '<p>Einstein was a physicist.</p>'
        '</div>'
    )
    out = proc.html_to_plain_text(html, compact=True)
    # KV list rendered first
    assert "Born" in out
    assert "1879" in out
    # Body still present
    assert "physicist" in out
    # No pipe-soup table
    assert "|" not in out


def test_html_to_plain_text_non_compact_preserves_table():
    proc = ContentProcessor()
    html = '<table class="infobox"><tr><th>K</th><td>V</td></tr></table><p>Body</p>'
    out = proc.html_to_plain_text(html, compact=False)
    # Either as pipe-table or original markdown — but the row data must be there
    assert "K" in out and "V" in out
    assert "Body" in out


def test_html_to_plain_text_compact_replaces_large_tables():
    proc = ContentProcessor()
    rows = "".join(
        "<tr><td>cell</td></tr>" for _ in range(20)
    )
    html = f"<div><table>{rows}</table><p>After table.</p></div>"
    out = proc.html_to_plain_text(html, compact=True)
    assert "Table 1:" in out
    assert "compact=False to expand" in out
    assert "After table." in out
```

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_content_processor.py -v -k compact
```
Expected: `TypeError: html_to_plain_text() got an unexpected keyword argument 'compact'` or assertion failures.

- [ ] **Step 3: Wire compact-mode extraction into `html_to_plain_text` paths**

In `openzim_mcp/content_processor.py`, modify `html_to_plain_text` and `html_to_plain_text_from_parsed` to accept `compact: bool = False`. Apply infobox + table extraction before `html2text`:

```python
def html_to_plain_text(
    self, html_content: str, *, compact: bool = False
) -> str:
    """Convert HTML to clean plain text.

    Args:
        html_content: HTML content to convert.
        compact: When True, extract infoboxes as KV lists and replace
            oversized tables with placeholders. Default False (legacy).
    """
    if not html_content:
        return ""

    try:
        soup = BeautifulSoup(html_content, HTML_PARSER)

        for selector in UNWANTED_HTML_SELECTORS:
            for element in soup.select(selector):
                element.decompose()

        infobox_md = ""
        if compact:
            kv_rows = self.extract_infobox(soup, kv_limit=self._infobox_kv_limit)
            if kv_rows:
                infobox_md = (
                    "\n".join(f"**{r['label']}:** {r['value']}" for r in kv_rows)
                    + "\n\n"
                )
            self.replace_oversized_tables(
                soup,
                row_threshold=self._table_row_threshold,
                char_threshold=self._table_char_threshold,
            )

        text = self._create_html_converter().handle(str(soup))
        text = re.sub(r"\n{3,}", "\n\n", text)
        return (infobox_md + text).strip()

    except Exception as e:
        logger.warning(f"Error converting HTML to text: {e}")
        soup = BeautifulSoup(html_content, HTML_PARSER)
        return str(soup.get_text().strip())
```

Apply the same edit to `html_to_plain_text_from_parsed`.

`ContentProcessor.__init__` must accept the three thresholds; if they aren't already passed in, plumb them through:

```python
def __init__(
    self,
    snippet_length: int = DEFAULT_SNIPPET_LENGTH,
    *,
    table_row_threshold: int = 8,
    table_char_threshold: int = 600,
    infobox_kv_limit: int = 30,
):
    self.snippet_length = snippet_length
    self._table_row_threshold = table_row_threshold
    self._table_char_threshold = table_char_threshold
    self._infobox_kv_limit = infobox_kv_limit
    # ... existing init ...
```

Update the construction site (`OpenZimMcpServer.__init__` or wherever `ContentProcessor()` is built) to pass these from `config.content`.

- [ ] **Step 4: Run the tests to confirm pass**

```bash
uv run pytest tests/test_content_processor.py -v
```
Expected: green; existing tests still pass since `compact` defaults to False.

- [ ] **Step 5: Identify all `ContentProcessor` construction sites and ensure compact wiring**

```bash
grep -rn "ContentProcessor(" --include="*.py" openzim_mcp/
```
Expected: a small number of construction sites. At each, ensure the compact flag flows from the surrounding `compact` context (it doesn't need to be passed to `__init__` — `compact` is a per-call argument to `html_to_plain_text`).

Then identify call sites of `html_to_plain_text` / `html_to_plain_text_from_parsed`:

```bash
grep -rn "html_to_plain_text" --include="*.py" openzim_mcp/
```

For call sites that already operate in compact mode (the simple-tools compact pipeline), pass `compact=True`. Trace the existing `compact` flag through `simple_tools.py` and any other path; ensure the flag reaches the HTML→text call.

- [ ] **Step 6: Run the full suite**

```bash
uv run pytest -x
```
Expected: green.

- [ ] **Step 7: Commit**

```bash
git add openzim_mcp/content_processor.py tests/test_content_processor.py openzim_mcp/
git commit -m "feat(content): wire infobox + table extraction into compact-mode HTML→text

When compact=True, infoboxes render as a markdown KV list above the body
and oversized tables collapse to placeholders. compact=False path is
byte-identical to v1.2.0. Phase A item #2 surface."
```

---

## Task 16: Make fuzzy score configurable and update score-as-multiplier semantics

**Files:**
- Modify: `openzim_mcp/zim/search.py:1304-1318`
- Modify: `tests/test_find_entry_by_title.py`, `tests/test_find_entry_by_title_quality.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_find_entry_by_title.py`:

```python
def test_fuzzy_match_score_uses_config_penalty(zim_operations):
    """A typo-corrected hit should score equal to FUZZY_TITLE_SCORE_PENALTY * 1.0."""
    payload = zim_operations.find_entry_by_title_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        title="Einstien",  # transposition typo for 'Einstein'
    )
    fuzzy_results = [
        r for r in payload["results"]
        if r.get("match_type") == "typo_corrected"
    ]
    assert fuzzy_results, "expected a typo-corrected result"
    # Default penalty is 0.85; allow any other configured value
    assert fuzzy_results[0]["score"] == zim_operations.config.search.fuzzy_title_score_penalty


def test_fuzzy_match_score_overridable(zim_operations_low_penalty):
    """Override FUZZY_TITLE_SCORE_PENALTY → 0.5 should produce score=0.5."""
    payload = zim_operations_low_penalty.find_entry_by_title_data(
        zim_file_path=str(zim_operations_low_penalty.fixture_zim_path),
        title="Einstien",
    )
    fuzzy = [r for r in payload["results"] if r.get("match_type") == "typo_corrected"]
    assert fuzzy and fuzzy[0]["score"] == 0.5
```

The `zim_operations_low_penalty` fixture should construct a `OpenZimMcpConfig` with `search=SearchConfig(fuzzy_title_score_penalty=0.5)`.

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_find_entry_by_title.py -v -k fuzzy
```
Expected: fail (current code hardcodes 0.7).

- [ ] **Step 3: Replace hardcoded score**

In `openzim_mcp/zim/search.py:1314`, change:

```python
"score": 0.7,
```
to:

```python
"score": self.config.search.fuzzy_title_score_penalty,
```

- [ ] **Step 4: Update old tests that asserted 0.7**

Find:
```bash
grep -nE 'score.*0\.7|0\.7.*score' tests/test_find_entry_by_title*.py
```
Update the asserted value to `0.85` (the new default), or compare to `config.search.fuzzy_title_score_penalty` for robustness.

- [ ] **Step 5: Run the tests**

```bash
uv run pytest tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py -v
```
Expected: green.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py
git commit -m "feat(search): make fuzzy match score configurable (default 0.85, was 0.7)

OPENZIM_MCP_SEARCH__FUZZY_TITLE_SCORE_PENALTY overrides the default.
Phase A item #14."
```

---

## Task 17: Make fuzzy length-gate configurable

**Files:**
- Modify: `openzim_mcp/zim/search.py:1163` (`_find_entry_typo_fallback`)
- Modify: `tests/test_find_entry_by_title.py`

- [ ] **Step 1: Write the failing test**

Append:

```python
def test_fuzzy_min_query_len_gates_short_queries(zim_operations_min_len_6):
    """When fuzzy_title_min_query_len=6, a 5-char typo should not trigger fuzzy."""
    payload = zim_operations_min_len_6.find_entry_by_title_data(
        zim_file_path=str(zim_operations_min_len_6.fixture_zim_path),
        title="Quanta",  # 6 chars but assume archive doesn't have it; tweak per fixture
    )
    fuzzy = [r for r in payload["results"] if r.get("match_type") == "typo_corrected"]
    # If no real article matches "Quanta" exactly, then with min_len=6 we still
    # try fuzzy (6 ≥ 6); this test asserts that with min_len=10 we'd skip.
    # Adjust title to a 5-char input to exercise the gate definitively:
    payload5 = zim_operations_min_len_6.find_entry_by_title_data(
        zim_file_path=str(zim_operations_min_len_6.fixture_zim_path),
        title="Einst",  # 5 chars, below 6
    )
    assert all(r.get("match_type") != "typo_corrected" for r in payload5["results"])
```

The `zim_operations_min_len_6` fixture sets `search=SearchConfig(fuzzy_title_min_query_len=6)`.

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_find_entry_by_title.py::test_fuzzy_min_query_len_gates_short_queries -v
```
Expected: fail.

- [ ] **Step 3: Convert `_find_entry_typo_fallback` from classmethod to instance**

In `openzim_mcp/zim/search.py:1150-1169`, change the signature from `@classmethod` to instance method, accept the gate from `self.config.search`:

```python
def _find_entry_typo_fallback(self, archive: Any, title: str) -> Optional[Any]:
    """Try typo-corrected variants of `title` against the fast path."""
    if len(title) < self.config.search.fuzzy_title_min_query_len:
        return None
    for variant in self._typo_variants(title):
        entry = self._find_entry_fast_path(archive, variant)
        if entry is not None:
            return entry
    return None
```

Update the call site at line 1304 to use `self._find_entry_typo_fallback(...)` instead of `cls._find_entry_typo_fallback(...)` (it's already `self.` per the existing code — confirm and adjust).

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_find_entry_by_title.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_find_entry_by_title.py
git commit -m "feat(search): make fuzzy length-gate configurable (default 4)

OPENZIM_MCP_SEARCH__FUZZY_TITLE_MIN_QUERY_LEN overrides the default.
Phase A item #14."
```

---

## Task 18: Update fuzzy trigger from "no results" to "no result ≥ 0.7"

**Files:**
- Modify: `openzim_mcp/zim/search.py:1300-1304`
- Modify: `tests/test_find_entry_by_title_quality.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/test_find_entry_by_title_quality.py`:

```python
def test_fuzzy_runs_even_when_low_score_results_exist(zim_operations):
    """If SuggestionSearcher returns only sub-0.7 results, fuzzy should still run."""
    # Find a query where SuggestionSearcher returns weak partial matches but
    # an exact case-variant of the typo'd query maps to a real article.
    # Example: 'Climate Channge' -> SuggestionSearcher may return 'Climate'
    # at score 0.5, but the typo fallback can find 'Climate change'.
    payload = zim_operations.find_entry_by_title_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        title="Climate Channge",
    )
    # Either we get the typo_corrected hit OR the SuggestionSearcher hit;
    # the new behavior is that fuzzy runs and gets a chance to add its own
    # candidate even if SuggestionSearcher returned something weak.
    has_fuzzy = any(
        r.get("match_type") == "typo_corrected" for r in payload["results"]
    )
    has_strong = any(r.get("score", 0) >= 0.7 for r in payload["results"])
    assert has_fuzzy or has_strong
```

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_find_entry_by_title_quality.py::test_fuzzy_runs_even_when_low_score_results_exist -v
```
Expected: passes today only if a strong result exists; the test is a guard against regression.

- [ ] **Step 3: Update the trigger condition**

At `openzim_mcp/zim/search.py:1300`, change:

```python
if (
    not fast_path_hit
    and len(aggregate_results) == pre_suggestion_count
):
```

to:

```python
already_has_strong = any(
    r.get("score", 0.0) >= 0.7 for r in aggregate_results
)
if not fast_path_hit and not already_has_strong:
```

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_find_entry_by_title_quality.py
git commit -m "feat(search): trigger fuzzy fallback whenever no result ≥ 0.7 exists

Previously fuzzy only ran when SuggestionSearcher returned zero hits.
Now it also runs when SuggestionSearcher returned only weak (< 0.7)
matches. Phase A item #14."
```

---

## Task 19: Surface typo candidates in `_meta.suggestions[]`

**Files:**
- Modify: `openzim_mcp/zim/search.py` (`find_entry_by_title_data`, `search_zim_file_data`, `get_search_suggestions_data`)
- Modify: `openzim_mcp/meta.py` (no change — already accepts `suggestions` kwarg)
- Modify: `tests/test_find_entry_by_title.py`

- [ ] **Step 1: Write the failing test**

Append:

```python
def test_fuzzy_candidates_appear_in_meta_suggestions(zim_operations):
    payload = zim_operations.find_entry_by_title_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        title="Einstien",
    )
    suggestions = payload.get("_meta", {}).get("suggestions", [])
    spelling_alts = [s for s in suggestions if s.get("type") == "alt_spelling"]
    assert spelling_alts, "expected typo variants to surface as alt_spelling suggestions"


def test_empty_search_emits_meta_suggestions_and_reason(zim_operations):
    payload = zim_operations.search_zim_file_data(
        zim_file_path=str(zim_operations.fixture_zim_path),
        query="zzzimpossiblequery",
    )
    assert payload["_meta"].get("reason") == "0_hits"
    # Suggestions may be empty if no candidates exist; just assert the field
    # is present (or absent), not its content
```

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_find_entry_by_title.py -v -k suggestions
```
Expected: fail.

- [ ] **Step 3: Build suggestions list and pass into `attach_meta`**

In `find_entry_by_title_data` (`openzim_mcp/zim/search.py`), after computing `aggregate_results` and before sorting/return, build a suggestions list:

```python
# Build _meta.suggestions[] from the typo variants we generated
suggestions: List[Dict[str, str]] = []
limit_n = self.config.search.structured_suggestions_limit
if not fast_path_hit and len(title) >= self.config.search.fuzzy_title_min_query_len:
    for variant in self._typo_variants(title):
        suggestions.append({"type": "alt_spelling", "value": variant})
        if len(suggestions) >= limit_n:
            break
```

After sorting `aggregate_results`, build payload and call `attach_meta`:

```python
payload = {
    "query": title,
    "results": aggregate_results[:limit],
    "fast_path_hit": fast_path_hit,
    "fuzzy_path_hit": fuzzy_path_hit,
    "files_searched": len(files),
}
reason = None if aggregate_results else "0_hits"
return attach_meta(
    payload,
    suggestions=suggestions,
    reason=reason,
)
```

In `search_zim_file_data` (`openzim_mcp/zim/search.py`), apply the same pattern in the empty-results branch (around line 270): pass `reason="0_hits"` and a `suggestions` list (Phase A leaves the list empty for content-search empty results since cross-archive lookups land in the next task; the field can be absent).

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_find_entry_by_title.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_find_entry_by_title.py
git commit -m "feat(search): surface typo candidates in _meta.suggestions

find_entry_by_title now emits alt_spelling suggestions derived from the
typo variants. search_zim_file emits reason=0_hits on empty results.
Phase A items #4 and #14 cross-cut."
```

---

## Task 20: Add cross-archive `alt_archive` suggestions on empty results

**Files:**
- Modify: `openzim_mcp/zim/search.py` (`search_zim_file_data` empty path)
- Modify: `tests/test_search_tools.py`

- [ ] **Step 1: Write the failing test**

```python
def test_empty_search_suggests_other_archives_with_query_match(zim_operations_multi):
    """When query yields zero results in archive A but matches archive B's name, suggest B."""
    # Setup: zim_operations_multi has two archives; archive 'wikipedia_en_all'
    # contains 'Photosynthesis', archive 'wiktionary_en' does not.
    payload = zim_operations_multi.search_zim_file_data(
        zim_file_path=str(zim_operations_multi.wiktionary_path),
        query="Photosynthesis",
    )
    suggestions = payload["_meta"].get("suggestions", [])
    archive_alts = [s for s in suggestions if s.get("type") == "alt_archive"]
    assert any("wikipedia" in a["value"].lower() for a in archive_alts)
```

(Adjust fixture names to match what already exists in `tests/conftest.py`.)

- [ ] **Step 2: Run the test to confirm failure**

```bash
uv run pytest tests/test_search_tools.py -v -k alt_archive
```
Expected: fail.

- [ ] **Step 3: Add cross-archive name match in empty path**

In the empty-results branch of `search_zim_file_data`, add:

```python
# Cross-archive name match — cheap basename substring filter
suggestions: List[Dict[str, str]] = []
limit_n = self.config.search.structured_suggestions_limit
try:
    files = self.list_zim_files_data()
    q_lower = query.lower()
    for f in files:
        if str(f.get("path", "")) == validated_path:
            continue  # the current archive
        name = Path(f.get("path", "")).stem  # e.g., 'wikipedia_en_all'
        if any(tok in name.lower() for tok in q_lower.split() if len(tok) >= 4):
            suggestions.append({"type": "alt_archive", "value": name})
            if len(suggestions) >= limit_n:
                break
except Exception as e:
    logger.debug(f"alt_archive suggestion build failed: {e}")
```

Pass into `attach_meta(payload, suggestions=suggestions, reason="0_hits")`.

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_search_tools.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/search.py tests/test_search_tools.py
git commit -m "feat(search): suggest other ZIM archives whose names match the empty query

Phase A item #4 (cross-archive alt_archive suggestion source)."
```

---

## Task 21: Replace empty-result prose with footer-driven recovery in compact mode

**Files:**
- Modify: `openzim_mcp/simple_tools.py` (compact-mode empty branch)
- Modify: `openzim_mcp/zim/search.py:352-368` (only the path used when caller is `compact=True` — see implementation note)
- Modify: `tests/test_simple_tools.py`

- [ ] **Step 1: Write the failing test**

```python
def test_compact_empty_search_uses_footer_not_legacy_prose(simple_tool_runner):
    out = simple_tool_runner.zim_query(
        "search for zzzimpossiblequery", options={"compact": True}
    )
    # Legacy prose markers absent
    assert "**Try one of these:**" not in out
    # Footer present
    assert out.rstrip().splitlines()[-1].startswith("> No results.")


def test_non_compact_empty_search_keeps_legacy_prose(simple_tool_runner):
    out = simple_tool_runner.zim_query(
        "search for zzzimpossiblequery", options={"compact": False}
    )
    # Legacy prose preserved in verbose mode
    assert "Try one of these:" in out or "suggestions for" in out.lower()
```

- [ ] **Step 2: Run the tests to confirm failure**

```bash
uv run pytest tests/test_simple_tools.py -v -k empty
```
Expected: fail.

- [ ] **Step 3: Branch the empty-result rendering on compact mode**

In `simple_tools.py`, the compact pipeline calls into `_format_search_text` indirectly. The cleanest place to branch is in `simple_tools.py` itself — if the underlying search returned zero results, replace the legacy prose body with a minimal one-liner and rely on the footer (added in Task 10) to carry the suggestions.

Concretely: after the search call, inspect `payload["_meta"]`; if `payload["_meta"].get("reason") == "0_hits"` AND we're in compact mode, render only:

```python
body = f'No results for "{query}".'
return self._wrap_compact_with_footer(
    body,
    suggestions=payload["_meta"].get("suggestions", []),
    reason="0_hits",
)
```

In `compact=False` mode, fall through to the existing `_format_search_text` (which produces the legacy prose).

- [ ] **Step 4: Run the tests**

```bash
uv run pytest tests/test_simple_tools.py -v
```
Expected: green.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/simple_tools.py tests/test_simple_tools.py
git commit -m "feat(simple): compact-mode empty results render via footer, not legacy prose

compact=False keeps the existing prose hints byte-identical.
Phase A item #4 surface."
```

---

## Task 22: Update README.md

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Add a v2 Phase A section**

Find the appropriate v1.x feature section in `README.md`. Add a new top-level section after it (or replace the relevant subsections):

````markdown
## Response metadata (`_meta` envelope)

Every dict-returning tool now includes a `_meta` key:

```json
{
  "tokens_est": 4283,
  "chars": 17034,
  "truncated": true,
  "more_at_offset": 17000,
  "total_chars": 87421,
  "suggestions": [
    {"type": "alt_spelling", "value": "Photosynthesis"}
  ],
  "reason": "0_hits"
}
```

`tokens_est` is a tiktoken `cl100k_base` estimate plus a 5% pad. Use it
for context budgeting; it's accurate to ±10% across common tokenizers.
`suggestions` and `reason` are present only on empty / low-confidence
results.

## Compact-mode prose footer

Simple-mode responses end with a single-line markdown blockquote:

```
> ~4.2K tokens · 17K of 87K chars · pass `offset=17000` for more
```

Empty results render the suggestions inline:

```
> No results. Try: `suggestions for Photosynthesis` · `search photosynthesis chlorophyll` · or try ZIM `wikipedia_en_all`
```

Set `OPENZIM_MCP_META__FOOTER_ENABLED=false` to suppress.

## Compact-mode infobox & table handling

In `compact=True` (the simple-mode default):

- Wikipedia-style `.infobox` / `.vcard` tables become a Markdown KV list
  prepended to the body. Capped at 30 rows; configurable via
  `OPENZIM_MCP_CONTENT__INFOBOX_KV_LIMIT`.
- Tables with more than 8 rows or 600 characters of text become a
  placeholder: `[Table N: 47 rows × 6 cols — pass compact=False to expand]`.
  Thresholds configurable via `OPENZIM_MCP_CONTENT__TABLE_ROW_THRESHOLD`
  and `OPENZIM_MCP_CONTENT__TABLE_CHAR_THRESHOLD`.

`compact=False` retains v1.2.0 byte-identical behavior.

## Typo-tolerant title lookup

`find_entry_by_title` falls back to single-edit variants (transposition,
single-character deletion) when neither direct path lookup nor the libzim
suggestion index returns a high-confidence match. Fuzzy-corrected hits
score `0.85` (configurable via `OPENZIM_MCP_SEARCH__FUZZY_TITLE_SCORE_PENALTY`)
to ensure exact matches always rank higher. The minimum query length to
trigger the fallback is 4 characters
(`OPENZIM_MCP_SEARCH__FUZZY_TITLE_MIN_QUERY_LEN`).
````

- [ ] **Step 2: Add a quick-reference env var table**

Append a table of new env vars to the existing config section:

```markdown
| Env var | Default | Purpose |
|---|---|---|
| `OPENZIM_MCP_META__FOOTER_ENABLED` | `true` | Append prose footer in compact mode |
| `OPENZIM_MCP_META__TOKENIZER_ENCODING` | `cl100k_base` | tiktoken encoding for `tokens_est` |
| `OPENZIM_MCP_SEARCH__STRUCTURED_SUGGESTIONS_LIMIT` | `5` | Cap on `_meta.suggestions[]` length |
| `OPENZIM_MCP_SEARCH__FUZZY_TITLE_MIN_QUERY_LEN` | `4` | Minimum query length for fuzzy fallback |
| `OPENZIM_MCP_SEARCH__FUZZY_TITLE_SCORE_PENALTY` | `0.85` | Score multiplier for fuzzy hits |
| `OPENZIM_MCP_CONTENT__TABLE_ROW_THRESHOLD` | `8` | Replace tables with more rows in compact mode |
| `OPENZIM_MCP_CONTENT__TABLE_CHAR_THRESHOLD` | `600` | Replace tables with more chars in compact mode |
| `OPENZIM_MCP_CONTENT__INFOBOX_KV_LIMIT` | `30` | Cap on infobox rows extracted |
```

- [ ] **Step 3: Commit**

```bash
git add README.md
git commit -m "docs(v2): document Phase A response metadata, footer, infobox/table, fuzzy"
```

---

## Task 23: Add CHANGELOG entry

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Add a `## [2.0.0a1]` section at the top of the changelog**

After the header (and any unreleased section), insert:

```markdown
## [2.0.0a1] — 2026-MM-DD

> First v2 pre-release. Phase A of the multi-phase v2 effort
> ([tracking doc](docs/v2/README.md)). All changes additive at the
> tool-signature layer; small compact-mode prose change for empty
> search results (see #4 below).

### Added

* **meta:** every dict-returning tool now includes a `_meta` envelope
  (`tokens_est`, `chars`, `truncated`, `more_at_offset`, `total_chars`,
  `suggestions`, `reason`). `tokens_est` uses tiktoken `cl100k_base`
  with a 5% pad. (#5)
* **simple:** compact-mode responses gain a one-line markdown blockquote
  footer (`> ~4.2K tokens · ...`). Set `OPENZIM_MCP_META__FOOTER_ENABLED=false`
  to suppress. (#5)
* **content:** in compact mode, `.infobox` / `.vcard` tables emit a
  Markdown KV list prepended to the body. (#2)
* **content:** in compact mode, tables exceeding row or character
  thresholds are replaced with `[Table N: ...]` placeholders. (#2)
* **search:** every search response is query-aware — snippets contain the
  actual matched passage (with `**bold**` highlights, capped at 5 hits)
  rather than the article lead. (#1)
* **search:** `_meta.suggestions[]` surfaces typo variants and other-archive
  candidates for empty / low-confidence searches. (#4)
* **search:** `find_entry_by_title` fuzzy fallback now triggers whenever
  no result clears 0.7 (previously only on zero hits). Score and
  length-gate are configurable via `OPENZIM_MCP_SEARCH__FUZZY_TITLE_*`. (#14)

### Changed

* **simple:** compact-mode empty-result prose now renders via the new
  footer + structured suggestions instead of the v1.2.0 paragraph. The
  information is one-for-one; the format is more model-readable.
  `compact=False` paths retain byte-identical v1.2.0 behavior. (#4)
* **search:** `find_entry_by_title` typo-corrected hits now score `0.85`
  (was hardcoded `0.7`) by default. (#14)

### Dependencies

* Added `tiktoken>=0.7.0` to core dependencies.
```

- [ ] **Step 2: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs: add v2.0.0a1 changelog entry"
```

---

## Task 24: Capture golden files for regression testing

**Files:**
- Create: `tests/golden/v2_phase_a_compact.txt` (5 fixture-article snapshots)
- Create: `tests/golden/v2_phase_a_verbose.txt` (the same 5 articles in `compact=False`)
- Create: `tests/test_golden_v2_phase_a.py`

- [ ] **Step 1: Identify the fixture-archive setup pattern**

```bash
grep -n "fixture\|fixture_zim\|temp_zim" tests/conftest.py
ls tests/live/conftest.py tests/conftest.py
```

The repo doesn't ship `.zim` test files in `tests/`. Live tests in `tests/live/` use real archives via env-var pointers; unit tests use temp dirs and synthesized `OpenZimMcpConfig`. For the golden suite, use the same pattern that existing `test_search_tools.py` and `test_content_tools.py` use to construct/access archives — read those files first to discover the fixture name, and follow the same convention.

If no synthesized-content fixture archive exists, add a minimal one via `libzim.writer.Creator` in a new `tests/conftest_v2_fixtures.py`:

```python
"""V2 golden-file fixture archive builder."""

import pytest
from pathlib import Path
from libzim.writer import Creator, Item, ContentProvider, StringProvider, Hint


@pytest.fixture(scope="session")
def golden_zim_path(tmp_path_factory) -> Path:
    """Build a tiny ZIM with the five articles needed for golden snapshots."""
    out = tmp_path_factory.mktemp("v2-golden") / "golden.zim"

    class _Item(Item):
        def __init__(self, path: str, title: str, html: str):
            super().__init__()
            self._path, self._title, self._html = path, title, html

        def get_path(self): return self._path
        def get_title(self): return self._title
        def get_mimetype(self): return "text/html"
        def get_contentprovider(self): return StringProvider(self._html)
        def get_hints(self): return {Hint.FRONT_ARTICLE: 1}

    fixtures = [
        ("Einstein",
         "<table class='infobox'><tr><th>Born</th><td>1879</td></tr></table>"
         "<p>Einstein was a physicist.</p>"
         "<table>" + "<tr><td>x</td></tr>" * 20 + "</table>"),
        ("WikDog",
         "<p><b>dog</b> /dɒɡ/ <i>noun</i> A domesticated mammal.</p>"),
        ("PlainArticle",
         "<p>This article has no infobox and no large tables.</p>"),
        ("MultiTable",
         "<p>Intro.</p>" + "<table>" + "<tr><td>x</td></tr>" * 20 + "</table>"
         "<p>Middle.</p>" + "<table>" + "<tr><td>y</td></tr>" * 20 + "</table>"),
        ("LongArticle",
         "<p>" + ("Word " * 5000) + "</p>"),
    ]
    with Creator(out).config_indexing(True, "eng") as creator:
        for path, html in fixtures:
            creator.add_item(_Item(f"A/{path}", path, html))
        creator.set_mainpath("A/Einstein")
    return out
```

- [ ] **Step 2: Write the snapshot test**

Create `tests/test_golden_v2_phase_a.py`:

```python
"""Phase A golden-file regression test."""

from pathlib import Path

import pytest

GOLDEN = Path(__file__).parent / "golden"

ARTICLES = [
    ("A/Einstein", True, "compact_einstein.txt"),
    ("A/Einstein", False, "verbose_einstein.txt"),
    ("A/WikDog", True, "compact_wikdog.txt"),
    ("A/WikDog", False, "verbose_wikdog.txt"),
    ("A/PlainArticle", True, "compact_plain.txt"),
    ("A/PlainArticle", False, "verbose_plain.txt"),
    ("A/MultiTable", True, "compact_multitable.txt"),
    ("A/MultiTable", False, "verbose_multitable.txt"),
    ("A/LongArticle", True, "compact_long.txt"),
    ("A/LongArticle", False, "verbose_long.txt"),
]


@pytest.mark.parametrize("article,compact,golden_file", ARTICLES)
def test_golden_unchanged(golden_zim_path, simple_tool_runner_factory,
                          article, compact, golden_file):
    runner = simple_tool_runner_factory(zim_path=golden_zim_path)
    out = runner.zim_query(
        f"tell me about {article}", options={"compact": compact}
    )
    expected = (GOLDEN / golden_file).read_text()
    assert out == expected, (
        f"golden mismatch for {article} (compact={compact}); "
        f"if intentional, delete {golden_file} and re-run to regenerate."
    )
```

- [ ] **Step 3: Generate the golden files**

Create `scripts/regenerate_golden.py`:

```python
"""Regenerate v2 Phase A golden files. Run after intentional output changes."""

import pathlib
import sys

# Match the imports the test uses
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from tests.conftest_v2_fixtures import golden_zim_path  # noqa: E402
from tests.conftest import simple_tool_runner_factory  # noqa: E402

ARTICLES = [
    ("A/Einstein", True, "compact_einstein.txt"),
    ("A/Einstein", False, "verbose_einstein.txt"),
    ("A/WikDog", True, "compact_wikdog.txt"),
    ("A/WikDog", False, "verbose_wikdog.txt"),
    ("A/PlainArticle", True, "compact_plain.txt"),
    ("A/PlainArticle", False, "verbose_plain.txt"),
    ("A/MultiTable", True, "compact_multitable.txt"),
    ("A/MultiTable", False, "verbose_multitable.txt"),
    ("A/LongArticle", True, "compact_long.txt"),
    ("A/LongArticle", False, "verbose_long.txt"),
]

GOLDEN = pathlib.Path(__file__).parent.parent / "tests" / "golden"
GOLDEN.mkdir(exist_ok=True)


def main():
    # Use a real session-scoped archive path
    runner = simple_tool_runner_factory()  # adapt arg list to actual signature
    for article, compact, filename in ARTICLES:
        body = runner.zim_query(
            f"tell me about {article}", options={"compact": compact}
        )
        (GOLDEN / filename).write_text(body)
        print(f"wrote {filename}: {len(body)} chars")


if __name__ == "__main__":
    main()
```

Run it once:

```bash
uv run python scripts/regenerate_golden.py
ls tests/golden/
```
Expected: 10 `.txt` files.

- [ ] **Step 4: Verify verbose-mode files are byte-identical to v1.2.0**

```bash
# Stash Phase A changes (keep the new fixtures + script)
git stash --keep-index --include-untracked -- openzim_mcp/

# Run the regenerator against v1.2.0 code
uv run python scripts/regenerate_golden.py

# Save the verbose outputs
mkdir -p /tmp/v1_verbose
cp tests/golden/verbose_*.txt /tmp/v1_verbose/

# Restore Phase A changes
git stash pop

# Regenerate against Phase A code
uv run python scripts/regenerate_golden.py

# Diff verbose files only
for f in tests/golden/verbose_*.txt; do
    diff "$f" "/tmp/v1_verbose/$(basename $f)" || echo "DIFF in $(basename $f)"
done
```
Expected: zero diff output. If any verbose file changed, the Phase A change has accidentally affected `compact=False` — fix the regression in the implementation, not the golden file.

- [ ] **Step 5: Run the golden suite**

```bash
uv run pytest tests/test_golden_v2_phase_a.py -v
```
Expected: green.

- [ ] **Step 6: Commit**

```bash
git add tests/golden/ tests/test_golden_v2_phase_a.py
git commit -m "test: capture v2 Phase A golden files for regression

5 fixture articles × 2 modes (compact / verbose). Verbose mode is
byte-identical to v1.2.0 — guards Phase A's backward-compat promise."
```

---

## Task 25: Final integration — run full test suite and check budgets

**Files:**
- Run: `uv run pytest -x`
- Run: `uv run pytest tests/ -k 'snippet or fuzzy or meta or footer or infobox or table' --durations=20`

- [ ] **Step 1: Run the full test suite**

```bash
uv run pytest -x --timeout=60
```
Expected: all green. If any failure traces to a Phase A change, fix in place. If any failure is pre-existing flaky behavior, document in PR.

- [ ] **Step 2: Spot-check performance budgets**

```bash
uv run pytest --durations=20 -k 'fuzzy or snippet or meta'
```
Expected:
- Fuzzy fallback tests complete in < 1s each (cold path includes archive open + variant probes)
- `tokens_est` tests complete in < 0.5s total
- Infobox / table extraction tests complete in < 100ms each

If any test exceeds 2× the budget noted in the spec (Snippet ≤ 5ms; fuzzy ≤ 30ms; etc), profile and optimize before proceeding.

- [ ] **Step 3: Run the full coverage report**

```bash
uv run pytest --cov=openzim_mcp --cov-report=term-missing | tail -30
```
Confirm: `meta.py`, the new helpers in `content_processor.py`, and the modified branches in `search.py` all show ≥ 90% line coverage. Add tests if any branch is uncovered.

- [ ] **Step 4: Commit any final test additions**

```bash
git add tests/
git commit -m "test: bring meta.py and Phase A branches to ≥90% coverage"
```

(Skip if no changes.)

---

## Task 26: Open the v2-phase-a → main PR

**Files:**
- N/A (PR creation)

- [ ] **Step 1: Push the branch**

```bash
git push -u origin v2-phase-a
```

- [ ] **Step 2: Open the PR via GitHub**

Title: `feat(v2): Phase A — quality wins (#1, #2, #4, #5, #14)`

Description:
```markdown
First v2 pre-release. Phase A of the multi-phase v2 effort tracked at
[`docs/v2/README.md`](docs/v2/README.md).

Spec: [`docs/superpowers/specs/2026-05-08-v2-phase-a-quality-wins-design.md`](docs/superpowers/specs/2026-05-08-v2-phase-a-quality-wins-design.md)

Implementation plan: [`docs/superpowers/plans/2026-05-08-v2-phase-a.md`](docs/superpowers/plans/2026-05-08-v2-phase-a.md)

### What changes

- **#5** `_meta` envelope on every dict return; prose footer in compact mode.
- **#1** Query-aware search snippets with `**bold**` highlights.
- **#2** Compact mode extracts infoboxes as KV lists; oversized tables collapse to placeholders.
- **#4** Structured `_meta.suggestions[]` (typed: alt_spelling, alt_archive, broader/narrower stub).
- **#14** Configurable fuzzy title score and length-gate; broader trigger.

### What does NOT change

- No tool signatures changed; no tool removed.
- `compact=False` paths byte-identical to v1.2.0 (verified by golden files in `tests/golden/`).

### Tag intent

`v2.0.0a1` after merge.
```

- [ ] **Step 3: After approval & merge, tag the pre-release**

```bash
git checkout main
git pull
git tag v2.0.0a1
git push origin v2.0.0a1
```

- [ ] **Step 4: Update the v2 tracking doc to mark Phase A shipped**

In `docs/v2/README.md`, update the Phase A row's status from "In Design (approved)" to "Shipped (v2.0.0a1)".

```bash
git checkout -b chore/v2-phase-a-shipped
# edit docs/v2/README.md
git add docs/v2/README.md
git commit -m "chore(v2): mark Phase A shipped at v2.0.0a1"
git push -u origin chore/v2-phase-a-shipped
# open a small PR; merge
```

---

## Summary

26 tasks. Order:

1. Foundation (1-3): tiktoken dep, defaults, config models
2. Item #5 plumbing (4-10): `meta.py` module → `_meta` on every dict return → footer in compact mode
3. Item #1 (11-12): query-aware `create_snippet` → thread query through search
4. Item #2 (13-15): `extract_infobox`, `replace_oversized_tables`, wire into compact-mode HTML→text
5. Item #14 (16-18): configurable score, configurable length-gate, broader trigger
6. Item #4 (19-21): suggestions in `_meta`, cross-archive alts, footer-driven empty-result prose
7. Documentation (22-23): README, CHANGELOG
8. Validation & release (24-26): golden files, full suite, PR + tag

When all 26 are merged, tag `v2.0.0a1`. v1.x receives no backports.
