Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ First 0.8.1 beta. Rolls up everything through 0.8.0 (below) plus the features, f

### Enhancements

- **Recover the offline speech engine with one click.** The private, on-device speech engine (whisper.cpp) ships in the installer, but if it is ever missing — for example on an older install — **Tools > Speech > Download Offline Speech Engine...** now fetches QUILL's own checksum-verified copy (about 8 MB, cancelable progress; disabled in Safe Mode) and sets it up, so dictation and offline transcription work without re-running the installer. (First step of the AI footprint/optimization plan: a pinned, SHA-256-verified release-asset acquisition path.)
- **Hear how deep your indentation is.** Press **Tab** or **Shift+Tab** and QUILL can now speak the new indentation depth — "4 spaces", "8 spaces", "1 tab" — instead of just "Indented lines", so you always know where the line sits. It honours your tabs-vs-spaces and width choices (**Insert tab characters instead of spaces** and **Number of spaces per indent level** in Settings). Prefer the terse message? Turn off **Announce indentation depth on Tab** (Settings, Accessibility).
- **Quieter dialogs, your choice.** A new **Announce entering and leaving dialogs** setting (Settings, Accessibility) turns off the spoken "Entered / Exited *name* dialog" cues for people whose screen reader already announces dialogs. On by default.
- **Jump straight to an open document.** With several documents open, press **Alt+1** through **Alt+9** (and **Alt+0** for the tenth) to go directly to that document by its position, instead of cycling with Ctrl+Tab. If nothing is open at that position, QUILL tells you and stays put. The bindings are remappable in the Keymap Editor.
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -3822,6 +3822,23 @@ <h3 id="525c-offline-speech-and-transcription">5.25c Offline speech and
Whisperer suite (locked off, <code>core.bw_whisperer</code>) is captured
in <code>docs/planning/deferred-locked-features.md</code>; this section
describes only what ships today.</p>
<p><strong>On-demand engine recovery (release-asset
acquisition).</strong> The whisper.cpp engine ships in the installer, so
offline speech works out of the box. As a recovery/optional path — and
the first concrete step of the AI footprint/optimization plan
(<code>docs/planning/QUILL-AI-Optimization-PRD.md</code> §10.2.4) —
<code>Tools -&gt; Speech -&gt; Download Offline Speech Engine...</code>
fetches QUILL's own copy of the engine when it is missing. Acquisition
is <code>quill/core/release_assets.py</code> (wx-free): a
<strong>pinned</strong>, <strong>SHA-256-verified</strong> download from
QUILL's controlled GitHub release asset (<code>assets-v1</code>),
HTTPS-only, with retry/resumable download, atomic verified install, and
a clean error on failure. It is gated by an explicit user action, the
GATE-9 network-egress audit, and Safe Mode; the bundled copy means
capability never depends on the download. Only components QUILL is
licensed to redistribute are hosted this way (whisper.cpp is MIT);
license-unclear engines are not re-hosted, and ffmpeg is never re-hosted
(it stays user-installed).</p>
<p><strong>Cloud providers ship as Quillins, not core.</strong> Per the
consolidation plan (#669), the cloud provider matrix is delivered as
extensions rather than baked into core. A Quillin declares a provider
Expand Down
13 changes: 13 additions & 0 deletions docs/Product Requirement Documents and Specifications/QUILL-PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,19 @@ and runs entirely on the user's machine. The detailed rollout/provider-center su
the deferred BITS Whisperer suite (locked off, `core.bw_whisperer`) is captured in
`docs/planning/deferred-locked-features.md`; this section describes only what ships today.

**On-demand engine recovery (release-asset acquisition).** The whisper.cpp engine ships
in the installer, so offline speech works out of the box. As a recovery/optional path —
and the first concrete step of the AI footprint/optimization plan
(`docs/planning/QUILL-AI-Optimization-PRD.md` §10.2.4) — `Tools -> Speech -> Download
Offline Speech Engine...` fetches QUILL's own copy of the engine when it is missing.
Acquisition is `quill/core/release_assets.py` (wx-free): a **pinned**, **SHA-256-verified**
download from QUILL's controlled GitHub release asset (`assets-v1`), HTTPS-only, with
retry/resumable download, atomic verified install, and a clean error on failure. It is
gated by an explicit user action, the GATE-9 network-egress audit, and Safe Mode; the
bundled copy means capability never depends on the download. Only components QUILL is
licensed to redistribute are hosted this way (whisper.cpp is MIT); license-unclear
engines are not re-hosted, and ffmpeg is never re-hosted (it stays user-installed).

**Cloud providers ship as Quillins, not core.** Per the consolidation plan (#669), the cloud
provider matrix is delivered as extensions rather than baked into core. A Quillin declares a
provider through the **`transcription_providers`** manifest contribution; QUILL's host implements
Expand Down
Binary file modified docs/planning/roadmap.epub
Binary file not shown.
11 changes: 11 additions & 0 deletions docs/planning/roadmap.html
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,17 @@ <h3 id="11-verbosity-white_check_mark-complete-for-10">1.1 Verbosity ✅
href="eleven-labs.md"><code>eleven-labs.md</code></a> — <strong>not
started</strong> (the SDK-in-gateway approach is decided). Dictation's
larger later-phase capabilities are <strong>2.0</strong> (§5).</p>
<p><strong>AI footprint &amp; optimization</strong> is its own
measurement-first plan, tracked in <a
href="QUILL-AI-Optimization-PRD.md"><code>QUILL-AI-Optimization-PRD.md</code></a>.
Its first concrete slice <strong>shipped in 0.8.1 Beta 1</strong>: a
pinned, SHA-256-verified path for fetching redistributable components
from QUILL's own GitHub release assets
(<code>quill/core/release_assets.py</code>), wired as <strong>Tools &gt;
Speech &gt; Download Offline Speech Engine...</strong> (recovery for the
bundled whisper.cpp engine). It establishes the reliable-acquisition
foundation (PRD §10.2.3–10.2.4) that any future unbundling builds on;
the engine stays bundled, so capability never depends on a download.</p>
<h3 id="13-agentic-ai-platform-shipped-in-081-beta-1">1.3 Agentic AI
platform (shipped in 0.8.1 Beta 1)</h3>
<p>The detailed planning spec was retired once the platform shipped; the
Expand Down
9 changes: 9 additions & 0 deletions docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ The **ElevenLabs / ElevenDesk** premium-cloud-TTS integration is its own workstr
tracked in [`eleven-labs.md`](eleven-labs.md) — **not started** (the SDK-in-gateway
approach is decided). Dictation's larger later-phase capabilities are **2.0** (§5).

**AI footprint & optimization** is its own measurement-first plan, tracked in
[`QUILL-AI-Optimization-PRD.md`](QUILL-AI-Optimization-PRD.md). Its first concrete
slice **shipped in 0.8.1 Beta 1**: a pinned, SHA-256-verified path for fetching
redistributable components from QUILL's own GitHub release assets
(`quill/core/release_assets.py`), wired as **Tools > Speech > Download Offline Speech
Engine...** (recovery for the bundled whisper.cpp engine). It establishes the
reliable-acquisition foundation (PRD §10.2.3–10.2.4) that any future unbundling builds
on; the engine stays bundled, so capability never depends on a download.

### 1.3 Agentic AI platform (shipped in 0.8.1 Beta 1)

The detailed planning spec was retired once the platform shipped; the AI suite is
Expand Down
Binary file modified docs/release notes/release0.8.1-beta1.epub
Binary file not shown.
9 changes: 9 additions & 0 deletions docs/release notes/release0.8.1-beta1.html
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,15 @@ <h3 id="fixes">Fixes</h3>
</ul>
<h3 id="enhancements">Enhancements</h3>
<ul>
<li><strong>Recover the offline speech engine with one click.</strong>
The private, on-device speech engine (whisper.cpp) ships in the
installer; if it is ever missing, <strong>Tools &gt; Speech &gt;
Download Offline Speech Engine...</strong> now fetches QUILL's own
checksum-verified copy (about 8 MB, cancelable; disabled in Safe Mode)
so dictation and offline transcription work without re-running the
installer. This is the first piece of the AI footprint/optimization plan
— a pinned, SHA-256-verified path for fetching redistributable
components from QUILL's own GitHub release assets.</li>
<li><strong>Hear how deep your indentation is.</strong> Tab / Shift+Tab
can now speak the new indentation depth — "4 spaces", "8 spaces", "1
tab" — instead of "Indented lines", honouring your tabs-vs-spaces and
Expand Down
8 changes: 8 additions & 0 deletions docs/release notes/release0.8.1-beta1.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ keys line up again.

### Enhancements

- **Recover the offline speech engine with one click.** The private, on-device
speech engine (whisper.cpp) ships in the installer; if it is ever missing,
**Tools > Speech > Download Offline Speech Engine...** now fetches QUILL's own
checksum-verified copy (about 8 MB, cancelable; disabled in Safe Mode) so
dictation and offline transcription work without re-running the installer. This
is the first piece of the AI footprint/optimization plan — a pinned,
SHA-256-verified path for fetching redistributable components from QUILL's own
GitHub release assets.
- **Hear how deep your indentation is.** Tab / Shift+Tab can now speak the new
indentation depth — "4 spaces", "8 spaces", "1 tab" — instead of "Indented
lines", honouring your tabs-vs-spaces and indent-width settings. Toggle with
Expand Down
Binary file modified docs/user guide/userguide.epub
Binary file not shown.
8 changes: 8 additions & 0 deletions docs/user guide/userguide.html
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,14 @@ <h3 id="offline-transcription-tools--speech">Offline transcription
official builder; QUILL never bundles or redistributes it. Without
ffmpeg, the whisper.cpp engine needs a 16 kHz mono WAV, while the Faster
Whisper engine handles the other formats on its own.</li>
<li><strong>Download Offline Speech Engine...</strong> The private,
on-device speech engine (whisper.cpp) ships with QUILL, so dictation and
offline transcription work out of the box. If it is ever missing — for
example on an older install that pre-dated bundling — <strong>Tools &gt;
Speech &gt; Download Offline Speech Engine...</strong> fetches QUILL's
own verified copy (about 8 MB, checksum-verified, with a cancelable
progress bar; disabled in Safe Mode) and sets it up. Most people never
need this; it is a one-click recovery.</li>
<li><strong>Speaker attribution.</strong> If you download the "Small
English with speaker detection" model (in Manage Speech Models), QUILL
marks <strong>who is speaking when</strong> — each turn is labelled
Expand Down
7 changes: 7 additions & 0 deletions docs/user guide/userguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,13 @@ not need to enable Artificial Intelligence to use these features. They live unde
never bundles or redistributes it. Without ffmpeg, the whisper.cpp engine needs
a 16 kHz mono WAV, while the Faster Whisper engine handles the other formats on
its own.
- **Download Offline Speech Engine...** The private, on-device speech engine
(whisper.cpp) ships with QUILL, so dictation and offline transcription work out
of the box. If it is ever missing — for example on an older install that
pre-dated bundling — **Tools > Speech > Download Offline Speech Engine...**
fetches QUILL's own verified copy (about 8 MB, checksum-verified, with a
cancelable progress bar; disabled in Safe Mode) and sets it up. Most people
never need this; it is a one-click recovery.
- **Speaker attribution.** If you download the "Small English with speaker
detection" model (in Manage Speech Models), QUILL marks **who is speaking when**
— each turn is labelled "Speaker 1", "Speaker 2", and so on in the transcript
Expand Down
201 changes: 201 additions & 0 deletions quill/core/release_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""On-demand fetch of redistributable runtime components from QUILL's own GitHub
release assets — the controlled, pinned, SHA-256-verified store (PRD 10.2.4).

Components QUILL is licensed to redistribute are uploaded to a Community-Access
release tag and pinned here by SHA-256. The app can download + verify + unpack one
on demand (e.g. to recover a missing offline speech engine), as a **supplement**
to the installer bundling — capability never depends on a download succeeding, so
the bundled copy remains the default and this is the recovery/optional path.

Reliability (PRD 10.2.3): pinned URL + SHA-256 (moving refs / placeholder hashes
refused), HTTPS enforced, retry-with-backoff and **resumable** download (HTTP
Range), atomic verified install (download to a temp dir, verify, then copy in),
and a clean error on any failure so the caller can degrade gracefully.

GATE-9 / network-egress: the only outbound call site is ``_download_resumable``;
it runs on an explicit user action and is blocked in Safe Mode. No ``wx`` imports.
"""

from __future__ import annotations

import hashlib
import os
import shutil
import tempfile
import time
import urllib.request
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

# (fraction 0.0-1.0, human message) — same shape as the speech ProgressCallback.
ProgressCallback = Callable[[float, str], None]

# Assets live on a dedicated, not-latest release tag so asset churn never touches
# the product release or the autoupdate feed (the tag does not match ``v*``).
_RELEASE_BASE = "https://github.com/Community-Access/quill/releases/download"

_CHUNK = 1024 * 1024


class ReleaseAssetError(Exception):
"""A redistributable component could not be fetched/verified/installed."""


@dataclass(frozen=True, slots=True)
class ReleaseAsset:
"""One pinned, SHA-256-verified component hosted on a QUILL release tag."""

component: str
tag: str
filename: str
sha256: str
expect_member: str = "" # a file the unpacked archive must contain
license: str = ""

@property
def url(self) -> str:
return f"{_RELEASE_BASE}/{self.tag}/{self.filename}"


# Pinned manifest. Add an entry only for a component we are licensed to
# redistribute (e.g. MIT). Each is verified by SHA-256 before use, so the host is
# never blindly trusted. License-unclear components (DECtalk, eSpeak GPL) are NOT
# listed until redistribution is cleared (PRD 10.2.4 open questions).
ASSETS: dict[str, ReleaseAsset] = {
"whispercpp": ReleaseAsset(
component="whispercpp",
tag="assets-v1",
filename="whisper-bin-x64.zip",
sha256="7d8be46ecd31828e1eb7a2ecdd0d6b314feafd82163038ab6092594b0a063539",
expect_member="whisper-cli.exe",
license="MIT (ggml-org/whisper.cpp v1.9.1)",
),
}


def is_pinned(asset: ReleaseAsset) -> bool:
"""True only when the asset is safely pinned: a real 64-hex SHA-256 and a URL
that is not a moving ref (``latest``/``head``/``main``/``master``)."""
sha = (asset.sha256 or "").strip().lower()
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
return False
low = asset.url.lower()
return not any(seg in low for seg in ("/latest/", "/head/", "/main/", "/master/"))


def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(_CHUNK), b""):
digest.update(chunk)
return digest.hexdigest()


def _download_resumable(
url: str,
dest: Path,
progress: ProgressCallback | None,
*,
retries: int = 4,
timeout: float = 60.0,
) -> None:
"""Download ``url`` to ``dest`` with retry/backoff, resuming a partial file via
HTTP Range. HTTPS-only. Raises :class:`ReleaseAssetError` after exhausting retries.

GATE-9: this is the module's only network egress; callers gate it on an explicit
user action and Safe Mode.
"""
if not url.lower().startswith("https://"):
raise ReleaseAssetError("Refusing a non-HTTPS download URL.")
last_error: Exception | None = None
for attempt in range(retries):
try:
have = dest.stat().st_size if dest.exists() else 0
request = urllib.request.Request(url)
if have:
request.add_header("Range", f"bytes={have}-")
with urllib.request.urlopen(request, timeout=timeout) as resp: # noqa: S310 - HTTPS enforced
status = getattr(resp, "status", 200)
# If the server ignored Range (200 not 206), restart from zero.
append = bool(have) and status == 206
if not append:
have = 0
total = have + int(resp.headers.get("Content-Length") or 0)
downloaded = have
with dest.open("ab" if append else "wb") as out:
while True:
chunk = resp.read(_CHUNK)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
if progress is not None and total > 0:
progress(
min(downloaded / total, 0.99),
"Downloading offline speech engine...",
)
return
except Exception as exc: # noqa: BLE001 - retry transient network errors
last_error = exc
time.sleep(min(2**attempt, 8))
raise ReleaseAssetError(f"Download failed after {retries} attempts: {last_error}")


def fetch_component(
component: str, target_dir: Path, *, progress: ProgressCallback | None = None
) -> Path:
"""Download, verify (SHA-256), and unpack ``component`` into ``target_dir``.

Atomic: everything happens in a temp dir; the verified files are copied into
``target_dir`` only after the checksum passes, so a partial/failed download
never leaves a half-installed engine. Returns ``target_dir``. Raises
:class:`ReleaseAssetError` (Safe Mode, unknown/unpinned component, network,
checksum mismatch, or a malformed archive) so the caller can degrade cleanly.
"""
if os.environ.get("QUILL_SAFE_MODE") == "1":
raise ReleaseAssetError("Downloading components is disabled in Safe Mode.")
asset = ASSETS.get(component)
if asset is None:
raise ReleaseAssetError(f"Unknown component: {component!r}")
if not is_pinned(asset):
raise ReleaseAssetError(
f"Refusing to fetch an unpinned/placeholder asset for {component!r}."
)

target = Path(target_dir)
tmp = Path(tempfile.mkdtemp(prefix="quill-asset-"))
try:
archive = tmp / asset.filename
if progress is not None:
progress(0.0, "Downloading offline speech engine...")
_download_resumable(asset.url, archive, progress)

actual = _sha256_file(archive)
if actual.lower() != asset.sha256.lower():
raise ReleaseAssetError(
f"Checksum mismatch for {asset.filename} "
f"(expected {asset.sha256[:12]}..., got {actual[:12]}...)."
)

if progress is not None:
progress(0.99, "Installing...")
extract = tmp / "extract"
with zipfile.ZipFile(archive) as zf:
zf.extractall(extract)

source_dir = extract
if asset.expect_member:
hits = list(extract.rglob(asset.expect_member))
if not hits:
raise ReleaseAssetError(f"{asset.filename} did not contain {asset.expect_member}.")
source_dir = hits[0].parent

target.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, target, dirs_exist_ok=True)
if progress is not None:
progress(1.0, "Done.")
return target
finally:
shutil.rmtree(tmp, ignore_errors=True)
Loading