open-standards/aac/aac-rs/third_party/litert-lm/vendor.sh
Transaction Science 3e88f9fbc9 feat(aac sentence helper): whole sentences and replies, suggested on the device
The sentence helper is an optional download (Settings → Sentence helper):
Gemma 4 E2B in Google's web build, run by Google's LiteRT-LM web runtime on
the graphics chip, in a worker. ✨ beside the message turns the words chosen
into three sentences, fitting what the partner said if it was typed in, or
suggests four replies when no words are chosen yet. The suggestions take the
word predictions' place, at their height, so no cell moves (SPEC §3); the
person chooses one and speaks it. Nothing is spoken by itself, and nothing
typed leaves the device.

- ai_helper.rs: the catalogue (aac-ai-index/1), prompts, whether a device can
  run it (a WebGPU hardware adapter, never the processor), download, continue,
  remove and re-check, and suggestions that a later request supersedes. The
  model's parts (64 MiB, SHA-256 checked) and the one runtime build this
  browser runs live in the model store; the worker streams the parts from
  there into GPU memory.
- third_party/litert-lm: @litert-lm/core 0.17.1 vendored and pinned (npm
  integrity, each file's size and SHA-256), with our worker bundled by esbuild
  0.28.2; the runtime's CDN default is emptied.
- tools/ai: build_ai.py (the pinned model → parts and catalogue),
  check_packs.py, languages.json (English, Spanish, German and Hindi ready;
  Swahili not yet), and eval/: the 20 exchanges, a harness that runs the
  app's own prompts (a test keeps them word for word), and the judged answers.
- Measured: Chrome on an M5 Max, 0.4-0.7 s an answer. A real iPad Air (M3) in
  Safari 27: 101 s to download and check 2 GB, about 80 s for the first start,
  5.9 s for later starts, 0.8-1.4 s an answer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 06:52:48 -04:00

184 lines
9.8 KiB
Shell
Executable file

#!/usr/bin/env bash
# ============================================================================
# vendor.sh — put the sentence helper's runtime (Google's LiteRT-LM for the
# web) on the app's own site.
#
# third_party/litert-lm/vendor.sh <site-dir> [cache-dir]
#
# Writes <site-dir>/vendor/litert-lm-<version>-<revision>/ (and removes any
# other vendor/litert-lm-* there):
# ai-worker.js this directory's worker.js, bundled by esbuild
# with @litert-lm/core and @litertjs/wasm-utils
# into one classic worker script
# litertlm_wasm_*_internal.js and .wasm
# the runtime's four WebAssembly builds, from the
# npm package, unchanged
# LICENSE.txt, NOTICE.txt this directory's licence and notice
#
# The app never imports these URLs directly. When someone downloads the
# sentence helper, src/ai_helper.rs stores the worker and the one WebAssembly
# build their browser runs in the model store (SHA-256 checked, kept across
# app updates), and src/bindings/ai_helper.js runs them from there, in a
# worker, offline.
#
# The version and revision come from LITERT_LM_VERSION and
# LITERT_LM_VENDOR_REV in crates/aac-web/src/ai_helper.rs, and each runtime
# file's size and SHA-256 from RUNTIME_FILES there. This script fails loudly
# on any drift: another version asked for than pinned here, an npm tarball
# that is not the pinned one, a vendored file that differs from the Rust pin,
# or a directory whose bytes differ from VENDOR_DIGEST. Changed bytes need a
# new revision, because browsers and the model store treat the directory's
# URLs as immutable.
#
# Idempotent; downloads are cached in cache-dir (default ~/.cache/aac-rs).
# Needs node (npm, npx) for the bundle.
# ============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AAC="$(cd "$HERE/../.." && pwd)"
SITE="${1:?usage: vendor.sh <site-dir> [cache-dir]}"
CACHE="${2:-${XDG_CACHE_HOME:-$HOME/.cache}/aac-rs}"
AH="$AAC/crates/aac-web/src/ai_helper.rs"
LM_VERSION="$(sed -n 's/^pub const LITERT_LM_VERSION: &str = "\([^"]*\)";$/\1/p' "$AH")"
LM_REV="$(sed -n 's/^pub const LITERT_LM_VENDOR_REV: &str = "\([^"]*\)";$/\1/p' "$AH")"
[ -n "$LM_VERSION" ] || { echo "✗ LITERT_LM_VERSION not found in $AH" >&2; exit 1; }
[ -n "$LM_REV" ] || { echo "✗ LITERT_LM_VENDOR_REV not found in $AH" >&2; exit 1; }
DIR="litert-lm-$LM_VERSION-$LM_REV"
# ─── Pins ────────────────────────────────────────────────────────────────────
# The npm releases, by their registry integrity (npm view <pkg>@<version>
# dist.integrity, read 2026-10-01). @litert-lm/core asks for
# @litertjs/wasm-utils ^2.0.0; 2.5.3 is the latest.
PINNED_VERSION="0.17.1"
NPM_INTEGRITY="sha512-eUxw6ggqZrvNA8Uk/w0TwglRWryn/clrjqHYyfxQWRl0uz5Txmte+qss0u3Dt+X6W/io4G8ADk97LmjMxVRHUg=="
WU_VERSION="2.5.3"
WU_INTEGRITY="sha512-4fJiw6tBQnIs+0jH/OQ16nYOiSl8/+zZnn5h2ul8aTKHi05sVqZW580Tmk78pC2kT39A9OYBVdFawteZ/sI1kQ=="
# The bundler. Its output for the same input is the same on every platform;
# the bundle's bytes are pinned in ai_helper.rs all the same.
ESBUILD_VERSION="0.28.2"
# LiteRT-LM's LICENSE at tag v0.17.1 (commit 5e58e9a0aef7abf7091207a8b1d1063a1c800f08).
LICENSE_SHA256="c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4"
# What vendor/$VENDOR_PINNED holds, as a sha256 over its sorted file list and
# each file's sha256. Re-pin only together with a new LITERT_LM_VENDOR_REV.
VENDOR_PINNED="litert-lm-0.17.1-aac1"
VENDOR_DIGEST="a1b35e0e0f11c25b77e2d7cc9c4b7a3c580026771f0a24530ce3ff90dcebcdb6"
# The runtime's WebAssembly builds, as the npm package names them.
WASM_BUILDS=(litertlm_wasm_internal litertlm_wasm_asyncify_internal litertlm_wasm_compat_internal litertlm_wasm_compat_asyncify_internal)
[ "$LM_VERSION" = "$PINNED_VERSION" ] || {
echo "✗ ai_helper.rs asks for LiteRT-LM $LM_VERSION, but vendor.sh is pinned to $PINNED_VERSION —" >&2
echo " check the new release (licence, no network use, the model it runs), then re-pin PINNED_VERSION and NPM_INTEGRITY" >&2
exit 1
}
[ "$DIR" = "$VENDOR_PINNED" ] || {
echo "✗ ai_helper.rs asks for $DIR, but vendor.sh is pinned to $VENDOR_PINNED — vendor it, check it, and re-pin VENDOR_PINNED and VENDOR_DIGEST" >&2
exit 1
}
sha256() { shasum -a 256 "$1" | cut -c1-64; }
fsize() { wc -c < "$1" | tr -d ' '; }
echo "▸ vendoring LiteRT-LM $LM_VERSION ($DIR)"
mkdir -p "$CACHE"
# Tell (but do not fail) when npm has a newer release to review.
LATEST="$(npm view @litert-lm/core version 2>/dev/null || true)"
if [ -n "$LATEST" ] && [ "$LATEST" != "$LM_VERSION" ]; then
echo " note: npm's latest @litert-lm/core is $LATEST (pinned: $LM_VERSION)"
fi
# An npm package, fetched and checked against its pinned integrity beside the
# cache entry, then moved in whole, so an interrupted run never leaves half a
# package that looks complete. npm_package <name> <version> <integrity> <dir>
npm_package() {
local name="$1" version="$2" integrity="$3" dir="$4"
if [ ! -f "$dir/package/package.json" ]; then
local tmp tgz got
tmp="$(mktemp -d "$CACHE/npm.XXXXXX")"
(cd "$tmp" && npm pack --silent "$name@$version" >/dev/null)
tgz="$(ls "$tmp"/*.tgz)"
got="sha512-$(openssl dgst -sha512 -binary "$tgz" | base64 | tr -d '\n')"
[ "$got" = "$integrity" ] || {
rm -rf "$tmp"
echo "✗ $name@$version from npm is not the pinned tarball (got $got)" >&2
exit 1
}
(cd "$tmp" && tar xzf "$tgz")
rm -rf "$dir"
mv "$tmp" "$dir"
fi
grep -q "\"version\": \"$version\"" "$dir/package/package.json" || { echo "✗ cached $name is not $version" >&2; exit 1; }
grep -q '"license": "Apache-2.0"' "$dir/package/package.json" || { echo "✗ $name $version no longer declares Apache-2.0 — review it" >&2; exit 1; }
}
LM_CACHE="$CACHE/litert-lm-core-$LM_VERSION"
WU_CACHE="$CACHE/litertjs-wasm-utils-$WU_VERSION"
npm_package "@litert-lm/core" "$LM_VERSION" "$NPM_INTEGRITY" "$LM_CACHE"
npm_package "@litertjs/wasm-utils" "$WU_VERSION" "$WU_INTEGRITY" "$WU_CACHE"
grep -q '"@litertjs/wasm-utils": "^2' "$LM_CACHE/package/package.json" \
|| { echo "✗ @litert-lm/core $LM_VERSION no longer asks for @litertjs/wasm-utils 2.x — review it" >&2; exit 1; }
[ "$(sha256 "$HERE/LICENSE.txt")" = "$LICENSE_SHA256" ] || { echo "✗ $HERE/LICENSE.txt is not LiteRT-LM's LICENSE at v0.17.1" >&2; exit 1; }
# The worker bundle: worker.js and the two packages, laid out as npm would,
# bundled into one classic script (the runtime loads its glue with
# importScripts, which module workers lack).
WORK="$(mktemp -d "$CACHE/litert-lm-bundle.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/node_modules/@litert-lm" "$WORK/node_modules/@litertjs"
cp -R "$LM_CACHE/package" "$WORK/node_modules/@litert-lm/core"
cp -R "$WU_CACHE/package" "$WORK/node_modules/@litertjs/wasm-utils"
cp "$HERE/worker.js" "$WORK/worker.js"
(cd "$WORK" && npx --yes "esbuild@$ESBUILD_VERSION" worker.js --bundle --format=iife --platform=browser \
--target=es2022 --legal-comments=none --charset=utf8 --log-level=warning --outfile=ai-worker.js)
# The runtime's default WebAssembly location is a public CDN; the app always
# hands it the WebAssembly, so empty it: nothing can then reach another site.
CDN="https://cdn.jsdelivr.net/npm/@litert-lm/core@$LM_VERSION/wasm"
grep -qF "\"$CDN\"" "$WORK/ai-worker.js" || { echo "✗ the runtime's default WebAssembly URL has changed — review the bundle" >&2; exit 1; }
sed "s#\"$CDN\"#\"\"#" "$WORK/ai-worker.js" > "$WORK/ai-worker.js.tmp" && mv "$WORK/ai-worker.js.tmp" "$WORK/ai-worker.js"
rm -rf "$SITE"/vendor/litert-lm-*
LM="$SITE/vendor/$DIR"
mkdir -p "$LM"
cp "$WORK/ai-worker.js" "$HERE/LICENSE.txt" "$HERE/NOTICE.txt" "$LM/"
for b in "${WASM_BUILDS[@]}"; do
cp "$LM_CACHE/package/wasm/$b.js" "$LM_CACHE/package/wasm/$b.wasm" "$LM/"
done
chmod 644 "$LM"/*
# The runtime must never reach another site: no URL in the worker at all, and
# none in a string in the glue scripts (their comments cite Emscripten's docs).
if grep -nE 'https?://' "$LM/ai-worker.js"; then
echo "✗ the sentence helper's worker names another site — review it" >&2; exit 1
fi
for b in "${WASM_BUILDS[@]}"; do
if grep -nE "[\"'\`]https?://" "$LM/$b.js"; then
echo "✗ $b.js loads from another site — review it" >&2; exit 1
fi
done
# Each runtime file must be exactly what ai_helper.rs pins (RUNTIME_FILES):
# the app downloads it by that size and SHA-256.
PINS="$(sed -n 's/^ *RuntimeFile { name: "\([^"]*\)", bytes: \([0-9_]*\), sha256: "\([0-9a-f]*\)" },$/\1 \2 \3/p' "$AH" | awk '{ gsub(/_/, "", $2); print }')"
[ "$(printf '%s\n' "$PINS" | grep -c .)" = 9 ] || { echo "✗ RUNTIME_FILES in $AH should pin 9 files" >&2; exit 1; }
bad=0
while read -r name bytes want; do
f="$LM/$name"
[ -s "$f" ] || { echo "✗ vendored runtime is missing $name" >&2; exit 1; }
got_bytes="$(fsize "$f")"; got="$(sha256 "$f")"
if [ "$got_bytes" != "$bytes" ] || [ "$got" != "$want" ]; then
echo "✗ vendor/$DIR/$name is $got_bytes bytes, sha256 $got;" >&2
echo " ai_helper.rs RUNTIME_FILES pins $bytes bytes, sha256 $want." >&2
bad=1
fi
done <<< "$PINS"
[ "$bad" = 0 ] || { echo " Changed bytes need a new LITERT_LM_VENDOR_REV (a new URL), then new pins there and here." >&2; exit 1; }
DIGEST="$(cd "$LM" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(sha256 "$f")"; done | shasum -a 256 | cut -c1-64)"
[ "$DIGEST" = "$VENDOR_DIGEST" ] || {
echo "✗ vendor/$DIR differs from what was pinned (got $DIGEST)." >&2
echo " Its URLs are cached as immutable: bump LITERT_LM_VENDOR_REV in ai_helper.rs," >&2
echo " then set VENDOR_PINNED and VENDOR_DIGEST in $HERE/vendor.sh." >&2
exit 1
}
echo "✓ vendor/$DIR ($(du -sh "$LM" | cut -f1))"