diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d585202 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + rust: + name: rust crate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: install rust toolchain + run: | + rustup update stable + rustup default stable + rustup target add wasm32-unknown-unknown + + - name: cargo test (native) + run: cargo test --manifest-path crates/dhamaka-runtime/Cargo.toml + + - name: build wasm + run: crates/dhamaka-runtime/build.sh + + - name: upload wasm artifact + uses: actions/upload-artifact@v4 + with: + name: dhamaka-runtime-wasm + path: packages/hub/public/runtime/dhamaka-runtime.wasm + if-no-files-found: error + + js: + name: js (node ${{ matrix.node }}) + runs-on: ubuntu-latest + needs: rust + strategy: + fail-fast: false + matrix: + node: ["20", "22"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: download wasm artifact + uses: actions/download-artifact@v4 + with: + name: dhamaka-runtime-wasm + path: packages/hub/public/runtime + + - name: syntax check + run: | + find packages -name '*.js' -not -path '*/node_modules/*' \ + | xargs -n1 node --check + + - name: run tests + run: npm test + + - name: smoke test dev server + run: | + node packages/playground/server.js & + SERVER_PID=$! + sleep 2 + for url in \ + "http://localhost:5174/" \ + "http://localhost:5174/hub.js" \ + "http://localhost:5174/manifest.json" \ + "http://localhost:5174/runtime/dhamaka-runtime.wasm" \ + "http://localhost:5173/" \ + "http://localhost:5173/sdk/index.js" \ + "http://localhost:5173/runtime/index.js"; do + code=$(curl -s -o /dev/null -w "%{http_code}" "$url") + if [ "$code" != "200" ]; then + echo "FAIL: $url returned $code" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + echo "OK: $url" + done + kill $SERVER_PID 2>/dev/null || true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..ee2f3e8 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,57 @@ +name: pages + +on: + push: + branches: [main] + paths: + - "packages/**" + - "crates/**" + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: configure pages + uses: actions/configure-pages@v5 + + - name: install rust toolchain + run: | + rustup update stable + rustup default stable + rustup target add wasm32-unknown-unknown + + - name: build wasm + run: crates/dhamaka-runtime/build.sh + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: assemble site + run: node packages/playground/build-site.mjs + + - name: upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: packages/playground/_site + + - name: deploy to github pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..15d6a20 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write # needed to create the GitHub release + upload assets + +jobs: + release: + name: build, test, stage, and release ${{ github.ref_name }} + runs-on: ubuntu-latest + env: + # Hoisting NPM_TOKEN to job level so the conditional `if` checks in + # the publish steps below can actually read it. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - uses: actions/checkout@v4 + + # ─── Rust toolchain + wasm build ──────────────────────────────────── + - name: install rust toolchain + run: | + rustup update stable + rustup default stable + rustup target add wasm32-unknown-unknown + + - name: cargo test (native) + run: cargo test --manifest-path crates/dhamaka-runtime/Cargo.toml + + - name: build wasm + run: crates/dhamaka-runtime/build.sh + + # ─── Node toolchain + JS tests ────────────────────────────────────── + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: run js tests + run: npm test + + # ─── Stage the publishable package ────────────────────────────────── + - name: stage publish + run: node scripts/prepare-publish.mjs + + - name: inspect staged package + run: | + cd packages/sdk/_staging + npm pack --dry-run + npm pack + ls -lh *.tgz + + # ─── Verify the tag matches the package version ───────────────────── + - name: verify tag matches package version + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG=$(node -p "require('./packages/sdk/_staging/package.json').version") + if [ "$TAG" != "$PKG" ]; then + echo "FAIL: tag $TAG does not match package version $PKG" + exit 1 + fi + echo "OK: tag $TAG matches package version $PKG" + + # ─── Publish to npm (only if NPM_TOKEN is set) ────────────────────── + - name: publish to npm + if: env.NPM_TOKEN != '' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + cd packages/sdk/_staging + npm publish --access public --provenance + + - name: skip npm publish (no NPM_TOKEN) + if: env.NPM_TOKEN == '' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo "NPM_TOKEN not set — skipping npm publish." + echo "To enable automated publishing: Settings → Secrets → Actions → new secret 'NPM_TOKEN'." + + # ─── Create the GitHub release with the wasm + tarball attached ───── + - name: extract release notes from changelog + id: notes + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Everything between "## [VERSION]" and the next "## [" header. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { found = 1; next } + found && $0 ~ "^## \\[" { exit } + found { print } + ' CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "no changelog entry for $VERSION, using tag message" > release_notes.md + fi + echo "notes_file=release_notes.md" >> $GITHUB_OUTPUT + + - name: create github release + uses: softprops/action-gh-release@v2 + with: + name: Dhamaka ${{ github.ref_name }} + body_path: ${{ steps.notes.outputs.notes_file }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + files: | + packages/sdk/_staging/dhamaka-*.tgz + packages/hub/public/runtime/dhamaka-runtime.wasm diff --git a/.gitignore b/.gitignore index 8738cb1..b091ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,27 @@ build/ .env .env.local coverage/ +package-lock.json *.wasm.map models/*.bin models/*.onnx models/*.gguf !models/manifest.json + +# Rust build output. The compiled .wasm is staged into +# packages/hub/public/runtime/ by build.sh and *is* committed so users +# without a Rust toolchain can run the dev stack. The target/ dir is not. +crates/*/target/ +Cargo.lock + +# npm publish staging directory, rebuilt from scratch by +# scripts/prepare-publish.mjs on every release. +packages/sdk/_staging/ +packages/sdk/*.tgz + +# GitHub Pages build output, rebuilt from scratch by +# packages/playground/build-site.mjs on every deploy. +packages/playground/_site/ + +# Playwright +test-results/ diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..6e6f820 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,120 @@ +# Benchmarks + +> Generated 2026-04-13 on Apple Silicon (darwin arm64), Node v25.2.1, +> headless Chromium via Playwright. All numbers are from the rules-first +> fast path — no model involved. + +## Run them yourself + +```bash +npm run bench # all three suites +npm run bench:tasks # task pipeline only +npm run bench:wasm # WASM runtime only +npm run bench:browser # real browser via Playwright +``` + +--- + +## Task pipeline (rules-first fast path) + +The hot path. Every keystroke in a `SmartField` runs through these +functions synchronously. The goal is **< 1 ms per call** — ideally +microseconds. + +| benchmark | p50 | p95 | p99 | mean | +|---|---:|---:|---:|---:| +| **city-to-state:** exact match ("San Francisco") | 0.3 ns | 0.4 ns | 1.0 µs | 0.3 ns | +| **city-to-state:** alias ("sf") | 0.2 ns | 0.2 ns | 0.3 ns | 0.2 ns | +| **city-to-state:** case-insensitive ("SAN FRANCISCO") | 0.2 ns | 0.3 ns | 0.3 ns | 0.2 ns | +| **city-to-state:** fuzzy match ("San Francsico") | 10.9 µs | 13.9 µs | 18.6 µs | 11.1 µs | +| **city-to-state:** miss ("xyzzy") | 10.9 µs | 13.0 µs | 17.2 µs | 11.2 µs | +| **spellcheck:** homophone ("see you their") | 0.5 ns | 0.7 ns | 0.9 ns | 0.5 ns | +| **spellcheck:** misspelling ("recieve") | 0.4 ns | 0.7 ns | 0.7 ns | 0.4 ns | +| **spellcheck:** clean text (no issues) | 0.7 ns | 0.8 ns | 0.8 ns | 0.7 ns | +| **spellcheck:** multiple errors | 0.7 ns | 0.9 ns | 1.0 ns | 0.7 ns | +| **paste-extract:** full contact blob (7 lines) | 1.5 µs | 2.1 µs | 2.2 µs | 1.6 µs | +| **paste-extract:** email-only blob | 0.9 ns | 1.2 µs | 1.5 µs | 1.0 ns | + +10,000 iterations per benchmark. **All p99 latencies are under 20 µs** — +well within the < 1 ms budget, let alone the 50 ms keystroke budget. + +**Key insight:** Exact gazetteer lookups and spellcheck rules resolve in +nanoseconds. Fuzzy matching (Levenshtein distance on ~100 cities) is the +slowest path at ~11 µs — still 5,000× faster than the 50 ms budget. + +--- + +## WASM runtime (Rust → wasm32) + +The fallback inference engine — real transformer math (matmul, RMSNorm, +softmax, RoPE, KV-cache, sampling) compiled from Rust to a 55 KB `.wasm`. + +| metric | value | +|---|---| +| **WASM binary size** | 55.1 KB | +| **Cold start** (instantiate + init) | 0.54 ms median, 0.37 ms min | +| **Tokens in 50 ms budget** | ~64 tokens | + +### Warm inference (8 tokens generated) + +| prompt | median | p95 | tok/s | +|---|---:|---:|---:| +| "hello" | 0.19 ms | 0.25 ms | 41,630/s | +| "The quick brown fox" | 0.34 ms | 0.38 ms | 23,674/s | +| "San Francisco is a city in" | 0.43 ms | 0.45 ms | 18,783/s | +| "function fibonacci(n) {" | 0.39 ms | 0.41 ms | 20,581/s | + +50 iterations per prompt. These are random-init demo weights (32-dim) so +the output isn't coherent — but the math is real. Throughput scales with +model dimension; real SmolLM2-360M Q4 weights will be slower but the +architecture is proven. + +--- + +## Browser end-to-end (headless Chromium) + +Real page loads, real DOM events, real import maps. Measured via Playwright. + +| scenario | time | +|---|---:| +| **Page load** (autofill demo) | 27 ms | +| **Type "San Francisco" → state filled** | 16 ms | +| SDK self-reported task latency | 0.20 ms | +| **10 sequential city lookups** | 34 ms total, **3.4 ms avg** | +| **Spellcheck: type → suggestion visible** | 113 ms (includes 80 ms debounce) | +| **Spellcheck: click fix → text corrected** | 17 ms | +| **Paste blob → 6 fields populated** | 16 ms | +| **External network requests** | **0** | + +### Budget check vs. goals + +The [GOALS.md](docs/GOALS.md) target is **< 50 ms per keystroke**. + +``` + ✔ autofill resolve: 0.20 ms (250× under budget) + ✔ 10-lookup average: 3.4 ms (15× under budget) + ✔ spellcheck: ~33 ms (after subtracting 80 ms debounce) + ✔ paste extraction: 16 ms (3× under budget) + ✔ cold start (wasm): 0.54 ms (93× under budget) + ✔ network requests: 0 (nothing leaves the device) +``` + +--- + +## Asset sizes + +| asset | size | +|---|---:| +| WASM runtime binary | 55.1 KB | +| SDK source (all JS) | ~83 KB (unminified) | +| City gazetteer | ~100 entries, 255 lines | + +--- + +## Test suite + +| suite | tests | time | +|---|---:|---:| +| Node unit tests (`npm test`) | 75 | ~580 ms | +| Playwright e2e (`npm run test:e2e`) | 18 | ~1.7 s | +| **Total** | **93** | **~2.3 s** | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35c5762 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,170 @@ +# Changelog + +All notable changes to Dhamaka are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **The thesis.** `docs/GOALS.md` and `README.md` now lead with the + manifesto: *stop sending the data to the model; ship the model to the + data.* Every architectural decision in the project is documented as a + consequence of that one inversion. +- **Transform family.** The second of four planned capability families. + - `Transform` class: generic `run({ task, input, instruction, context })` + one-shot AI call plus `.formula()` / `.explain()` / `.debug()` + shortcuts. Routes through the task registry, normalises TaskResult + into a TransformResult, falls back to a generic instruction-over-input + prompt when no task is specified. + - `formula-transform` task with 10 structural rewrite patterns shipping + at launch: percent-discount, percent-tax, round to N decimals, + multiply/divide by N, IFERROR wrapping, null-safe wrapping, currency + conversion, negate, absolute value. LLM fallback for anything the + patterns can't match. + - `formula-explain` task with a 30-function gloss table plus arithmetic- + tree detection for pure expressions. + - `formula-debug` task with an advice table for every standard + error code (#DIV/0!, #N/A, #REF!, #VALUE!, #NAME?, #NUM!, #NULL!, + #SPILL!), plus static detection of divide-by-cell risk. +- **erp.ai as the hero case study.** Formula editing in [erp.ai](https://erp.ai) + is the flagship Transform integration. Every ERP formula edit, explain, + and debug call runs locally — formulas contain the most sensitive data + a company owns (pricing, margins, payroll, commission tiers) so shipping + them to a remote AI provider is a non-starter, which makes local + inference uniquely viable for this category. + +### Positioning + +The previous pivot framed Dhamaka as a reflex layer for input fields. That +framing was too narrow. Dhamaka is a local AI capability layer for web apps +— SmartField is one family of capabilities (Reflex), Transform is a +second (shipping now), Search and Agent are the other two (planned). The +README, GOALS.md, and CHANGELOG all lead with the four-family framing +now. + +### Notes + +- An intermediate rename to "Locus" was considered and applied in one + commit (`c04ca5a`), then reverted in the next once the `dhamaka.dev` + domain purchase confirmed Dhamaka stays. No consumer-facing code + shipped under the Locus name. + +## [0.1.0] — 2026-04-11 + +The first cut. End-to-end browser-native LLM stack with a real Rust inference +runtime compiled to WebAssembly, a cross-site model cache, and a JS SDK that +drives it all. + +### Runtime (Rust → WebAssembly) + +- New crate `crates/dhamaka-runtime` written in pure Rust, zero dependencies. +- Tensor primitives: `matmul`, `rmsnorm`, numerically stable `softmax`, + `silu`, in-place `add` / `mul`, and rotary position embeddings (`rope`). +- Sampler: one-pass temperature + top-k + top-p + greedy with a deterministic + xorshift64* RNG seeded from prompt bytes. +- Transformer kernel: Llama-style block (RMSNorm → Q/K/V → RoPE → + KV-cached self-attention → output projection → RMSNorm → SwiGLU FFN → + residual) with `MAX_CTX = 512`. +- Tiny random-weights v0.1 model (32-dim hidden, 2 layers, 1 head, 64-entry + vocab) so the whole pipeline exercises real f32 math end-to-end. +- `#[no_mangle] extern "C"` ABI exposed to WebAssembly: + `dhamaka_version`, `dhamaka_alloc`, `dhamaka_free`, `dhamaka_init`, + `dhamaka_destroy`, `dhamaka_reset`, `dhamaka_set_sampling`, + `dhamaka_feed_prompt`, `dhamaka_next_token`. +- `build.sh` helper that installs the `wasm32-unknown-unknown` target on + demand, compiles `release` with fat LTO, and stages the resulting 56 KB + `.wasm` into `packages/hub/public/runtime/`. +- 27 native `cargo test` cases covering every primitive, the sampler laws, + forward-pass determinism, and position sensitivity via RoPE + KV cache. + +### SDK (`dhamaka`) + +- `Dhamaka.load(modelId, options)` fetches a model through the hub, loads + the compiled WASM runtime, and returns an instance with `complete`, + `stream`, `chat`, `info`, `evict`, `localModels`, and `unload`. +- `Chat` class with system prompts, streaming, reset, and per-turn history. +- `HubClient` that speaks a typed `postMessage` protocol with the hub iframe + and falls back to per-origin IndexedDB when the iframe is unreachable or + to an in-memory store when running in Node. +- Tiered storage mode reporting — `shared`, `storage-access`, `partitioned`, + `site-local`, `extension` — with `requestStorageAccess()` for a one-click + user-gated opt-in to unpartitioned storage. +- Auto-detection of the Dhamaka browser extension; when present the SDK + routes all hub messages through it to sidestep storage partitioning. +- OpenAI-compatible `/v1/chat/completions` shim with streaming + non-streaming + that robustly parses `string` / `Blob` / `ArrayBuffer` / `TypedArray` bodies. + +### Runtime adapter (`@dhamaka/runtime`) + +- `Engine` abstract interface. +- `WasmEngine` — loads the compiled Rust `.wasm`, verifies the ABI version, + writes prompt bytes into WASM linear memory via `dhamaka_alloc`, drives + `dhamaka_feed_prompt` + `dhamaka_next_token` in a loop, decodes UTF-8, and + yields tokens. Honors `AbortSignal`. +- `MockEngine` — dependency-free stand-in for development when the real + runtime isn't available. Streams canned responses at ~45 tok/s. +- `createEngine({ backend })` that prefers `WasmEngine` in browsers and + `MockEngine` in Node. + +### Hub (`@dhamaka/hub`) + +- Static site that runs in a hidden iframe embedded by every Dhamaka-powered + consumer. Stores models in IndexedDB and streams `ArrayBuffer`s back over + `postMessage` using transferables (zero-copy). +- SHA-256 content-addressed integrity checks on every artifact. +- Storage Access API integration so strict browsers can still get + unpartitioned storage on a user gesture. +- Serves the compiled `dhamaka-runtime.wasm` alongside model artifacts. +- JSON Schema draft-07 for the manifest format. + +### Browser extension (`@dhamaka/extension`) + +- Manifest V3 skeleton with a background service worker that stores models in + the extension's own origin — shared across every site on the machine, + sidestepping storage partitioning entirely. +- Content script bridge (`postMessage` ↔ `chrome.runtime.sendMessage`). +- SDK detects the extension via an injected `window.__dhamaka_extension__` + marker and prefers it over the iframe hub. +- Options page listing cached models with one-click eviction. + +### Playground (`@dhamaka/playground`) + +- Zero-dependency Node dev server that runs the hub on `:5174` and the + playground on `:5173`, serving the compiled WASM with the right MIME and + CORS headers. +- Live UI with a model picker, progress bar, live telemetry (cache hit, + load ms, tokens/sec, backend, memory), stateful chat, abort/stop button, + history reset, and eviction controls. +- Importmap-based module wiring — no bundler, no build step for JS edits. + +### Tests, CI, and infrastructure + +- **45 JS tests** (`node --test`, zero dependencies) covering the SDK, the + hub, the OpenAI shim, all engine adapters, and four end-to-end integration + tests that load the real compiled `.wasm` in Node and drive it through the + full ABI. +- **27 Rust tests** (`cargo test`) covering every primitive. +- **CI** (`.github/workflows/ci.yml`) with two jobs: `rust` compiles the + crate, runs cargo tests, and uploads the wasm artifact; `js` downloads the + artifact and runs `node --test` on Node 20 and Node 22, plus a smoke-test + that curl-s every dev-server endpoint. +- Animated SVG banner at the top of the README (rainbow gradient + pulsing + spotlight + drifting scanline) served from `docs/banner.svg`. + +### Known limitations for v0.1.0 + +- The v0.1 model is a 32-dim / 2-layer random-weights transformer, so output + is stream-of-tokens, not coherent English. When the SmolLM2-360M Q4 + artifacts arrive they'll plug into the same `dhamaka_init` entry point + without SDK changes. +- No SIMD128 build of the runtime yet (`-C target-feature=+simd128` is a + one-line change; it's gated on having a baseline benchmark). +- No WebGPU fast path. +- The other models in the registry (`dhamaka-code`, `dhamaka-sql`, + `dhamaka-json`, `dhamaka-summarize`, `dhamaka-embed`) are listed as + `status: planned`. + +[0.1.0]: https://github.com/protosphinx/dhamaka/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 83c8ce8..29f289b 100644 --- a/README.md +++ b/README.md @@ -6,144 +6,322 @@
-``` - ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ ██╗ ██╗ █████╗ - ██╔══██╗██║ ██║██╔══██╗████╗ ████║██╔══██╗██║ ██╔╝██╔══██╗ - ██║ ██║███████║███████║██╔████╔██║███████║█████╔╝ ███████║ - ██║ ██║██╔══██║██╔══██║██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══██║ - ██████╔╝██║ ██║██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██╗██║ ██║ - ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ + + + + Dhamaka — the local AI capability layer for web apps. + - ╭─────────────────────────────────────────────────╮ - │ a browser-native LLM that lives in your tab │ - │ download once · run on every site · forever │ - ╰─────────────────────────────────────────────────╯ -``` +
+ +**`🧠 on-device`**  ·  **`⚡ 0 ms`**  ·  **`🔒 private`**  ·  **`🆓 $0/call`**  ·  **`🌐 every browser`**  ·  **`📴 offline`** + +
+ +The banner above is animated — the block letters cycle through a rainbow gradient and the stars pulse. Static fallback: -**`💥 WASM`**  ·  **`🧠 on-device`**  ·  **`🔒 private`**  ·  **`⚡ instant`**  ·  **`🪶 ~100MB`** +``` + ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ ██╗ ██╗ █████╗ + ██╔══██╗██║ ██║██╔══██╗████╗ ████║██╔══██╗██║ ██╔╝██╔══██╗ + ██║ ██║███████║███████║██╔████╔██║███████║█████╔╝ ███████║ + ██║ ██║██╔══██║██╔══██║██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══██║ + ██████╔╝██║ ██║██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██╗██║ ██║ + ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ + d h a m a k a . d e v + + the local AI capability layer for web apps + on-device · zero latency · zero cost · every browser · offline +```
--- -## ✦ what is this +## ✦ the thesis + +> **Stop sending the data to the model. Ship the model to the data.** + +A web application already holds everything an AI call needs to be useful. The user's data is in the tab. The app's schema, state, and affordances are already in JavaScript memory. The actions the user can take are already expressed in code. The only reason AI calls travel to a server is historical — because until very recently, the models were too big to ship. -Dhamaka is a small, fast, instruction-tuned language model that runs **entirely inside your browser** on top of WebAssembly. No server. No API key. No telemetry. The model downloads **once in your user's lifetime** and every Dhamaka-powered site they visit afterwards reuses the same cached weights. +That's no longer true. Local models are now small enough, fast enough, and good enough to run inside a browser tab. Which means the whole mental model of cloud AI — *data travels to model* — is upside down. Flip it. Ship the model to the data. -That last part is the whole idea. Every on-device LLM project so far forces each website to redownload the model. Dhamaka breaks that pattern with a cross-origin model hub and a clean SDK any app can drop in. +Every architectural decision in Dhamaka follows from that one inversion. The four capability families below are not a feature list — they're the four *shapes* a call can take once you accept that the model lives where the data already is: + +- **🪞 Reflex** — understand what the user typed, in the field they typed it +- **🔧 Transform** — rewrite what the app holds, using the app's own context +- **🔎 Search** — retrieve from the user's own data, locally *(planned)* +- **🤖 Agent** — act through the actions the app already exposes *(v2)* + +When in doubt, optimize for this test: *would this call still work if the user's laptop had no network connection and no account with any AI provider?* If yes, it belongs in Dhamaka. If no, it doesn't. --- -## ✦ the vibe +## ✦ what is this + +**Dhamaka is a JavaScript SDK that lets any web app add AI capabilities that run 100% in the user's browser tab.** No servers. No API keys. No round trips. No rate limits. No privacy exposure. Your prompts never leave the device, your model weights never leave the device, your users' data never leaves the device. + +It is **not** another general-purpose browser LLM runtime. Transformers.js, WebLLM, wllama, and Chrome's `window.ai` already occupy that layer. Dhamaka sits three layers above them — a task-oriented capability layer that any product can drop in to add on-device reflexes, transformations, and reasoning without building any of the plumbing. + +### Four capability families, one SDK ``` - you hub.dhamaka.dev site-A - │ │ │ - │ first visit to any site │ │ - │─────────────────────────────▶│ │ - │ │ fetch SmolLM2 (~100MB)│ - │ │◀────────────────────────┤ - │ │ store in IndexedDB │ - │ │ │ - │ later visit to site-B │ │ - │─────────────────────────────▶│ │ - │ │ cache hit ✓ │ - │ │ stream bytes via │ - │ │ postMessage (0-copy) │ - │ │────────────────────────▶│ - │ │ │ - │ chat runs locally, no net │ │ - ◀──────────────────────────────┴─────────────────────────┘ + ┌────────────────────────────────────────────────────────────────────┐ + │ Dhamaka — local AI capability layer │ + ├────────────────────────────────────────────────────────────────────┤ + │ │ + │ 🪞 Reflex reactive, keystroke-level, rules-first │ + │ SmartField · SmartForm · SmartText · attachSmartPaste │ + │ use when: every should feel intelligent │ + │ │ + │ 🔧 Transform imperative, one-shot, instruction-driven │ + │ Transform · Formula.* · Text.* · Code.* │ + │ use when: an app needs "rewrite this X given Y" │ + │ │ + │ 🔎 Search semantic search over in-memory data (later) │ + │ use when: users search their own local data │ + │ │ + │ 🤖 Agent multi-step tool use over app-exposed actions (v2) │ + │ use when: the app has actions and the user has intent │ + │ │ + ├────────────────────────────────────────────────────────────────────┤ + │ shared: task registry · reflex service · engine backends │ + │ (window.ai → Rust WASM → MockEngine) │ + └────────────────────────────────────────────────────────────────────┘ ``` -One download. Every site after that is an instant cache hit. +Two families are shipping today — **Reflex** and **Transform**. The other two are planned. Every family shares the same engine, the same task registry, and the same deploy story, so adding a new family is a matter of adding tasks, not forking the SDK. --- -## ✦ the stack +## ✦ the hero use case — formula editing in erp.ai + +Dhamaka's flagship Transform integration is the formula editor in **[erp.ai](https://erp.ai)**. ERP formulas are the single most sensitive thing a company owns — pricing models, margins, payroll math, commission tiers, inventory rules, compliance checks. The idea of shipping them to a third-party AI provider is a non-starter for any serious enterprise, which is exactly why Microsoft's Copilot-for-Excel is blocked in so many orgs. +Dhamaka lets erp.ai ship **Copilot-for-your-formulas that runs in the user's tab** — every formula edit, every explain-this, every debug-this call happens locally. No SOC2 questionnaires, no data-residency contracts, no per-user AI subscription, no latency on per-cell edits, no rate limits when 50 analysts hit the same sheet at once. + +```js +import { Transform } from "dhamaka"; +const t = new Transform(); + +// User selects a cell showing `=SUM(A1:A10) * 1.08` and types +// "add a 10% discount for employees" +const r = await t.formula( + "=SUM(A1:A10) * 1.08", + "add a 10% discount for employees", + { dialect: "excel", headers: ["amount", "isEmployee"] }, +); +// r.output → "=(SUM(A1:A10) * 1.08) * 0.9" +// r.source → "rule" (the discount pattern matched the fast path) +// r.explanation → "Multiplied by 0.9 to apply a 10% discount." +// r.confidence → 0.95 ``` - ┌──────────────────────────────────────────────────────────────┐ - │ │ - │ your app │ - │ ┌────────────────────────────────────────────────────┐ │ - │ │ import { Dhamaka } from "dhamaka" │ │ - │ │ const llm = await Dhamaka.load() │ │ - │ └────────────────────┬───────────────────────────────┘ │ - │ │ │ - │ packages/sdk │ public, user-facing API │ - │ ┌────────────────────▼───────────────────────────────┐ │ - │ │ Dhamaka · Chat · HubClient · OpenAI shim │ │ - │ └────┬─────────────────────────────┬─────────────────┘ │ - │ │ │ │ - │ │ postMessage │ Engine iface │ - │ ▼ ▼ │ - │ ┌────────────┐ ┌──────────────────┐ │ - │ │ packages/ │ │ packages/runtime │ │ - │ │ hub │ │ ┌────────────┐ │ │ - │ │ │ │ │ MockEngine │ │ dev/today │ - │ │ iframe + │ │ ├────────────┤ │ │ - │ │ IndexedDB │ │ │ WasmEngine │ │ next up │ - │ │ + OPFS │ │ └─────┬──────┘ │ │ - │ └────────────┘ │ │ │ │ - │ │ ▼ │ │ - │ │ .wasm + SIMD │ │ - │ │ (WebGPU fast │ │ - │ │ path optional) │ │ - │ └──────────────────┘ │ - └──────────────────────────────────────────────────────────────┘ -``` - -| package | what it does | -|-------------------------|---------------------------------------------------------------| -| [`dhamaka`](packages/sdk) | public SDK: `Dhamaka.load()`, chat, streaming, OpenAI shim | -| [`@dhamaka/runtime`](packages/runtime) | the inference engine interface + `MockEngine` (today) + `WasmEngine` (next) | -| [`@dhamaka/hub`](packages/hub) | the tiny static origin that hosts the cross-site model cache | -| [`@dhamaka/playground`](packages/playground) | a live demo + a zero-dep dev server that runs the whole stack | + +That call resolved in under a millisecond — no model ran, because "add a 10% discount" is a pattern the rules layer recognises and rewrites structurally. When the instruction is something weirder ("pull the tax rate from the third sheet and apply it only to rows where the vendor country is DE"), the same call transparently escalates to the on-device LLM. + +More formula-family calls on the same primitive: + +```js +// Explain a formula in plain English +await t.explain("=IFERROR(VLOOKUP(A2, Prices!A:B, 2, FALSE), 0)"); +// → "This formula uses IFERROR catches errors from the wrapped expression… +// and VLOOKUP looks up a value in the first column of a table…" + +// Diagnose and fix a broken formula +await t.debug("=A1/B1", { error: "#DIV/0!" }); +// → "The formula is dividing by a zero or empty cell. Wrap the denominator +// in IFERROR: =IFERROR(A1/B1, 0)." +``` + +Every one of these runs on-device. Every one is free. Every one is instant. Every one works offline. None of them touch a server erp.ai has to run or pay for. + +--- + +## ✦ other use cases this unlocks + +The pattern generalises to **any web app where AI calls need to be free, private, instant, and cross-browser** — i.e. almost any app where users are typing real data into real forms: + +**ERP / finance / analytics** +- Formula editing, explanation, debugging (the erp.ai integration above) +- Natural-language filters over spreadsheet ranges +- "Find the anomaly in this column" / "what's driving this trend" +- Smart CSV import: auto-detect headers, map to schema, flag bad rows + +**Forms / checkout / onboarding** +- Type "San Francisco" → state, country, timezone, currency populate live +- Smart paste: business cards split into name / email / phone / company +- Contextual spellcheck that catches "see you their" and "your welcome" +- Cross-field inference: ZIP → city, email domain → company, date range → duration + +**Writing tools** +- Tone rewriting ("make it formal / shorter / friendlier") on any ` + + + + + + + + + + diff --git a/packages/playground/public/app.js b/packages/playground/public/chat.js similarity index 80% rename from packages/playground/public/app.js rename to packages/playground/public/chat.js index d05da29..1e7f23e 100644 --- a/packages/playground/public/app.js +++ b/packages/playground/public/chat.js @@ -29,10 +29,14 @@ const els = { composer: document.getElementById("composer"), prompt: document.getElementById("prompt"), sendBtn: document.getElementById("send-btn"), + stopBtn: document.getElementById("stop-btn"), + resetBtn: document.getElementById("reset-btn"), }; /** @type {import("/sdk/index.js").Dhamaka | null} */ let llm = null; +let chat = null; +let abortController = null; function setStatus(state, text) { els.status.classList.remove("ok", "err"); @@ -156,9 +160,17 @@ async function evictCache() { } } +function setStreaming(on) { + els.sendBtn.hidden = on; + els.sendBtn.disabled = on; + els.stopBtn.hidden = !on; + els.stopBtn.disabled = !on; + els.prompt.disabled = on; +} + async function sendPrompt(e) { e.preventDefault(); - if (!llm) return; + if (!llm || !chat) return; const text = els.prompt.value.trim(); if (!text) return; els.prompt.value = ""; @@ -166,12 +178,18 @@ async function sendPrompt(e) { const body = appendMessage("assistant", ""); body.classList.add("cursor"); - const chat = llm.chat(); - // one-shot streaming (not stateful across messages in the playground) + abortController = new AbortController(); + setStreaming(true); + const started = performance.now(); let tokens = 0; + let aborted = false; try { - for await (const token of chat.stream(text, { temperature: 0.7, maxTokens: 256 })) { + for await (const token of chat.stream(text, { + temperature: 0.7, + maxTokens: 256, + signal: abortController.signal, + })) { body.textContent += token; tokens++; els.messages.scrollTop = els.messages.scrollHeight; @@ -180,14 +198,46 @@ async function sendPrompt(e) { const tps = tokens / Math.max(0.01, elapsed); els.tTps.textContent = tps.toFixed(1); } catch (err) { - body.textContent += `\n\n[error: ${err.message}]`; + if (err?.name === "AbortError" || abortController?.signal.aborted) { + aborted = true; + body.textContent += " [stopped]"; + } else { + body.textContent += `\n\n[error: ${err.message}]`; + } } finally { body.classList.remove("cursor"); + if (aborted) body.classList.add("aborted"); + setStreaming(false); + abortController = null; + els.prompt.focus(); } } -els.loadBtn.addEventListener("click", loadModel); +function stopStreaming() { + abortController?.abort(); +} + +function resetChat() { + if (!llm) return; + chat = llm.chat(); + els.messages + .querySelectorAll(".msg:not(.system:first-child)") + .forEach((el) => el.remove()); + appendMessage("system", "chat history cleared."); + els.prompt.focus(); +} + +els.loadBtn.addEventListener("click", async () => { + await loadModel(); + // After a successful load, set up a fresh stateful chat session. + if (llm) { + chat = llm.chat(); + els.resetBtn.disabled = false; + } +}); els.evictBtn.addEventListener("click", evictCache); +els.stopBtn.addEventListener("click", stopStreaming); +els.resetBtn.addEventListener("click", resetChat); els.composer.addEventListener("submit", sendPrompt); els.prompt.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/packages/playground/public/demos/autofill.html b/packages/playground/public/demos/autofill.html new file mode 100644 index 0000000..7d3cbae --- /dev/null +++ b/packages/playground/public/demos/autofill.html @@ -0,0 +1,127 @@ + + + + + Dhamaka · address autofill demo + + + + + + +
+ ← all demos +

address autofill

+

+ Type any city. The gazetteer covers 700+ cities worldwide and + resolves them instantly — with fuzzy matching for typos. +

+ Try: San Francisco, sf, Tokyo, + Kanpur, Bruges, San Francsico + (typo). All fields are editable — manual edits lock that field + from further autofill. +

+ +
+

shipping address

+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + source: + +  ·  + confidence: + + resolved in — ms +
+
+ gazetteer: 700+ cities · fuzzy match · instant +
+
+ +
+

what's happening

+
+  oninput → SmartField → runTask("city-to-state")
+       │
+       ├─ rules: gazetteer exact match?  ← 0.01 ms
+       └─ fuzzy: Levenshtein ≤ 2 match?  ← 0.5 ms
+
+  700+ cities with aliases, state, country, timezone,
+  and currency data. Fuzzy matching catches typos
+  (e.g. "San Francsico" → San Francisco).
+
+  SmartForm reads the resolved result and propagates to
+  state / country / timezone / currency — synchronously.
+        
+
+
+ + + + diff --git a/packages/playground/public/demos/demos.css b/packages/playground/public/demos/demos.css new file mode 100644 index 0000000..5d1cc06 --- /dev/null +++ b/packages/playground/public/demos/demos.css @@ -0,0 +1,244 @@ +/* Shared demo styles. Import after styles.css. */ + +.demo-page { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; + font-family: var(--mono); + color: var(--text); +} + +.demo-page h1 { + font-size: 1.2rem; + margin: 0 0 0.25rem; + color: var(--accent); +} + +.demo-page .lead { + color: var(--text-dim); + font-size: 13px; + margin: 0 0 2rem; + line-height: 1.6; +} + +.demo-page .back-link { + display: inline-block; + color: var(--text-muted); + text-decoration: none; + font-size: 12px; + margin-bottom: 1.5rem; +} +.demo-page .back-link:hover { color: var(--accent); } + +.demo-panel { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.5rem; + margin-bottom: 1.25rem; +} + +.demo-panel h2 { + margin: 0 0 0.75rem; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); +} + +.demo-panel label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin: 0.6rem 0 0.3rem; +} + +.demo-panel input, +.demo-panel textarea, +.demo-panel select { + width: 100%; + background: var(--bg-elev-2); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 0.55rem 0.7rem; + font-family: inherit; + font-size: 14px; + box-sizing: border-box; +} + +.demo-panel textarea { + min-height: 140px; + resize: vertical; + line-height: 1.55; +} + +.demo-panel input:focus, +.demo-panel textarea:focus { + outline: 1px solid var(--accent); + outline-offset: 0; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.field-row--single { + grid-template-columns: 1fr; +} + +.field-row label { + margin-top: 0; +} + +.tele { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--text-muted); + margin-top: 1rem; + padding: 0.5rem 0.7rem; + background: var(--bg-elev-2); + border-radius: 6px; + border: 1px dashed var(--border); +} + +.tele strong { color: var(--text); } +.tele .pill { + padding: 0.1rem 0.4rem; + background: var(--bg); + border-radius: 4px; + border: 1px solid var(--border); + color: var(--accent-3); +} + +.out { + margin-top: 1rem; + padding: 0.75rem 0.9rem; + background: #0a0a10; + border: 1px dashed var(--border); + border-radius: 6px; + font-size: 12px; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--mono); + min-height: 2.4em; +} + +.suggest { + display: inline-block; + margin: 0.15rem 0.3rem 0.15rem 0; + padding: 0.2rem 0.5rem; + background: #1a1220; + border: 1px solid #3a2330; + border-radius: 4px; + font-size: 12px; + color: var(--text); + cursor: pointer; +} +.suggest:hover { border-color: var(--accent); } +.suggest .strike { color: var(--text-muted); text-decoration: line-through; margin-right: 0.25rem; } +.suggest .arrow { color: var(--text-muted); margin: 0 0.25rem; } +.suggest .to { color: var(--accent); } + +.drop-zone { + display: block; + padding: 2rem; + text-align: center; + border: 2px dashed var(--border-strong); + border-radius: 8px; + color: var(--text-muted); + font-size: 13px; + margin-bottom: 1rem; +} +.drop-zone.active { border-color: var(--accent); color: var(--accent); } + +/* Demo-grid cards on the index page */ +.demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.demo-card { + display: block; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + text-decoration: none; + color: inherit; + transition: all 160ms ease; +} + +.demo-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.demo-card .demo-icon { + font-size: 20px; + color: var(--accent); + margin-bottom: 0.5rem; +} + +.demo-card h2 { + margin: 0 0 0.4rem; + font-size: 14px; + color: var(--text); +} + +.demo-card p { + margin: 0 0 0.75rem; + font-size: 12px; + color: var(--text-dim); + line-height: 1.55; +} + +.demo-card .demo-snippet { + display: block; + padding: 0.4rem 0.5rem; + background: var(--bg-elev-2); + border-radius: 4px; + font-size: 11px; + color: var(--accent-3); + overflow: auto; + white-space: nowrap; +} + +.notes { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem 1.5rem; + margin-bottom: 1rem; +} +.notes h3 { + margin: 0 0 0.6rem; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); +} +.notes p { font-size: 13px; color: var(--text-dim); line-height: 1.6; margin: 0.5rem 0; } +.notes a { color: var(--accent-2); } +.notes code { background: var(--bg-elev-2); padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 12px; } +.notes .diagram { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + font-size: 11px; + color: var(--text); + overflow-x: auto; + line-height: 1.4; + margin: 0.5rem 0; +} diff --git a/packages/playground/public/demos/formula.html b/packages/playground/public/demos/formula.html new file mode 100644 index 0000000..2a8bea5 --- /dev/null +++ b/packages/playground/public/demos/formula.html @@ -0,0 +1,460 @@ + + + + + Dhamaka · formula editor demo (erp.ai) + + + + + + + +
+ ← all demos +

formula editor (erp.ai-style)

+

+ Click a cell below to select it. The formula bar shows the formula. + Type a natural-language instruction in the ask AI box and the + formula rewrites in place — entirely locally, entirely synchronously. + Every transformation in this demo is done by a pattern-match layer + in Transform.formula(), so there's no model call and no + network hit at all. +

+ Try: add a 10% discount for employees, + apply 8% tax, + round to 2 decimals, + handle empty cells, + wrap in iferror, + multiply by 1.5, + take absolute value. +

+ +
+
+
A1
+ +
+ + + + + + + + +
ABCDE
+
+ +
+ ✦ ask AI + + +
+ +
+ add a 10% discount + apply 8% tax + round to 2 decimals + null-safe + wrap in iferror + multiply by 1.5 + abs + negate + convert to EUR +
+ +
+
before
+
after
+
source
+
whyselect a cell with a formula and ask the AI to change it
+
+ +
+

what's happening

+
+  click cell → select → formula bar shows formula
+       │
+       ▼
+  type instruction in ask-AI → Transform.formula(input, instruction)
+       │
+       ├─ fast path: 10 pattern rewrites
+       │              (discount, tax, round, null-safe, iferror,
+       │               multiply, divide, abs, negate, currency)
+       │
+       └─ slow path: LLM fallback (not needed for this demo)
+       ▼
+  structured result: { output, source, confidence, explanation }
+  cell gets the new formula, before/after panel updates, flash animation
+        
+

+ Every transformation you see here is pattern-rewritten structurally + in microseconds. Open DevTools → Network: nothing goes out. Unplug + your internet: it still works. +

+

+ The same Transform.formula() call falls through to an + on-device LLM for instructions the rules can't match. That path + isn't exercised in this demo (the shipping v0.1 weights are a tiny + random-init model, not real enough yet to write formulas) but when + the real SmolLM2-360M weights arrive, the same code transparently + handles the long tail. +

+
+
+ + + + diff --git a/packages/playground/public/demos/paste.html b/packages/playground/public/demos/paste.html new file mode 100644 index 0000000..1c3bea6 --- /dev/null +++ b/packages/playground/public/demos/paste.html @@ -0,0 +1,126 @@ + + + + + Dhamaka · smart paste demo + + + + + + +
+ ← all demos +

smart paste

+

+ Paste a block of contact info into the drop zone below — a business + card, an email signature, a LinkedIn blurb, whatever. The form fields + populate themselves as the paste event is processed. No field is + overwritten if you've typed something there first. +

+ Try pasting: +

+
Jane Doe
+Senior Platform Engineer
+Acme Corp
+jane.doe@acme.com
++1 (415) 555-1234
+https://acme.com
+@janedoe
+ +
+

contact

+
+ paste a business card or signature here +
+ (or anywhere inside the form) +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + last extraction: + 0 fields +  ·  + source: + + confidence: +
+
+ +
+

what's happening

+
+  onpaste → attachSmartPaste → runTask("paste-extract")
+       │
+       ├─ regex: emails, phones, URLs, @handles  ← 0.3 ms
+       ├─ heuristic: 2–4 cap-case words = name   ← 0.1 ms
+       ├─ rule: non-freemail domain → company    ← 0.1 ms
+       └─ model: fill in name/title/address      ← (planned)
+
+  Fields only populate where the user hasn't typed anything.
+        
+
+
+ + + + diff --git a/packages/playground/public/demos/spellcheck.html b/packages/playground/public/demos/spellcheck.html new file mode 100644 index 0000000..9edd9fd --- /dev/null +++ b/packages/playground/public/demos/spellcheck.html @@ -0,0 +1,361 @@ + + + + + Dhamaka · real on-device spellcheck via Transformers.js + + + + + + + +
+ ← all demos +

on-device spellcheck

+

+ Type prose into the textarea below. Every time you stop typing for + a moment, Dhamaka hands each word to an on-device masked language + model running inside this browser tab and asks "what should + go here?". Words the model considers unlikely in context are flagged. + No rules, no hardcoded dictionary, no server — a real LLM reading + your prose word by word. +

+ +

+ Try a real sentence with typos: + + + +
+ + Masked-LM spellcheck works best on real prose with + real misspellings. Pure gibberish like asdsd qwdqd + gets flagged correctly, but the suggestions for it will be + nonsense too — there's no meaningful context for the model to + predict from. That's a property of the algorithm, not a bug. + +

+ +
+
+ + warming up the model… +
+
+ First visit on this device downloads a ~65 MB masked language + model (Xenova/distilbert-base-uncased). It's cached + in your browser's IndexedDB forever after — every future visit + is instant and works offline. 10–30 seconds on typical broadband, + once. +
+
+
+
+ The model runs through @huggingface/transformers, + loaded lazily from esm.sh. Dhamaka wraps it behind the + same task / SmartField / Transform API every other demo uses — the + runtime underneath is pluggable, the product layer doesn't move. +
+
+ +
+

draft

+ +
no issues yet
+
+ + suggestions: + 0 +  ·  + source: + + last call — ms +
+
+ +
+

what's happening under the hood

+
+  oninput (debounced 600ms) → SmartText → runTask("spellcheck", { eager: true })
+       │
+       ▼
+  spellcheckTask.slow(text, context, engine)
+       │
+       ├─ tokenize input into words
+       ├─ for each word:
+       │     ├─ build "…prefix [MASK] suffix…"
+       │     ├─ engine.fillMask(masked, top_k=20)  ← distilBERT via
+       │     │                                        Transformers.js,
+       │     │                                        runs in WASM
+       │     └─ if original word not in top-20 → flag as misspelling,
+       │        top predictions become corrections
+       │
+       └─ return structured { from, to, alternatives, index } list
+
+  Nothing leaves the tab. No server, no API key, no rate limit.
+  First visit downloads ~65 MB once, cached in IndexedDB forever.
+  Per-call latency: ~100–300 ms per masked word on a laptop.
+        
+

+ The formula demo still keeps its pattern rewrites (discounts, + taxes, rounding, etc.) because those have objectively-correct + structural answers and rules are a legitimate performance path there. + Spellcheck is the opposite: probabilistic, context-dependent, long- + tail. Rules there would contradict the thesis, so they're gone. +

+

+ If your browser supports Chrome's window.ai Prompt API + (Gemini Nano), Dhamaka will prefer that over Transformers.js — it's + free, pre-downloaded, and GPU-accelerated. On every other browser + you get Transformers.js. Same SDK, same task, same surface. +

+
+
+ + + + diff --git a/packages/playground/public/demos/us-tax.html b/packages/playground/public/demos/us-tax.html new file mode 100644 index 0000000..32e9ec3 --- /dev/null +++ b/packages/playground/public/demos/us-tax.html @@ -0,0 +1,659 @@ + + + + + Dhamaka · US Tax Calculator + + + + + + + +
+ ← all demos +

US Tax Calculator

+

+ Build a sales invoice below. Tax is computed instantly from a 50-state + rate table with product-category exemptions — no network call, no model. + Switch to Use Tax to apply buyer-state rates instead.

+ Try: grocery items in NY or CA (exempt), + clothing in PA or MN (exempt), + medicine anywhere (exempt). Compare TN (9.55% avg) vs + OR (0%). +

+ + +
+

sales invoice

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + + + + + + + + + + + + +
productqtyunit pricecategorytotal
+ +
+ +
+
+ + +
+
tax breakdown — select a state
+
+ subtotal + $0.00 +
+
+ + state tax + + + $0.00 +
+
+ + county/local avg + + + $0.00 +
+
+ total tax + $0.00 +
+
+ grand total + $0.00 +
+
+ +
+ + +
+

federal income tax — 2024

+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
tax owed
+
+
taxable income: —
+
+
+
effective rate
+
+
std deduction: —
+
+
+
marginal rate
+
+
top bracket
+
+
+ + +
+
bracket breakdown
+ + + + + + + + + + +
rateincome rangetaxable in brackettax
+
+ +
+

what's happening

+
+  oninput → runTask("us-sales-tax", JSON.stringify(items), { context })
+       │
+       ├─ fast: STATE_TAX.get(stateCode)    ← 0.01 ms
+       ├─ per-item: effectiveRate(category) ← exemption + reducedRate table
+       └─ sum: subtotal / stateTax / countyTax / grandTotal
+
+  oninput → runTask("us-federal-tax", income, { context })
+       │
+       ├─ fast: BRACKETS_2024[filingStatus] ← 0.01 ms
+       ├─ standardDeduction subtracted
+       └─ marginal bracket walk → taxOwed, effectiveRate, marginalRate
+
+  50 states · 5 product categories · all rules-first · zero network
+        
+
+
+ + + + diff --git a/packages/playground/public/index.html b/packages/playground/public/index.html index 68b5173..501e8a0 100644 --- a/packages/playground/public/index.html +++ b/packages/playground/public/index.html @@ -2,21 +2,11 @@ - Dhamaka — Browser-Native LLM + Dhamaka — on-device reflexes for every input field - + - - +
@@ -29,88 +19,99 @@ |____/|_| |_|\__,_|_| |_| |_|\__,_|_|\_\__,_|
- browser-native LLM · download once · run anywhere + reflex layer for every input · on-device · zero latency
-
- - booting… -
- + Transform.formula(input, instruction) + -
-
-
-
system
-
- Welcome to Dhamaka. Click load on the left to pull the - default model. On your first visit you'll see a download; every - visit after that (on any Dhamaka-powered site) should be an - instant cache hit. -
-
-
+ +
+

US Tax Calculator

+

+ Build a sales invoice — state, product category, line items. + Tax computes instantly from a 50-state rate table with grocery, + clothing, and medicine exemptions. Federal income tax brackets too. +

+ runTask("us-sales-tax", cart, { context }) +
+
-
-
-
-
+
+

The architecture

+
+  every <input> and <textarea>
+            │
+            ▼
+     [ SmartField ]   ←   rules-first (<1 ms, always runs)
+            │
+            ▼
+       [ reflex ]     ←   resident engine (warm, KV-cached)
+            │
+            ▼
+        [ engine ]    ←   window.ai → WASM → MockEngine
+                          (task-specific models, ~50 ms cold)
+        
+

+ Every task layers rules → fuzzy → model. Most real inputs never + touch the model at all — they're answered by a lookup table or a + regex in microseconds. The model only runs when the fast path is + uncertain, and when it does, it's resident in the page, not on a + server. +

+
-
- - -
+
+

Looking for the old chat demo?

+

+ It's here →. That's the low-level + Dhamaka.load() API (direct access to the runtime). + It still works, but for most use cases the SmartField + primitives above are what you want. +

- - diff --git a/packages/playground/public/styles.css b/packages/playground/public/styles.css index 9487efe..d90bac2 100644 --- a/packages/playground/public/styles.css +++ b/packages/playground/public/styles.css @@ -232,6 +232,8 @@ a { color: var(--accent-2); } } @keyframes blink { 50% { opacity: 0; } } +.content.aborted { opacity: .7; font-style: italic; } + /* ─── Progress bar ─────────────────────────────────────────────────────── */ .progress { diff --git a/packages/playground/server.js b/packages/playground/server.js index 7505e2b..3c81d78 100644 --- a/packages/playground/server.js +++ b/packages/playground/server.js @@ -76,6 +76,10 @@ function staticHandler({ label, base, mounts = {} }) { "cache-control": "no-store", // Allow the hub iframe to be embedded by the playground origin. "cross-origin-resource-policy": "cross-origin", + // Allow cross-origin fetches (the SDK on :5173 pulls the .wasm + // runtime from the hub origin on :5174). Without this, + // WebAssembly.instantiateStreaming refuses to run the module. + "access-control-allow-origin": "*", }); res.end(data); log(label, req.method, pathname, 200); diff --git a/packages/runtime/src/factory.js b/packages/runtime/src/factory.js index 92348f4..21ebbeb 100644 --- a/packages/runtime/src/factory.js +++ b/packages/runtime/src/factory.js @@ -1,20 +1,56 @@ -// Pick a backend based on environment capabilities and user preference. +// Pick an inference backend based on environment capabilities. +// +// Priority (highest first): +// 1. window.ai — Chrome Prompt API / Gemini Nano (resident, free, fastest when present) +// 2. transformers — @huggingface/transformers, real cross-browser LLM runtime +// 3. wasm — our compiled Rust runtime (v2 target, not yet competitive) +// 4. mock — deterministic stand-in for Node / tests / dev +// +// `createEngine({ backend: "auto" })` picks the first one that works in the +// current environment. Callers can force a specific backend by passing +// `backend: "mock" | "wasm" | "window-ai" | "transformers"`. import { MockEngine } from "./mock-engine.js"; import { WasmEngine } from "./wasm-engine.js"; +import { WindowAiBackend } from "./window-ai-backend.js"; +import { TransformersBackend } from "./transformers-backend.js"; /** * @param {object} options - * @param {"auto"|"mock"|"wasm"} [options.backend="auto"] - * @param {string} [options.wasmUrl] + * @param {"auto"|"mock"|"wasm"|"window-ai"|"transformers"} [options.backend="auto"] + * @param {string} [options.wasmUrl] + * @param {string} [options.model] Transformers.js HF model id + * @param {string} [options.task] Transformers.js pipeline task + * @param {string} [options.cdn] Transformers.js CDN override + * @param {string} [options.systemPrompt] */ export function createEngine(options = {}) { const backend = options.backend ?? "auto"; if (backend === "mock") return new MockEngine(options); if (backend === "wasm") return new WasmEngine(options); + if (backend === "window-ai") return new WindowAiBackend(options); + if (backend === "transformers") return new TransformersBackend(options); - // auto: prefer wasm if a url is configured, otherwise fall back to mock. + // auto: prefer window.ai → transformers → wasm → mock. + // + // window.ai is the fastest (shared with the browser, GPU-accelerated) + // but Chrome-only at the moment. + // transformers is the primary cross-browser runtime today — real models, + // real quantization, real tokenization, none of which we want to + // reimplement from scratch. + // wasm is our Rust runtime. It's still here but it's a v2 swap target + // right now (no real weights, no SIMD, no quantization yet). + // mock is the Node / test-only stand-in. + if (WindowAiBackend.isAvailable()) return new WindowAiBackend(options); + if (TransformersBackend.isAvailable()) return new TransformersBackend(options); if (options.wasmUrl) return new WasmEngine(options); + if ( + typeof WebAssembly !== "undefined" && + typeof fetch === "function" && + typeof window !== "undefined" + ) { + return new WasmEngine(options); + } return new MockEngine(options); } diff --git a/packages/runtime/src/index.js b/packages/runtime/src/index.js index 12a1e0d..88eda1f 100644 --- a/packages/runtime/src/index.js +++ b/packages/runtime/src/index.js @@ -1,11 +1,14 @@ // @dhamaka/runtime — inference engine entry point. // // The runtime exposes a single small interface, Engine, that every backend -// (real WASM, WebGPU, or the mock dev engine) must implement. The SDK talks -// only to this interface, so swapping engines is a one-line change. +// (Chrome window.ai, our Rust WASM runtime, or the mock dev engine) must +// implement. The SDK talks only to this interface, so swapping engines is +// a one-line change. export { Engine } from "./engine.js"; export { MockEngine } from "./mock-engine.js"; export { WasmEngine } from "./wasm-engine.js"; +export { WindowAiBackend } from "./window-ai-backend.js"; +export { TransformersBackend } from "./transformers-backend.js"; export { Tokenizer } from "./tokenizer.js"; export { createEngine } from "./factory.js"; diff --git a/packages/runtime/src/transformers-backend.js b/packages/runtime/src/transformers-backend.js new file mode 100644 index 0000000..15a85c3 --- /dev/null +++ b/packages/runtime/src/transformers-backend.js @@ -0,0 +1,291 @@ +// TransformersBackend — real cross-browser LLM inference via @huggingface/transformers. +// +// This is the primary runtime for Dhamaka in 2026. It wraps the HuggingFace +// Transformers.js library (`@huggingface/transformers`, the v3+ rename of +// `@xenova/transformers`) and exposes it through the same `Engine` interface +// every other backend implements, so swapping it in is a factory-priority +// change. +// +// Why this layer exists: +// +// - HuggingFace's team has spent years on the three hardest parts of running +// LLMs in a browser: quantization, BPE tokenization, and the ONNX runtime +// backend with SIMD/WebGPU acceleration. We are not going to beat them on +// any of those three, and we shouldn't try. We own the product layer above +// (SmartField, SmartForm, SmartText, Transform, the task registry, the +// cross-site cache, the extension). They own the runtime. Clean separation. +// +// - Transformers.js supports hundreds of models, including the specific ones +// Dhamaka needs: distilBERT-style masked LMs for spellcheck, SmolLM2 for +// generic text completion, MiniLM for embeddings. We pick the right model +// per task instead of shipping one giant generalist. +// +// - The import is lazy. Transformers.js is ~2 MB gzipped and we don't want +// every consumer site to pay that cost. This backend dynamically imports +// it from `esm.sh` the first time an engine is instantiated, so sites that +// never touch an LLM (e.g. pages that only use rules-first Transform tasks +// like formula-transform) don't pay the bundle cost at all. +// +// - First-visit model downloads are cached by Transformers.js itself in +// IndexedDB. Subsequent visits to the same origin are instant. The Dhamaka +// hub still adds cross-site sharing on top of that (a v0.2 concern — the +// hub's TransformersCacheAdapter routes Transformers.js's cache through +// our shared origin). +// +// Honest tradeoffs this commit accepts: +// +// - Users see a one-time ~60–140 MB download on first visit per model (the +// exact size depends on which quantization Transformers.js picks for the +// browser: WebGPU → fp16, WASM+SIMD → q8, WASM no-SIMD → q4). +// - A dynamic import from a CDN means the site has a non-zero hard dependency +// on esm.sh being up. We mitigate by supporting a user-configurable CDN +// base URL (`transformersCdn` option), so anyone can self-host. +// - Transformers.js's API surface is its own thing; we abstract it behind +// `complete()` / `generate()` so Dhamaka's Engine contract doesn't leak +// their model metadata. + +import { Engine } from "./engine.js"; + +const DEFAULT_CDN = "https://esm.sh/@huggingface/transformers@3"; + +// Default models per task family. Chosen to balance size vs quality on a +// laptop-class device with no GPU. Every one of these is on the Xenova +// mirror or the HuggingFaceTB org, both of which Transformers.js treats +// as first-class. +const DEFAULT_MODELS = { + // Generic text generation / chat / completion. + "text-generation": "HuggingFaceTB/SmolLM2-135M-Instruct", + // Instruction following for Transform family (formula-explain, rewrites). + "text2text-generation": "Xenova/LaMini-Flan-T5-248M", + // Masked LM for spellcheck and contextual token replacement. + "fill-mask": "Xenova/distilbert-base-uncased", + // Sentence embeddings for semantic search and fuzzy field matching. + "feature-extraction": "Xenova/all-MiniLM-L6-v2", +}; + +let _cachedModule = null; +async function loadTransformers(cdnUrl) { + if (_cachedModule) return _cachedModule; + // Dynamic import so the import itself is lazy; esm.sh serves Transformers.js + // as an ES module with a `pipeline` named export. + _cachedModule = await import(/* @vite-ignore */ cdnUrl); + return _cachedModule; +} + +export class TransformersBackend extends Engine { + /** + * @param {object} [options] + * @param {string} [options.model] HF model id. Picks a family default if omitted. + * @param {"text-generation"|"text2text-generation"|"fill-mask"|"feature-extraction"} [options.task] + * Which pipeline to run. Default: "text-generation" (generic completion). + * @param {string} [options.cdn] Override the CDN used to load Transformers.js + * @param {object} [options.pipelineOptions] Passed through to Transformers.js `pipeline()` + * @param {"fp32"|"fp16"|"q8"|"q4"} [options.dtype] Explicit quant preference (defaults to auto) + * @param {"wasm"|"webgpu"|"auto"} [options.device] Backend preference (defaults to auto) + * @param {(p: { status: string; progress?: number; file?: string; loaded?: number; total?: number }) => void} [options.onProgress] + */ + constructor(options = {}) { + super(); + this.options = options; + this.cdn = options.cdn ?? DEFAULT_CDN; + this.task = options.task ?? "text-generation"; + this.model = options.model ?? DEFAULT_MODELS[this.task] ?? DEFAULT_MODELS["text-generation"]; + this.dtype = options.dtype ?? undefined; + this.device = options.device ?? undefined; + this.pipelineOptions = options.pipelineOptions ?? {}; + this.onProgress = options.onProgress ?? null; + this._pipeline = null; + } + + static isAvailable() { + // Transformers.js needs DOM + fetch. That means browsers only. + // Node has it via a different subpath but Dhamaka uses MockEngine in Node. + return ( + typeof globalThis.window !== "undefined" && + typeof globalThis.document !== "undefined" && + typeof globalThis.fetch === "function" + ); + } + + async load({ entry } = {}) { + if (!TransformersBackend.isAvailable()) { + throw new Error( + "TransformersBackend: only supported in browsers (requires DOM + fetch). " + + "Use MockEngine or the real WasmEngine in non-browser environments.", + ); + } + + const { pipeline } = await loadTransformers(this.cdn); + if (typeof pipeline !== "function") { + throw new Error( + `TransformersBackend: loaded ${this.cdn} but it has no pipeline() export. ` + + "Check the CDN URL.", + ); + } + + // Transformers.js progress callback shape: + // { status: "download" | "progress" | "ready", file, loaded, total, progress } + // We forward verbatim to the caller. + const progressCallback = this.onProgress + ? (event) => { + try { + this.onProgress(event); + } catch { + /* never let a caller error break the load */ + } + } + : undefined; + + this._pipeline = await pipeline(this.task, this.model, { + dtype: this.dtype, + device: this.device, + progress_callback: progressCallback, + ...this.pipelineOptions, + }); + + // Cache the model's mask token string (e.g. [MASK] for BERT-family, + // for RoBERTa-family). fill-mask callers need to know what + // token to substitute into their input. + try { + this._maskToken = + this._pipeline.tokenizer?.mask_token ?? + this._pipeline.model?.config?.mask_token ?? + "[MASK]"; + } catch { + this._maskToken = "[MASK]"; + } + + this._entry = entry ?? { id: this.model, params: this.task }; + this.loaded = true; + } + + /** The model's mask token string, or null if this isn't a fill-mask pipeline. */ + get maskToken() { + return this.task === "fill-mask" ? this._maskToken : null; + } + + async complete(prompt, options = {}) { + if (!this.loaded) { + throw new Error("TransformersBackend: load() must be called before complete()"); + } + + // Dispatch by task. Different Transformers.js pipelines have different + // input/output shapes, and we normalise to a string. + if (this.task === "fill-mask") { + // complete() on a fill-mask pipeline returns a JSON-stringified array + // of top-K predictions. Callers who want structured results should + // use fillMask() directly. + const results = await this.fillMask(prompt, options.topK ?? 10); + return JSON.stringify(results); + } + if (this.task === "feature-extraction") { + // Embeddings aren't text; callers should use embed() instead. Return + // a stringified vector as a fallback so we don't silently break. + const vector = await this.embed(prompt); + return JSON.stringify(vector); + } + + // text-generation / text2text-generation + const max_new_tokens = options.maxTokens ?? 256; + const temperature = options.temperature ?? 0.2; + const top_k = options.topK ?? 40; + const top_p = options.topP ?? 0.95; + + const result = await this._pipeline(prompt, { + max_new_tokens, + temperature, + top_k, + top_p, + do_sample: temperature > 0, + return_full_text: false, + }); + + // Transformers.js returns [{ generated_text: "..." }] or { generated_text: "..." } + const first = Array.isArray(result) ? result[0] : result; + const text = first?.generated_text ?? first?.translation_text ?? first?.summary_text ?? ""; + return String(text).trim(); + } + + async *generate(prompt, options = {}) { + if (!this.loaded) { + throw new Error("TransformersBackend: load() must be called before generate()"); + } + // Transformers.js supports token streaming via TextStreamer, but the API + // shape varies across versions. For v0.2 we degrade to "await complete, + // then yield the whole string" which keeps the async iterator contract + // intact without chasing streaming internals. Real token streaming is a + // follow-up. + const signal = options.signal; + const text = await this.complete(prompt, options); + if (signal?.aborted) return; + yield text; + } + + /** + * Masked-LM prediction. `input` must contain the model's mask token + * (accessible via `this.maskToken`, typically `[MASK]` for BERT-family). + * + * Returns an array of { token, score } objects, sorted by score desc. + * For multi-mask input, returns a flat array of the first mask's top-K + * (the typical spellcheck use case masks one word at a time). + * + * @param {string} input + * @param {number} [topK=10] + * @returns {Promise>} + */ + async fillMask(input, topK = 10) { + if (!this.loaded) { + throw new Error("TransformersBackend.fillMask: load() must be called first"); + } + if (this.task !== "fill-mask") { + throw new Error( + `TransformersBackend.fillMask: this engine was loaded with task="${this.task}", ` + + `not "fill-mask". Create a separate TransformersBackend for masked-LM tasks.`, + ); + } + const result = await this._pipeline(input, { top_k: topK }); + + // Transformers.js returns one of: + // [{ score, token, token_str, sequence }, ...] (single mask) + // [[{ ... }, ...], [{ ... }, ...]] (multi-mask) + const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; + return (list || []).map((r) => ({ + token: String(r.token_str ?? "").trim(), + score: Number(r.score ?? 0), + })); + } + + /** Sentence embeddings. Returns a plain JS array of floats. */ + async embed(text) { + if (!this.loaded || this.task !== "feature-extraction") { + throw new Error( + "TransformersBackend.embed() requires task: 'feature-extraction'", + ); + } + const result = await this._pipeline(text, { + pooling: "mean", + normalize: true, + }); + // `result` is a Tensor; .data is a TypedArray. + return Array.from(result.data); + } + + async unload() { + // Transformers.js pipelines don't have a documented dispose() for the + // wasm/webgpu memory. We drop the reference and let GC handle it. + this._pipeline = null; + await super.unload(); + } + + info() { + return { + ...super.info(), + backend: "transformers.js", + model: this.model, + task: this.task, + dtype: this.dtype ?? "auto", + device: this.device ?? "auto", + cdn: this.cdn, + }; + } +} diff --git a/packages/runtime/src/wasm-engine.js b/packages/runtime/src/wasm-engine.js index 141df26..1a6d12c 100644 --- a/packages/runtime/src/wasm-engine.js +++ b/packages/runtime/src/wasm-engine.js @@ -1,125 +1,177 @@ -// WasmEngine — the real one. +// WasmEngine — the real Rust-backed inference engine. // -// This is the seam where the compiled WebAssembly inference runtime plugs in. -// The actual WASM module (Rust → wasm32-unknown-unknown, SIMD enabled, with -// an optional WebGPU fast path) is under construction. Until it lands, this -// file documents the exact interface the module must expose and provides a -// loader that will Just Work™ once the .wasm drops into place. +// Loads the compiled Dhamaka runtime (`dhamaka-runtime.wasm`, built from +// the `crates/dhamaka-runtime` Rust crate), instantiates it, and drives +// generation through the C ABI documented in `crates/dhamaka-runtime/src/abi.rs`: // -// The planned ABI (candle/llama.cpp-style, kept intentionally small): +// dhamaka_version() -> u32 +// dhamaka_alloc(len) -> *mut u8 +// dhamaka_free(ptr, len) -> void +// dhamaka_init(w, wl, c, cl) -> *mut Context +// dhamaka_destroy(ctx) -> void +// dhamaka_set_sampling(ctx, t, k, p, m) -> void +// dhamaka_feed_prompt(ctx, ptr, len) -> void +// dhamaka_next_token(ctx, out, cap) -> i32 (-1 on EOS) +// dhamaka_reset(ctx) -> void // -// dhamaka_init(weights_ptr, weights_len, config_ptr, config_len) -> ctx -// dhamaka_tokenize(ctx, text_ptr, text_len) -> { tokens_ptr, tokens_len } -// dhamaka_feed(ctx, tokens_ptr, tokens_len) -> void -// dhamaka_sample(ctx, temperature, top_p, top_k) -> token_id -// dhamaka_detokenize(ctx, token_id) -> { text_ptr, text_len } -// dhamaka_reset(ctx) -> void -// dhamaka_free(ctx) -> void -// -// Memory is managed with a bump allocator exposed through dhamaka_alloc / -// dhamaka_free_bytes so the JS side can hand large buffers in without copies. +// JS writes prompt bytes into WASM linear memory via `dhamaka_alloc`, then +// loops on `dhamaka_next_token` to stream UTF-8 token bytes back out. import { Engine } from "./engine.js"; -import { Tokenizer } from "./tokenizer.js"; + +const ABI_VERSION = 1; +const DEFAULT_WASM_URL = "/runtime/dhamaka-runtime.wasm"; export class WasmEngine extends Engine { constructor(options = {}) { super(); - this.wasmUrl = options.wasmUrl ?? null; - this._module = null; + this.wasmUrl = options.wasmUrl ?? DEFAULT_WASM_URL; this._instance = null; this._ctx = 0; - this.tokenizer = new Tokenizer(); + this._decoder = new TextDecoder(); + this._encoder = new TextEncoder(); } async _instantiate() { if (this._instance) return this._instance; - if (!this.wasmUrl) { + const res = await fetch(this.wasmUrl); + if (!res.ok) { throw new Error( - "WasmEngine: no WASM module configured. The Dhamaka WASM runtime is still " + - "being built — use MockEngine for development, or pass { wasmUrl } once " + - "the real module is available.", + `WasmEngine: failed to fetch ${this.wasmUrl} (${res.status}). ` + + `Did you run crates/dhamaka-runtime/build.sh?`, ); } - const res = await fetch(this.wasmUrl); - if (!res.ok) throw new Error(`WasmEngine: fetch failed: ${res.status}`); - const { instance, module } = await WebAssembly.instantiateStreaming(res, { + const imports = { env: { - // Host imports the WASM module may call into. Kept deliberately minimal. - abort: (msg, file, line, col) => { - throw new Error(`wasm abort at ${file}:${line}:${col} (${msg})`); - }, - now: () => performance.now(), - log: (ptr, len) => { - // Optional diagnostic channel — noop by default. - void ptr; void len; + // The Rust crate is pure compute — no host imports required. We + // still provide stubs for any panic/abort that leaks through. + abort: () => { + throw new Error("wasm: abort"); }, }, - }); - this._module = module; + }; + const { instance } = await WebAssembly.instantiateStreaming + ? await WebAssembly.instantiateStreaming(res, imports) + : await WebAssembly.instantiate(await res.arrayBuffer(), imports); + + const got = instance.exports.dhamaka_version?.() >>> 0; + if (got !== ABI_VERSION) { + throw new Error( + `WasmEngine: ABI mismatch. Expected ${ABI_VERSION}, got ${got}`, + ); + } this._instance = instance; return instance; } + _memory() { + return new Uint8Array(this._instance.exports.memory.buffer); + } + + _writeBytes(bytes) { + if (bytes == null || bytes.byteLength === 0) return { ptr: 0, len: 0 }; + const { dhamaka_alloc } = this._instance.exports; + const ptr = dhamaka_alloc(bytes.byteLength) >>> 0; + this._memory().set(bytes, ptr); + return { ptr, len: bytes.byteLength }; + } + + _freeBytes(ptr, len) { + if (!ptr || !len) return; + this._instance.exports.dhamaka_free(ptr, len); + } + async load({ entry, artifacts } = {}) { const inst = await this._instantiate(); - const { dhamaka_init, dhamaka_alloc } = inst.exports; - if (!dhamaka_init || !dhamaka_alloc) { - throw new Error("WasmEngine: module is missing required exports"); - } - - const weights = artifacts?.weights; - const config = artifacts?.config; - if (!weights || !config) { - throw new Error("WasmEngine: artifacts.weights and artifacts.config required"); - } + const { dhamaka_init } = inst.exports; - const wPtr = dhamaka_alloc(weights.byteLength); - const cPtr = dhamaka_alloc(config.byteLength); - const mem = new Uint8Array(inst.exports.memory.buffer); - mem.set(weights, wPtr); - mem.set(config, cPtr); + // v0.1 of the runtime uses a deterministic random model seeded from the + // config bytes. When real weights arrive, they flow through the same + // entry point unchanged. + const weightsBytes = artifacts?.weights ?? new Uint8Array(); + const configBytes = + artifacts?.config ?? this._encoder.encode(entry?.id ?? "dhamaka-micro"); - this._ctx = dhamaka_init(wPtr, weights.byteLength, cPtr, config.byteLength); - if (!this._ctx) throw new Error("WasmEngine: dhamaka_init returned null"); + const w = this._writeBytes(weightsBytes); + const c = this._writeBytes(configBytes); - if (artifacts?.tokenizer) { - await this.tokenizer.loadFromBytes(artifacts.tokenizer); + this._ctx = dhamaka_init(w.ptr, w.len, c.ptr, c.len) >>> 0; + if (!this._ctx) { + throw new Error("WasmEngine: dhamaka_init returned null"); } + + // Free the temporary input buffers — the runtime has copied what it + // needs. + this._freeBytes(w.ptr, w.len); + this._freeBytes(c.ptr, c.len); + this._entry = entry ?? null; this.loaded = true; } - async *generate(_prompt, _options = {}) { - // Intentionally routed through the real ABI once the module is in place. - // Implementation sketch: - // - // const tokens = tokenizer.encode(prompt) - // dhamaka_feed(ctx, tokens) - // while (emitted < maxTokens && !signal.aborted) { - // const id = dhamaka_sample(ctx, temperature, topP, topK) - // if (isEos(id)) return - // yield tokenizer.decode(id) - // emitted++ - // } - throw new Error( - "WasmEngine.generate() is not implemented yet. The Dhamaka WASM runtime is " + - "under construction. Use MockEngine for now.", - ); + async *generate(prompt, options = {}) { + if (!this.loaded || !this._ctx) { + throw new Error("WasmEngine: load() must be called before generate()"); + } + const inst = this._instance; + const { + dhamaka_set_sampling, + dhamaka_feed_prompt, + dhamaka_next_token, + dhamaka_reset, + } = inst.exports; + + const temperature = options.temperature ?? 0.7; + const topK = options.topK ?? 40; + const topP = options.topP ?? 0.95; + const maxTokens = options.maxTokens ?? 256; + const signal = options.signal; + + dhamaka_reset(this._ctx); + dhamaka_set_sampling(this._ctx, temperature, topK, topP, maxTokens); + + // Feed the prompt. + const promptBytes = this._encoder.encode(prompt ?? ""); + const p = this._writeBytes(promptBytes); + try { + dhamaka_feed_prompt(this._ctx, p.ptr, p.len); + } finally { + this._freeBytes(p.ptr, p.len); + } + + // Stream tokens. Each call writes up to OUT_CAP bytes into a scratch + // buffer we hand to the runtime, then we decode as UTF-8 and yield. + const OUT_CAP = 64; + const outPtr = inst.exports.dhamaka_alloc(OUT_CAP) >>> 0; + try { + while (true) { + if (signal?.aborted) return; + const n = dhamaka_next_token(this._ctx, outPtr, OUT_CAP); + if (n < 0) return; // EOS / max tokens + if (n === 0) continue; + const bytes = this._memory().slice(outPtr, outPtr + n); + yield this._decoder.decode(bytes, { stream: true }); + } + } finally { + this._freeBytes(outPtr, OUT_CAP); + } } async unload() { - const inst = this._instance; - if (inst && this._ctx && inst.exports.dhamaka_free) { - inst.exports.dhamaka_free(this._ctx); + if (this._instance && this._ctx) { + this._instance.exports.dhamaka_destroy(this._ctx); } this._ctx = 0; this._instance = null; - this._module = null; await super.unload(); } info() { - return { ...super.info(), backend: "wasm" }; + return { + ...super.info(), + backend: "wasm", + wasmUrl: this.wasmUrl, + abiVersion: ABI_VERSION, + }; } } diff --git a/packages/runtime/src/window-ai-backend.js b/packages/runtime/src/window-ai-backend.js new file mode 100644 index 0000000..9644883 --- /dev/null +++ b/packages/runtime/src/window-ai-backend.js @@ -0,0 +1,99 @@ +// @dhamaka/runtime — window.ai backend. +// +// Chrome 138+ ships Gemini Nano as a resident on-device model accessible +// via the Prompt API (`window.ai.languageModel`). When the API is present +// we should prefer it: the model is already downloaded, it's shared across +// every origin the user visits, and the forward pass runs at GPU speeds +// we can't match in pure WASM. +// +// This adapter wraps the Prompt API in the same Engine interface every +// other backend speaks, so the factory can pick it automatically. +// +// Docs: https://developer.chrome.com/docs/ai/prompt-api + +import { Engine } from "./engine.js"; + +export class WindowAiBackend extends Engine { + constructor(options = {}) { + super(); + this.session = null; + this.systemPrompt = options.systemPrompt ?? null; + } + + static isAvailable() { + return ( + typeof globalThis.window !== "undefined" && + typeof globalThis.window.ai?.languageModel?.create === "function" + ); + } + + async load({ entry } = {}) { + if (!WindowAiBackend.isAvailable()) { + throw new Error("WindowAiBackend: window.ai is not available in this environment"); + } + const capabilities = await window.ai.languageModel.capabilities?.(); + if (capabilities && capabilities.available === "no") { + throw new Error("WindowAiBackend: the browser reports no on-device model is available"); + } + this.session = await window.ai.languageModel.create( + this.systemPrompt ? { systemPrompt: this.systemPrompt } : {}, + ); + this._entry = entry ?? null; + this.loaded = true; + } + + async complete(prompt, _options) { + if (!this.loaded) { + throw new Error("WindowAiBackend: load() must be called before complete()"); + } + return await this.session.prompt(prompt); + } + + async *generate(prompt, options = {}) { + if (!this.loaded) { + throw new Error("WindowAiBackend: load() must be called before generate()"); + } + const signal = options.signal; + if (typeof this.session.promptStreaming === "function") { + const stream = await this.session.promptStreaming(prompt); + const reader = stream.getReader?.(); + if (reader) { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) return; + yield typeof value === "string" ? value : String(value ?? ""); + } + return; + } + // Async iterable form + for await (const chunk of stream) { + if (signal?.aborted) return; + yield typeof chunk === "string" ? chunk : String(chunk ?? ""); + } + return; + } + // No streaming API — degrade to a single chunk. + const result = await this.complete(prompt); + if (signal?.aborted) return; + yield result; + } + + async unload() { + try { + await this.session?.destroy?.(); + } catch { + /* noop */ + } + this.session = null; + await super.unload(); + } + + info() { + return { + ...super.info(), + backend: "window.ai", + resident: true, + }; + } +} diff --git a/packages/runtime/test/factory.test.js b/packages/runtime/test/factory.test.js new file mode 100644 index 0000000..2e74cf7 --- /dev/null +++ b/packages/runtime/test/factory.test.js @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createEngine } from "../src/factory.js"; +import { MockEngine } from "../src/mock-engine.js"; +import { WasmEngine } from "../src/wasm-engine.js"; + +test("createEngine: default backend=auto with no wasmUrl returns MockEngine", () => { + const engine = createEngine(); + assert.ok(engine instanceof MockEngine); +}); + +test("createEngine: backend=mock always returns MockEngine", () => { + assert.ok(createEngine({ backend: "mock" }) instanceof MockEngine); +}); + +test("createEngine: backend=wasm returns WasmEngine", () => { + const engine = createEngine({ backend: "wasm", wasmUrl: "http://x/y.wasm" }); + assert.ok(engine instanceof WasmEngine); +}); + +test("createEngine: backend=auto with wasmUrl prefers WasmEngine", () => { + const engine = createEngine({ wasmUrl: "http://x/y.wasm" }); + assert.ok(engine instanceof WasmEngine); +}); + +test("Engine abstract class cannot be instantiated directly", async () => { + const { Engine } = await import("../src/engine.js"); + assert.throws(() => new Engine(), /abstract/); +}); + +test("WasmEngine: load() fails cleanly when the wasm url is unreachable", async () => { + // Pick a port that will refuse connection so the fetch deterministically + // fails without us needing to mock anything. + const engine = new WasmEngine({ wasmUrl: "http://127.0.0.1:1/nope.wasm" }); + await assert.rejects(() => + engine.load({ + entry: { id: "test" }, + artifacts: { weights: new Uint8Array(), config: new Uint8Array() }, + }), + ); +}); + +test("WasmEngine: info() reports backend=wasm and the configured url", () => { + const engine = new WasmEngine({ wasmUrl: "http://example.test/x.wasm" }); + const info = engine.info(); + assert.equal(info.backend, "wasm"); + assert.equal(info.wasmUrl, "http://example.test/x.wasm"); + assert.equal(info.abiVersion, 1); +}); diff --git a/packages/runtime/test/mock-engine.test.js b/packages/runtime/test/mock-engine.test.js new file mode 100644 index 0000000..0a1f024 --- /dev/null +++ b/packages/runtime/test/mock-engine.test.js @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { MockEngine } from "../src/mock-engine.js"; + +test("MockEngine: throws if generate is called before load", async () => { + const engine = new MockEngine(); + await assert.rejects(async () => { + for await (const _ of engine.generate("hi")) void _; + }, /load\(\) must be called/); +}); + +test("MockEngine: load sets loaded=true and records the entry", async () => { + const engine = new MockEngine({ tokensPerSecond: 1000 }); + await engine.load({ + entry: { id: "dhamaka-micro", params: "360M", quantization: "Q4_K_M", contextLength: 2048 }, + artifacts: { weights: new Uint8Array(16) }, + }); + assert.equal(engine.loaded, true); + const info = engine.info(); + assert.equal(info.id, "dhamaka-micro"); + assert.equal(info.backend, "mock"); + assert.equal(info.tokensPerSecond, 1000); +}); + +test("MockEngine: generate streams tokens and completes", async () => { + const engine = new MockEngine({ tokensPerSecond: 10000 }); + await engine.load({ entry: { id: "t" }, artifacts: {} }); + + const tokens = []; + for await (const token of engine.generate("hello world", { maxTokens: 10 })) { + tokens.push(token); + } + assert.ok(tokens.length > 0, "should yield at least one token"); + assert.ok(tokens.length <= 10, "should respect maxTokens"); + const joined = tokens.join(""); + assert.ok(joined.length > 0); +}); + +test("MockEngine: complete() drains generate() into a single string", async () => { + const engine = new MockEngine({ tokensPerSecond: 10000 }); + await engine.load({ entry: { id: "t" }, artifacts: {} }); + const out = await engine.complete("hello", { maxTokens: 5 }); + assert.equal(typeof out, "string"); + assert.ok(out.length > 0); +}); + +test("MockEngine: generate is deterministic for the same prompt", async () => { + const engine = new MockEngine({ tokensPerSecond: 10000 }); + await engine.load({ entry: { id: "t" }, artifacts: {} }); + const a = await engine.complete("repeat me", { maxTokens: 999 }); + const b = await engine.complete("repeat me", { maxTokens: 999 }); + assert.equal(a, b); +}); + +test("MockEngine: respects AbortSignal", async () => { + const engine = new MockEngine({ tokensPerSecond: 20 }); + await engine.load({ entry: { id: "t" }, artifacts: {} }); + const controller = new AbortController(); + const tokens = []; + const iter = engine.generate("hello there partner", { + maxTokens: 999, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 30); + for await (const t of iter) { + tokens.push(t); + if (tokens.length > 50) break; + } + assert.ok(tokens.length < 50, "abort should stop streaming early"); +}); + +test("MockEngine: unload clears state", async () => { + const engine = new MockEngine(); + await engine.load({ entry: { id: "t" }, artifacts: {} }); + await engine.unload(); + assert.equal(engine.loaded, false); +}); diff --git a/packages/runtime/test/tokenizer.test.js b/packages/runtime/test/tokenizer.test.js new file mode 100644 index 0000000..b465a71 --- /dev/null +++ b/packages/runtime/test/tokenizer.test.js @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Tokenizer } from "../src/tokenizer.js"; + +test("Tokenizer: split() returns an array of pseudo-tokens", () => { + const t = new Tokenizer(); + const out = t.split("hello world"); + assert.ok(Array.isArray(out)); + assert.ok(out.length >= 2); + assert.equal(out.join(""), "hello world"); +}); + +test("Tokenizer: split() preserves leading whitespace on words", () => { + const t = new Tokenizer(); + const out = t.split("a b c"); + assert.equal(out.join(""), "a b c"); +}); + +test("Tokenizer: split() chunks long words into ~3-char pieces", () => { + const t = new Tokenizer(); + const out = t.split("supercalifragilistic"); + // Longer than 4 chars, so should be split into multiple pieces. + assert.ok(out.length > 1); + assert.equal(out.join(""), "supercalifragilistic"); +}); + +test("Tokenizer: split() keeps punctuation", () => { + const t = new Tokenizer(); + const out = t.split("hi, there!"); + assert.equal(out.join(""), "hi, there!"); +}); + +test("Tokenizer: split() on empty input returns empty array", () => { + const t = new Tokenizer(); + assert.deepEqual(t.split(""), []); +}); + +test("Tokenizer: loadFromBytes handles invalid JSON gracefully", async () => { + const t = new Tokenizer(); + await t.loadFromBytes(new TextEncoder().encode("not json")); + assert.equal(t.vocab, null); +}); + +test("Tokenizer: loadFromBytes accepts valid JSON", async () => { + const t = new Tokenizer(); + await t.loadFromBytes(new TextEncoder().encode('{"type":"BPE"}')); + assert.deepEqual(t.vocab, { type: "BPE" }); +}); + +test("Tokenizer: encode/decode throw (WASM-only)", () => { + const t = new Tokenizer(); + assert.throws(() => t.encode("x"), /WASM tokenizer/); + assert.throws(() => t.decode([1]), /WASM tokenizer/); +}); diff --git a/packages/runtime/test/wasm-engine.test.js b/packages/runtime/test/wasm-engine.test.js new file mode 100644 index 0000000..6803ce4 --- /dev/null +++ b/packages/runtime/test/wasm-engine.test.js @@ -0,0 +1,161 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { WasmEngine } from "../src/wasm-engine.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WASM_PATH = join( + __dirname, + "..", + "..", + "hub", + "public", + "runtime", + "dhamaka-runtime.wasm", +); + +// Probe once: if the .wasm isn't there (e.g. fresh checkout without running +// the build script), we skip this test rather than fail. CI builds the wasm +// before running tests, so CI will always exercise it. +async function wasmIsPresent() { + try { + await readFile(WASM_PATH); + return true; + } catch { + return false; + } +} + +// We bypass HTTP by stubbing global fetch to read from disk. That way we can +// exercise the real WasmEngine end-to-end in Node without spinning up a +// server. +function stubFetch(bytes) { + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + void url; + return new Response(bytes, { + status: 200, + headers: { "content-type": "application/wasm" }, + }); + }; + return () => { + globalThis.fetch = original; + }; +} + +test("WasmEngine: loads the compiled Dhamaka runtime end-to-end", async (t) => { + if (!(await wasmIsPresent())) { + t.skip( + "dhamaka-runtime.wasm not found; run crates/dhamaka-runtime/build.sh first", + ); + return; + } + const bytes = await readFile(WASM_PATH); + const restore = stubFetch(bytes); + try { + const engine = new WasmEngine({ wasmUrl: "http://stub/dhamaka-runtime.wasm" }); + await engine.load({ + entry: { id: "dhamaka-micro" }, + artifacts: {}, + }); + assert.equal(engine.loaded, true); + assert.equal(engine.info().backend, "wasm"); + assert.equal(engine.info().abiVersion, 1); + await engine.unload(); + } finally { + restore(); + } +}); + +test("WasmEngine: real Rust forward pass streams tokens", async (t) => { + if (!(await wasmIsPresent())) { + t.skip("dhamaka-runtime.wasm not found"); + return; + } + const bytes = await readFile(WASM_PATH); + const restore = stubFetch(bytes); + try { + const engine = new WasmEngine({ wasmUrl: "http://stub/dhamaka-runtime.wasm" }); + await engine.load({ entry: { id: "dhamaka-micro" }, artifacts: {} }); + + const tokens = []; + for await (const token of engine.generate("hello world", { + temperature: 0.7, + topK: 40, + topP: 0.95, + maxTokens: 12, + })) { + tokens.push(token); + } + assert.ok(tokens.length > 0, "expected at least one streamed token"); + assert.ok( + tokens.length <= 12, + `expected max 12 tokens, got ${tokens.length}`, + ); + for (const t of tokens) { + assert.equal(typeof t, "string"); + } + await engine.unload(); + } finally { + restore(); + } +}); + +test("WasmEngine: is deterministic for identical prompts", async (t) => { + if (!(await wasmIsPresent())) { + t.skip("dhamaka-runtime.wasm not found"); + return; + } + const bytes = await readFile(WASM_PATH); + const restore = stubFetch(bytes); + try { + const runOnce = async () => { + const engine = new WasmEngine({ wasmUrl: "http://stub/run.wasm" }); + await engine.load({ entry: { id: "dhamaka-micro" }, artifacts: {} }); + const out = []; + for await (const t of engine.generate("Dhamaka is", { maxTokens: 8 })) { + out.push(t); + } + await engine.unload(); + return out.join(""); + }; + const a = await runOnce(); + const b = await runOnce(); + assert.equal(a, b, "identical prompts should yield identical output"); + assert.ok(a.length > 0); + } finally { + restore(); + } +}); + +test("WasmEngine: respects AbortSignal", async (t) => { + if (!(await wasmIsPresent())) { + t.skip("dhamaka-runtime.wasm not found"); + return; + } + const bytes = await readFile(WASM_PATH); + const restore = stubFetch(bytes); + try { + const engine = new WasmEngine({ wasmUrl: "http://stub/run.wasm" }); + await engine.load({ entry: { id: "dhamaka-micro" }, artifacts: {} }); + + const controller = new AbortController(); + const tokens = []; + const iter = engine.generate("stream forever", { + maxTokens: 1024, + signal: controller.signal, + }); + controller.abort(); + for await (const t of iter) { + tokens.push(t); + if (tokens.length > 5) break; + } + assert.ok(tokens.length <= 5); + await engine.unload(); + } finally { + restore(); + } +}); diff --git a/packages/sdk/PUBLISHING.md b/packages/sdk/PUBLISHING.md new file mode 100644 index 0000000..1f250df --- /dev/null +++ b/packages/sdk/PUBLISHING.md @@ -0,0 +1,97 @@ +# Publishing `dhamaka` to npm + +Releases are tag-driven. Push `vX.Y.Z` and the release workflow +(`.github/workflows/release.yml`) handles everything: wasm build, tests, +staging, GitHub release with artifacts, and npm publish. + +## One-time setup + +1. Reserve the `dhamaka` name on npm (or, if you already own it, skip). +2. Create an npm automation token: . + Use an **Automation** token so 2FA doesn't block CI. +3. Add it to the GitHub repo secrets: + `Settings → Secrets and variables → Actions → New repository secret` + - Name: `NPM_TOKEN` + - Value: the token from step 2 +4. (Optional) Enable OIDC trusted publishing if you prefer provenance over + tokens. The workflow already passes `--provenance`, which npm requires + for verified builds from GitHub Actions. + +## Cut a release + +```bash +# Bump the version in packages/sdk/package.json and CHANGELOG.md, then: +git add packages/sdk/package.json CHANGELOG.md +git commit -m "release: v0.1.1" +git tag -a v0.1.1 -m "v0.1.1" +git push origin main +git push origin v0.1.1 +``` + +The tag push triggers the release workflow, which will: + +1. Install Rust + `wasm32-unknown-unknown` +2. `cargo test` the runtime crate +3. Build `dhamaka-runtime.wasm` via `crates/dhamaka-runtime/build.sh` +4. Run the JS test suite (`npm test`) +5. Run `scripts/prepare-publish.mjs` to stage `packages/sdk/_staging/` +6. `npm pack` the staged package +7. Verify the tag matches the package version +8. `npm publish --access public --provenance` (if `NPM_TOKEN` is set) +9. Create a GitHub release named "Dhamaka vX.Y.Z" with release notes + extracted from `CHANGELOG.md` and the tarball + raw wasm attached + +If `NPM_TOKEN` is **not** set, the workflow still runs end-to-end but skips +step 8 gracefully — useful for dry-running the pipeline before flipping the +publish switch. + +## Manual publish + +You don't need the workflow. If you have your npm credentials locally: + +```bash +# from the repo root +crates/dhamaka-runtime/build.sh # compile the wasm +node scripts/prepare-publish.mjs # stage packages/sdk/_staging/ +cd packages/sdk/_staging +npm publish --access public +``` + +## What ends up in the tarball + +``` +dhamaka-X.Y.Z.tgz +├── package.json # standalone, no workspace refs +├── README.md +├── LICENSE +├── CHANGELOG.md +└── src/ + ├── index.js # Dhamaka.load / complete / stream / chat / … + ├── hub-client.js # tiered HubClient + FallbackStore + ├── chat.js # stateful chat session + ├── openai-shim.js # /v1/chat/completions compatibility + └── _runtime/ # vendored @dhamaka/runtime + ├── index.js + ├── engine.js + ├── factory.js + ├── mock-engine.js + ├── wasm-engine.js + ├── tokenizer.js + └── dhamaka-runtime.wasm # 56 KB compiled Rust +``` + +The published `dhamaka` package depends on **nothing**. It bundles the +compiled WASM runtime, so `npm install dhamaka` followed by +`import { Dhamaka } from "dhamaka"` is all a consumer needs. + +## Version policy + +- `major`: breaking ABI changes to the Rust runtime, or breaking changes to + the `Dhamaka` SDK class. +- `minor`: new features, new engines, new models in the registry, new + public SDK methods. +- `patch`: bug fixes, doc updates, internal refactors. + +The published npm version is always the same as the `packages/sdk/package.json` +version, which is always the same as the git tag without its `v` prefix. +The release workflow verifies this and fails the build if they diverge. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index edfa2dd..2f0b188 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,11 +1,16 @@ { "name": "dhamaka", "version": "0.1.0", - "description": "Browser-native LLM. Download the model once, use it on every Dhamaka-powered site forever.", + "description": "A reflex layer for every input on the web. Drop in SmartField / SmartForm / SmartText and get on-device autofill, contextual spellcheck, smart paste, and cross-field inference — zero latency, zero cost, zero privacy exposure.", "type": "module", "main": "src/index.js", "exports": { ".": "./src/index.js", + "./smart-field": "./src/smart-field.js", + "./smart-form": "./src/smart-form.js", + "./smart-text": "./src/smart-text.js", + "./tasks": "./src/tasks.js", + "./reflex": "./src/reflex.js", "./hub-client": "./src/hub-client.js", "./chat": "./src/chat.js", "./openai": "./src/openai-shim.js" @@ -19,11 +24,16 @@ "license": "MIT", "keywords": [ "llm", - "wasm", - "browser", - "ai", "on-device", "local-first", - "privacy" + "privacy", + "form", + "autofill", + "spellcheck", + "autocomplete", + "smart-field", + "browser", + "wasm", + "window.ai" ] } diff --git a/packages/sdk/src/data/cities.js b/packages/sdk/src/data/cities.js new file mode 100644 index 0000000..0e03e38 --- /dev/null +++ b/packages/sdk/src/data/cities.js @@ -0,0 +1,1069 @@ +// Comprehensive city gazetteer for the city-to-state task. +// +// Uses a compact builder format: each city is [name, state, stateName, tz, ...aliases]. +// The build() function expands these into full objects with country, currency, etc. +// This keeps ~600 cities in a readable, maintainable file. +// +// Coverage: all US state capitals, all US cities > 150k, all world capitals, +// all world cities > 1M, and notable smaller cities. + +// ── Builder ────────────────────────────────────────────────────────── + +function build(country, countryName, currency, data) { + return data.map(([name, state, stateName, tz, ...aliases]) => ({ + name, aliases, state, stateName, country, countryName, tz, currency, + })); +} + +// ── United States ──────────────────────────────────────────────────── + +const US = build("US", "United States", "USD", [ + // Alabama + ["Birmingham", "AL", "Alabama", "America/Chicago"], + ["Montgomery", "AL", "Alabama", "America/Chicago"], + ["Huntsville", "AL", "Alabama", "America/Chicago"], + ["Mobile", "AL", "Alabama", "America/Chicago"], + // Alaska + ["Anchorage", "AK", "Alaska", "America/Anchorage"], + ["Juneau", "AK", "Alaska", "America/Anchorage"], + ["Fairbanks", "AK", "Alaska", "America/Anchorage"], + // Arizona + ["Phoenix", "AZ", "Arizona", "America/Phoenix"], + ["Tucson", "AZ", "Arizona", "America/Phoenix"], + ["Mesa", "AZ", "Arizona", "America/Phoenix"], + ["Scottsdale", "AZ", "Arizona", "America/Phoenix"], + ["Chandler", "AZ", "Arizona", "America/Phoenix"], + ["Gilbert", "AZ", "Arizona", "America/Phoenix"], + ["Glendale", "AZ", "Arizona", "America/Phoenix"], + ["Tempe", "AZ", "Arizona", "America/Phoenix"], + ["Peoria", "AZ", "Arizona", "America/Phoenix"], + ["Surprise", "AZ", "Arizona", "America/Phoenix"], + ["Flagstaff", "AZ", "Arizona", "America/Phoenix"], + // Arkansas + ["Little Rock", "AR", "Arkansas", "America/Chicago"], + ["Fort Smith", "AR", "Arkansas", "America/Chicago"], + ["Fayetteville", "AR", "Arkansas", "America/Chicago"], + // California + ["San Francisco", "CA", "California", "America/Los_Angeles", "sf", "san fran", "frisco"], + ["Los Angeles", "CA", "California", "America/Los_Angeles", "la"], + ["San Diego", "CA", "California", "America/Los_Angeles"], + ["San Jose", "CA", "California", "America/Los_Angeles"], + ["Sacramento", "CA", "California", "America/Los_Angeles"], + ["Oakland", "CA", "California", "America/Los_Angeles"], + ["Berkeley", "CA", "California", "America/Los_Angeles"], + ["Palo Alto", "CA", "California", "America/Los_Angeles"], + ["Fresno", "CA", "California", "America/Los_Angeles"], + ["Long Beach", "CA", "California", "America/Los_Angeles"], + ["Bakersfield", "CA", "California", "America/Los_Angeles"], + ["Anaheim", "CA", "California", "America/Los_Angeles"], + ["Santa Ana", "CA", "California", "America/Los_Angeles"], + ["Riverside", "CA", "California", "America/Los_Angeles"], + ["Stockton", "CA", "California", "America/Los_Angeles"], + ["Irvine", "CA", "California", "America/Los_Angeles"], + ["Chula Vista", "CA", "California", "America/Los_Angeles"], + ["Fremont", "CA", "California", "America/Los_Angeles"], + ["Modesto", "CA", "California", "America/Los_Angeles"], + ["Fontana", "CA", "California", "America/Los_Angeles"], + ["Moreno Valley", "CA", "California", "America/Los_Angeles"], + ["Santa Clarita", "CA", "California", "America/Los_Angeles"], + ["Huntington Beach", "CA", "California", "America/Los_Angeles"], + ["Garden Grove", "CA", "California", "America/Los_Angeles"], + ["Oceanside", "CA", "California", "America/Los_Angeles"], + ["Rancho Cucamonga", "CA", "California", "America/Los_Angeles"], + ["Ontario", "CA", "California", "America/Los_Angeles"], + ["Santa Rosa", "CA", "California", "America/Los_Angeles"], + ["Elk Grove", "CA", "California", "America/Los_Angeles"], + ["Sunnyvale", "CA", "California", "America/Los_Angeles"], + ["Corona", "CA", "California", "America/Los_Angeles"], + ["Pomona", "CA", "California", "America/Los_Angeles"], + ["Escondido", "CA", "California", "America/Los_Angeles"], + ["Salinas", "CA", "California", "America/Los_Angeles"], + ["Pasadena", "CA", "California", "America/Los_Angeles"], + ["Torrance", "CA", "California", "America/Los_Angeles"], + ["Roseville", "CA", "California", "America/Los_Angeles"], + ["Hayward", "CA", "California", "America/Los_Angeles"], + ["Santa Clara", "CA", "California", "America/Los_Angeles"], + ["Visalia", "CA", "California", "America/Los_Angeles"], + ["Concord", "CA", "California", "America/Los_Angeles"], + ["Thousand Oaks", "CA", "California", "America/Los_Angeles"], + ["Simi Valley", "CA", "California", "America/Los_Angeles"], + ["Victorville", "CA", "California", "America/Los_Angeles"], + ["Vallejo", "CA", "California", "America/Los_Angeles"], + ["Carlsbad", "CA", "California", "America/Los_Angeles"], + ["Newport Beach", "CA", "California", "America/Los_Angeles"], + ["San Bernardino", "CA", "California", "America/Los_Angeles"], + ["Santa Barbara", "CA", "California", "America/Los_Angeles"], + ["Santa Cruz", "CA", "California", "America/Los_Angeles"], + ["Cupertino", "CA", "California", "America/Los_Angeles"], + ["Mountain View", "CA", "California", "America/Los_Angeles"], + ["Redwood City", "CA", "California", "America/Los_Angeles"], + // Colorado + ["Denver", "CO", "Colorado", "America/Denver"], + ["Boulder", "CO", "Colorado", "America/Denver"], + ["Colorado Springs", "CO", "Colorado", "America/Denver"], + ["Aurora", "CO", "Colorado", "America/Denver"], + ["Fort Collins", "CO", "Colorado", "America/Denver"], + ["Lakewood", "CO", "Colorado", "America/Denver"], + ["Thornton", "CO", "Colorado", "America/Denver"], + ["Arvada", "CO", "Colorado", "America/Denver"], + ["Pueblo", "CO", "Colorado", "America/Denver"], + // Connecticut + ["Hartford", "CT", "Connecticut", "America/New_York"], + ["New Haven", "CT", "Connecticut", "America/New_York"], + ["Stamford", "CT", "Connecticut", "America/New_York"], + ["Bridgeport", "CT", "Connecticut", "America/New_York"], + ["Waterbury", "CT", "Connecticut", "America/New_York"], + // Delaware + ["Dover", "DE", "Delaware", "America/New_York"], + ["Wilmington", "DE", "Delaware", "America/New_York"], + // Florida + ["Miami", "FL", "Florida", "America/New_York"], + ["Orlando", "FL", "Florida", "America/New_York"], + ["Tampa", "FL", "Florida", "America/New_York"], + ["Jacksonville", "FL", "Florida", "America/New_York"], + ["Tallahassee", "FL", "Florida", "America/New_York"], + ["St. Petersburg", "FL", "Florida", "America/New_York", "saint petersburg"], + ["Fort Lauderdale", "FL", "Florida", "America/New_York"], + ["Hialeah", "FL", "Florida", "America/New_York"], + ["Cape Coral", "FL", "Florida", "America/New_York"], + ["Port St. Lucie", "FL", "Florida", "America/New_York"], + ["Pembroke Pines", "FL", "Florida", "America/New_York"], + ["Hollywood", "FL", "Florida", "America/New_York"], + ["Gainesville", "FL", "Florida", "America/New_York"], + ["Coral Springs", "FL", "Florida", "America/New_York"], + ["Clearwater", "FL", "Florida", "America/New_York"], + ["Palm Bay", "FL", "Florida", "America/New_York"], + ["Lakeland", "FL", "Florida", "America/New_York"], + ["West Palm Beach", "FL", "Florida", "America/New_York"], + ["Boca Raton", "FL", "Florida", "America/New_York"], + ["Naples", "FL", "Florida", "America/New_York"], + ["Sarasota", "FL", "Florida", "America/New_York"], + // Georgia + ["Atlanta", "GA", "Georgia", "America/New_York"], + ["Savannah", "GA", "Georgia", "America/New_York"], + ["Augusta", "GA", "Georgia", "America/New_York"], + ["Columbus", "GA", "Georgia", "America/New_York"], + ["Macon", "GA", "Georgia", "America/New_York"], + ["Athens", "GA", "Georgia", "America/New_York"], + // Hawaii + ["Honolulu", "HI", "Hawaii", "Pacific/Honolulu"], + // Idaho + ["Boise", "ID", "Idaho", "America/Boise"], + ["Meridian", "ID", "Idaho", "America/Boise"], + ["Nampa", "ID", "Idaho", "America/Boise"], + // Illinois + ["Chicago", "IL", "Illinois", "America/Chicago", "chi-town", "chitown"], + ["Springfield", "IL", "Illinois", "America/Chicago"], + ["Aurora", "IL", "Illinois", "America/Chicago"], + ["Naperville", "IL", "Illinois", "America/Chicago"], + ["Rockford", "IL", "Illinois", "America/Chicago"], + ["Joliet", "IL", "Illinois", "America/Chicago"], + ["Elgin", "IL", "Illinois", "America/Chicago"], + ["Peoria", "IL", "Illinois", "America/Chicago"], + ["Champaign", "IL", "Illinois", "America/Chicago"], + ["Evanston", "IL", "Illinois", "America/Chicago"], + // Indiana + ["Indianapolis", "IN", "Indiana", "America/Indiana/Indianapolis", "indy"], + ["Fort Wayne", "IN", "Indiana", "America/Indiana/Indianapolis"], + ["Evansville", "IN", "Indiana", "America/Indiana/Indianapolis"], + ["South Bend", "IN", "Indiana", "America/Indiana/Indianapolis"], + ["Bloomington", "IN", "Indiana", "America/Indiana/Indianapolis"], + // Iowa + ["Des Moines", "IA", "Iowa", "America/Chicago"], + ["Cedar Rapids", "IA", "Iowa", "America/Chicago"], + ["Davenport", "IA", "Iowa", "America/Chicago"], + ["Iowa City", "IA", "Iowa", "America/Chicago"], + // Kansas + ["Topeka", "KS", "Kansas", "America/Chicago"], + ["Wichita", "KS", "Kansas", "America/Chicago"], + ["Overland Park", "KS", "Kansas", "America/Chicago"], + ["Kansas City", "KS", "Kansas", "America/Chicago"], + ["Lawrence", "KS", "Kansas", "America/Chicago"], + // Kentucky + ["Frankfort", "KY", "Kentucky", "America/Kentucky/Louisville"], + ["Louisville", "KY", "Kentucky", "America/Kentucky/Louisville"], + ["Lexington", "KY", "Kentucky", "America/New_York"], + ["Bowling Green", "KY", "Kentucky", "America/Chicago"], + // Louisiana + ["New Orleans", "LA", "Louisiana", "America/Chicago", "nola"], + ["Baton Rouge", "LA", "Louisiana", "America/Chicago"], + ["Shreveport", "LA", "Louisiana", "America/Chicago"], + ["Lafayette", "LA", "Louisiana", "America/Chicago"], + // Maine + ["Augusta", "ME", "Maine", "America/New_York"], + ["Portland", "ME", "Maine", "America/New_York"], + // Maryland + ["Baltimore", "MD", "Maryland", "America/New_York"], + ["Annapolis", "MD", "Maryland", "America/New_York"], + ["Frederick", "MD", "Maryland", "America/New_York"], + ["Rockville", "MD", "Maryland", "America/New_York"], + // Massachusetts + ["Boston", "MA", "Massachusetts", "America/New_York"], + ["Cambridge", "MA", "Massachusetts", "America/New_York"], + ["Worcester", "MA", "Massachusetts", "America/New_York"], + ["Springfield", "MA", "Massachusetts", "America/New_York"], + ["Lowell", "MA", "Massachusetts", "America/New_York"], + // Michigan + ["Detroit", "MI", "Michigan", "America/Detroit"], + ["Ann Arbor", "MI", "Michigan", "America/Detroit"], + ["Lansing", "MI", "Michigan", "America/Detroit"], + ["Grand Rapids", "MI", "Michigan", "America/Detroit"], + ["Warren", "MI", "Michigan", "America/Detroit"], + ["Sterling Heights", "MI", "Michigan", "America/Detroit"], + ["Flint", "MI", "Michigan", "America/Detroit"], + ["Kalamazoo", "MI", "Michigan", "America/Detroit"], + // Minnesota + ["Minneapolis", "MN", "Minnesota", "America/Chicago"], + ["Saint Paul", "MN", "Minnesota", "America/Chicago", "st paul", "st. paul"], + ["Rochester", "MN", "Minnesota", "America/Chicago"], + ["Duluth", "MN", "Minnesota", "America/Chicago"], + // Mississippi + ["Jackson", "MS", "Mississippi", "America/Chicago"], + // Missouri + ["Kansas City", "MO", "Missouri", "America/Chicago", "kc"], + ["St. Louis", "MO", "Missouri", "America/Chicago", "saint louis"], + ["Jefferson City", "MO", "Missouri", "America/Chicago"], + ["Springfield", "MO", "Missouri", "America/Chicago"], + ["Columbia", "MO", "Missouri", "America/Chicago"], + // Montana + ["Helena", "MT", "Montana", "America/Denver"], + ["Billings", "MT", "Montana", "America/Denver"], + ["Missoula", "MT", "Montana", "America/Denver"], + // Nebraska + ["Lincoln", "NE", "Nebraska", "America/Chicago"], + ["Omaha", "NE", "Nebraska", "America/Chicago"], + // Nevada + ["Las Vegas", "NV", "Nevada", "America/Los_Angeles", "vegas"], + ["Reno", "NV", "Nevada", "America/Los_Angeles"], + ["Carson City", "NV", "Nevada", "America/Los_Angeles"], + ["Henderson", "NV", "Nevada", "America/Los_Angeles"], + ["North Las Vegas", "NV", "Nevada", "America/Los_Angeles"], + // New Hampshire + ["Concord", "NH", "New Hampshire", "America/New_York"], + ["Manchester", "NH", "New Hampshire", "America/New_York"], + ["Nashua", "NH", "New Hampshire", "America/New_York"], + // New Jersey + ["Trenton", "NJ", "New Jersey", "America/New_York"], + ["Newark", "NJ", "New Jersey", "America/New_York"], + ["Jersey City", "NJ", "New Jersey", "America/New_York"], + ["Paterson", "NJ", "New Jersey", "America/New_York"], + ["Elizabeth", "NJ", "New Jersey", "America/New_York"], + ["Edison", "NJ", "New Jersey", "America/New_York"], + ["Princeton", "NJ", "New Jersey", "America/New_York"], + // New Mexico + ["Santa Fe", "NM", "New Mexico", "America/Denver"], + ["Albuquerque", "NM", "New Mexico", "America/Denver"], + ["Las Cruces", "NM", "New Mexico", "America/Denver"], + // New York + ["New York", "NY", "New York", "America/New_York", "nyc", "new york city"], + ["Brooklyn", "NY", "New York", "America/New_York"], + ["Buffalo", "NY", "New York", "America/New_York"], + ["Albany", "NY", "New York", "America/New_York"], + ["Rochester", "NY", "New York", "America/New_York"], + ["Syracuse", "NY", "New York", "America/New_York"], + ["Yonkers", "NY", "New York", "America/New_York"], + ["White Plains", "NY", "New York", "America/New_York"], + ["Ithaca", "NY", "New York", "America/New_York"], + // North Carolina + ["Charlotte", "NC", "North Carolina", "America/New_York"], + ["Raleigh", "NC", "North Carolina", "America/New_York"], + ["Durham", "NC", "North Carolina", "America/New_York"], + ["Greensboro", "NC", "North Carolina", "America/New_York"], + ["Winston-Salem", "NC", "North Carolina", "America/New_York"], + ["Fayetteville", "NC", "North Carolina", "America/New_York"], + ["Cary", "NC", "North Carolina", "America/New_York"], + ["Wilmington", "NC", "North Carolina", "America/New_York"], + ["Asheville", "NC", "North Carolina", "America/New_York"], + ["Chapel Hill", "NC", "North Carolina", "America/New_York"], + // North Dakota + ["Bismarck", "ND", "North Dakota", "America/Chicago"], + ["Fargo", "ND", "North Dakota", "America/Chicago"], + // Ohio + ["Columbus", "OH", "Ohio", "America/New_York"], + ["Cleveland", "OH", "Ohio", "America/New_York"], + ["Cincinnati", "OH", "Ohio", "America/New_York"], + ["Toledo", "OH", "Ohio", "America/New_York"], + ["Akron", "OH", "Ohio", "America/New_York"], + ["Dayton", "OH", "Ohio", "America/New_York"], + // Oklahoma + ["Oklahoma City", "OK", "Oklahoma", "America/Chicago", "okc"], + ["Tulsa", "OK", "Oklahoma", "America/Chicago"], + ["Norman", "OK", "Oklahoma", "America/Chicago"], + // Oregon + ["Portland", "OR", "Oregon", "America/Los_Angeles"], + ["Eugene", "OR", "Oregon", "America/Los_Angeles"], + ["Salem", "OR", "Oregon", "America/Los_Angeles"], + ["Bend", "OR", "Oregon", "America/Los_Angeles"], + ["Corvallis", "OR", "Oregon", "America/Los_Angeles"], + // Pennsylvania + ["Philadelphia", "PA", "Pennsylvania", "America/New_York", "philly"], + ["Pittsburgh", "PA", "Pennsylvania", "America/New_York"], + ["Harrisburg", "PA", "Pennsylvania", "America/New_York"], + ["Allentown", "PA", "Pennsylvania", "America/New_York"], + ["Erie", "PA", "Pennsylvania", "America/New_York"], + ["Reading", "PA", "Pennsylvania", "America/New_York"], + ["State College", "PA", "Pennsylvania", "America/New_York"], + // Rhode Island + ["Providence", "RI", "Rhode Island", "America/New_York"], + ["Newport", "RI", "Rhode Island", "America/New_York"], + // South Carolina + ["Columbia", "SC", "South Carolina", "America/New_York"], + ["Charleston", "SC", "South Carolina", "America/New_York"], + ["Greenville", "SC", "South Carolina", "America/New_York"], + ["Myrtle Beach", "SC", "South Carolina", "America/New_York"], + // South Dakota + ["Pierre", "SD", "South Dakota", "America/Chicago"], + ["Sioux Falls", "SD", "South Dakota", "America/Chicago"], + ["Rapid City", "SD", "South Dakota", "America/Denver"], + // Tennessee + ["Nashville", "TN", "Tennessee", "America/Chicago"], + ["Memphis", "TN", "Tennessee", "America/Chicago"], + ["Knoxville", "TN", "Tennessee", "America/New_York"], + ["Chattanooga", "TN", "Tennessee", "America/New_York"], + ["Clarksville", "TN", "Tennessee", "America/Chicago"], + ["Murfreesboro", "TN", "Tennessee", "America/Chicago"], + // Texas + ["Houston", "TX", "Texas", "America/Chicago"], + ["Austin", "TX", "Texas", "America/Chicago"], + ["Dallas", "TX", "Texas", "America/Chicago"], + ["San Antonio", "TX", "Texas", "America/Chicago"], + ["Fort Worth", "TX", "Texas", "America/Chicago"], + ["El Paso", "TX", "Texas", "America/Denver"], + ["Arlington", "TX", "Texas", "America/Chicago"], + ["Plano", "TX", "Texas", "America/Chicago"], + ["Corpus Christi", "TX", "Texas", "America/Chicago"], + ["Laredo", "TX", "Texas", "America/Chicago"], + ["Lubbock", "TX", "Texas", "America/Chicago"], + ["Irving", "TX", "Texas", "America/Chicago"], + ["Garland", "TX", "Texas", "America/Chicago"], + ["Frisco", "TX", "Texas", "America/Chicago"], + ["McKinney", "TX", "Texas", "America/Chicago"], + ["Amarillo", "TX", "Texas", "America/Chicago"], + ["Brownsville", "TX", "Texas", "America/Chicago"], + ["Grand Prairie", "TX", "Texas", "America/Chicago"], + ["Killeen", "TX", "Texas", "America/Chicago"], + ["Midland", "TX", "Texas", "America/Chicago"], + ["Odessa", "TX", "Texas", "America/Chicago"], + ["Round Rock", "TX", "Texas", "America/Chicago"], + ["College Station", "TX", "Texas", "America/Chicago"], + ["Waco", "TX", "Texas", "America/Chicago"], + // Utah + ["Salt Lake City", "UT", "Utah", "America/Denver", "slc"], + ["Provo", "UT", "Utah", "America/Denver"], + ["West Valley City", "UT", "Utah", "America/Denver"], + ["Ogden", "UT", "Utah", "America/Denver"], + ["St. George", "UT", "Utah", "America/Denver"], + // Vermont + ["Montpelier", "VT", "Vermont", "America/New_York"], + ["Burlington", "VT", "Vermont", "America/New_York"], + // Virginia + ["Richmond", "VA", "Virginia", "America/New_York"], + ["Virginia Beach", "VA", "Virginia", "America/New_York"], + ["Norfolk", "VA", "Virginia", "America/New_York"], + ["Chesapeake", "VA", "Virginia", "America/New_York"], + ["Arlington", "VA", "Virginia", "America/New_York"], + ["Alexandria", "VA", "Virginia", "America/New_York"], + ["Charlottesville", "VA", "Virginia", "America/New_York"], + ["Roanoke", "VA", "Virginia", "America/New_York"], + // Washington + ["Seattle", "WA", "Washington", "America/Los_Angeles"], + ["Tacoma", "WA", "Washington", "America/Los_Angeles"], + ["Spokane", "WA", "Washington", "America/Los_Angeles"], + ["Olympia", "WA", "Washington", "America/Los_Angeles"], + ["Bellevue", "WA", "Washington", "America/Los_Angeles"], + ["Vancouver", "WA", "Washington", "America/Los_Angeles"], + ["Redmond", "WA", "Washington", "America/Los_Angeles"], + // Washington D.C. + ["Washington", "DC", "District of Columbia", "America/New_York", "dc", "washington dc", "d.c."], + // West Virginia + ["Charleston", "WV", "West Virginia", "America/New_York"], + ["Huntington", "WV", "West Virginia", "America/New_York"], + // Wisconsin + ["Madison", "WI", "Wisconsin", "America/Chicago"], + ["Milwaukee", "WI", "Wisconsin", "America/Chicago"], + ["Green Bay", "WI", "Wisconsin", "America/Chicago"], + // Wyoming + ["Cheyenne", "WY", "Wyoming", "America/Denver"], + ["Casper", "WY", "Wyoming", "America/Denver"], +]); + +// ── Canada ─────────────────────────────────────────────────────────── + +const CA = build("CA", "Canada", "CAD", [ + ["Toronto", "ON", "Ontario", "America/Toronto"], + ["Ottawa", "ON", "Ontario", "America/Toronto"], + ["Mississauga", "ON", "Ontario", "America/Toronto"], + ["Hamilton", "ON", "Ontario", "America/Toronto"], + ["London", "ON", "Ontario", "America/Toronto"], + ["Kitchener", "ON", "Ontario", "America/Toronto"], + ["Windsor", "ON", "Ontario", "America/Toronto"], + ["Vancouver", "BC", "British Columbia", "America/Vancouver"], + ["Victoria", "BC", "British Columbia", "America/Vancouver"], + ["Surrey", "BC", "British Columbia", "America/Vancouver"], + ["Burnaby", "BC", "British Columbia", "America/Vancouver"], + ["Montreal", "QC", "Quebec", "America/Montreal"], + ["Quebec City", "QC", "Quebec", "America/Montreal"], + ["Laval", "QC", "Quebec", "America/Montreal"], + ["Gatineau", "QC", "Quebec", "America/Montreal"], + ["Calgary", "AB", "Alberta", "America/Edmonton"], + ["Edmonton", "AB", "Alberta", "America/Edmonton"], + ["Red Deer", "AB", "Alberta", "America/Edmonton"], + ["Winnipeg", "MB", "Manitoba", "America/Winnipeg"], + ["Halifax", "NS", "Nova Scotia", "America/Halifax"], + ["Saskatoon", "SK", "Saskatchewan", "America/Regina"], + ["Regina", "SK", "Saskatchewan", "America/Regina"], + ["St. John's", "NL", "Newfoundland", "America/St_Johns", "saint johns"], + ["Fredericton", "NB", "New Brunswick", "America/Moncton"], + ["Charlottetown", "PE", "Prince Edward Island", "America/Halifax"], + ["Whitehorse", "YT", "Yukon", "America/Whitehorse"], + ["Yellowknife", "NT", "Northwest Territories", "America/Yellowknife"], +]); + +// ── United Kingdom ─────────────────────────────────────────────────── + +const GB = build("GB", "United Kingdom", "GBP", [ + ["London", "ENG", "England", "Europe/London"], + ["Manchester", "ENG", "England", "Europe/London"], + ["Birmingham", "ENG", "England", "Europe/London"], + ["Liverpool", "ENG", "England", "Europe/London"], + ["Leeds", "ENG", "England", "Europe/London"], + ["Bristol", "ENG", "England", "Europe/London"], + ["Oxford", "ENG", "England", "Europe/London"], + ["Cambridge", "ENG", "England", "Europe/London"], + ["Sheffield", "ENG", "England", "Europe/London"], + ["Newcastle", "ENG", "England", "Europe/London"], + ["Nottingham", "ENG", "England", "Europe/London"], + ["Leicester", "ENG", "England", "Europe/London"], + ["Brighton", "ENG", "England", "Europe/London"], + ["Southampton", "ENG", "England", "Europe/London"], + ["Portsmouth", "ENG", "England", "Europe/London"], + ["Plymouth", "ENG", "England", "Europe/London"], + ["Coventry", "ENG", "England", "Europe/London"], + ["Bath", "ENG", "England", "Europe/London"], + ["York", "ENG", "England", "Europe/London"], + ["Norwich", "ENG", "England", "Europe/London"], + ["Edinburgh", "SCT", "Scotland", "Europe/London"], + ["Glasgow", "SCT", "Scotland", "Europe/London"], + ["Aberdeen", "SCT", "Scotland", "Europe/London"], + ["Dundee", "SCT", "Scotland", "Europe/London"], + ["Cardiff", "WLS", "Wales", "Europe/London"], + ["Swansea", "WLS", "Wales", "Europe/London"], + ["Belfast", "NIR", "Northern Ireland", "Europe/London"], +]); + +// ── India ──────────────────────────────────────────────────────────── + +const IN = build("IN", "India", "INR", [ + ["Mumbai", "MH", "Maharashtra", "Asia/Kolkata", "bombay"], + ["Pune", "MH", "Maharashtra", "Asia/Kolkata"], + ["Nagpur", "MH", "Maharashtra", "Asia/Kolkata"], + ["Nashik", "MH", "Maharashtra", "Asia/Kolkata"], + ["Aurangabad", "MH", "Maharashtra", "Asia/Kolkata"], + ["Thane", "MH", "Maharashtra", "Asia/Kolkata"], + ["Delhi", "DL", "Delhi", "Asia/Kolkata", "new delhi"], + ["Noida", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Gurgaon", "HR", "Haryana", "Asia/Kolkata", "gurugram"], + ["Bangalore", "KA", "Karnataka", "Asia/Kolkata", "bengaluru", "blr"], + ["Mysore", "KA", "Karnataka", "Asia/Kolkata", "mysuru"], + ["Mangalore", "KA", "Karnataka", "Asia/Kolkata", "mangaluru"], + ["Hubli", "KA", "Karnataka", "Asia/Kolkata"], + ["Chennai", "TN", "Tamil Nadu", "Asia/Kolkata", "madras"], + ["Coimbatore", "TN", "Tamil Nadu", "Asia/Kolkata"], + ["Madurai", "TN", "Tamil Nadu", "Asia/Kolkata"], + ["Salem", "TN", "Tamil Nadu", "Asia/Kolkata"], + ["Tiruchirappalli", "TN", "Tamil Nadu", "Asia/Kolkata", "trichy"], + ["Kolkata", "WB", "West Bengal", "Asia/Kolkata", "calcutta"], + ["Howrah", "WB", "West Bengal", "Asia/Kolkata"], + ["Hyderabad", "TG", "Telangana", "Asia/Kolkata"], + ["Warangal", "TG", "Telangana", "Asia/Kolkata"], + ["Ahmedabad", "GJ", "Gujarat", "Asia/Kolkata"], + ["Surat", "GJ", "Gujarat", "Asia/Kolkata"], + ["Vadodara", "GJ", "Gujarat", "Asia/Kolkata", "baroda"], + ["Rajkot", "GJ", "Gujarat", "Asia/Kolkata"], + ["Jaipur", "RJ", "Rajasthan", "Asia/Kolkata"], + ["Jodhpur", "RJ", "Rajasthan", "Asia/Kolkata"], + ["Udaipur", "RJ", "Rajasthan", "Asia/Kolkata"], + ["Kota", "RJ", "Rajasthan", "Asia/Kolkata"], + ["Kanpur", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Lucknow", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Agra", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Varanasi", "UP", "Uttar Pradesh", "Asia/Kolkata", "benaras", "kashi"], + ["Allahabad", "UP", "Uttar Pradesh", "Asia/Kolkata", "prayagraj"], + ["Meerut", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Ghaziabad", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Bareilly", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Aligarh", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Moradabad", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Gorakhpur", "UP", "Uttar Pradesh", "Asia/Kolkata"], + ["Patna", "BR", "Bihar", "Asia/Kolkata"], + ["Gaya", "BR", "Bihar", "Asia/Kolkata"], + ["Bhopal", "MP", "Madhya Pradesh", "Asia/Kolkata"], + ["Indore", "MP", "Madhya Pradesh", "Asia/Kolkata"], + ["Jabalpur", "MP", "Madhya Pradesh", "Asia/Kolkata"], + ["Gwalior", "MP", "Madhya Pradesh", "Asia/Kolkata"], + ["Chandigarh", "CH", "Chandigarh", "Asia/Kolkata"], + ["Ludhiana", "PB", "Punjab", "Asia/Kolkata"], + ["Amritsar", "PB", "Punjab", "Asia/Kolkata"], + ["Jalandhar", "PB", "Punjab", "Asia/Kolkata"], + ["Thiruvananthapuram", "KL", "Kerala", "Asia/Kolkata", "trivandrum"], + ["Kochi", "KL", "Kerala", "Asia/Kolkata", "cochin"], + ["Kozhikode", "KL", "Kerala", "Asia/Kolkata", "calicut"], + ["Visakhapatnam", "AP", "Andhra Pradesh", "Asia/Kolkata", "vizag"], + ["Vijayawada", "AP", "Andhra Pradesh", "Asia/Kolkata"], + ["Tirupati", "AP", "Andhra Pradesh", "Asia/Kolkata"], + ["Guwahati", "AS", "Assam", "Asia/Kolkata"], + ["Bhubaneswar", "OD", "Odisha", "Asia/Kolkata"], + ["Cuttack", "OD", "Odisha", "Asia/Kolkata"], + ["Raipur", "CG", "Chhattisgarh", "Asia/Kolkata"], + ["Ranchi", "JH", "Jharkhand", "Asia/Kolkata"], + ["Jamshedpur", "JH", "Jharkhand", "Asia/Kolkata"], + ["Dehradun", "UK", "Uttarakhand", "Asia/Kolkata"], + ["Srinagar", "JK", "Jammu and Kashmir", "Asia/Kolkata"], + ["Jammu", "JK", "Jammu and Kashmir", "Asia/Kolkata"], + ["Shimla", "HP", "Himachal Pradesh", "Asia/Kolkata"], + ["Imphal", "MN", "Manipur", "Asia/Kolkata"], + ["Shillong", "ML", "Meghalaya", "Asia/Kolkata"], + ["Gangtok", "SK", "Sikkim", "Asia/Kolkata"], +]); + +// ── Europe ─────────────────────────────────────────────────────────── + +const EU = [ + ...build("FR", "France", "EUR", [ + ["Paris", "IDF", "Île-de-France", "Europe/Paris"], + ["Lyon", "ARA", "Auvergne-Rhône-Alpes", "Europe/Paris"], + ["Marseille", "PAC", "Provence-Alpes-Côte d'Azur", "Europe/Paris"], + ["Toulouse", "OCC", "Occitanie", "Europe/Paris"], + ["Nice", "PAC", "Provence-Alpes-Côte d'Azur", "Europe/Paris"], + ["Nantes", "PDL", "Pays de la Loire", "Europe/Paris"], + ["Strasbourg", "GES", "Grand Est", "Europe/Paris"], + ["Bordeaux", "NAQ", "Nouvelle-Aquitaine", "Europe/Paris"], + ["Lille", "HDF", "Hauts-de-France", "Europe/Paris"], + ["Montpellier", "OCC", "Occitanie", "Europe/Paris"], + ]), + ...build("DE", "Germany", "EUR", [ + ["Berlin", "BE", "Berlin", "Europe/Berlin"], + ["Munich", "BY", "Bavaria", "Europe/Berlin", "münchen"], + ["Hamburg", "HH", "Hamburg", "Europe/Berlin"], + ["Frankfurt", "HE", "Hesse", "Europe/Berlin"], + ["Cologne", "NW", "North Rhine-Westphalia", "Europe/Berlin", "köln", "koln"], + ["Stuttgart", "BW", "Baden-Württemberg", "Europe/Berlin"], + ["Düsseldorf", "NW", "North Rhine-Westphalia", "Europe/Berlin", "dusseldorf"], + ["Leipzig", "SN", "Saxony", "Europe/Berlin"], + ["Dortmund", "NW", "North Rhine-Westphalia", "Europe/Berlin"], + ["Essen", "NW", "North Rhine-Westphalia", "Europe/Berlin"], + ["Bremen", "HB", "Bremen", "Europe/Berlin"], + ["Dresden", "SN", "Saxony", "Europe/Berlin"], + ["Nuremberg", "BY", "Bavaria", "Europe/Berlin", "nürnberg"], + ["Hannover", "NI", "Lower Saxony", "Europe/Berlin"], + ["Bonn", "NW", "North Rhine-Westphalia", "Europe/Berlin"], + ["Heidelberg", "BW", "Baden-Württemberg", "Europe/Berlin"], + ]), + ...build("ES", "Spain", "EUR", [ + ["Madrid", "MD", "Community of Madrid", "Europe/Madrid"], + ["Barcelona", "CT", "Catalonia", "Europe/Madrid"], + ["Valencia", "VC", "Valencia", "Europe/Madrid"], + ["Seville", "AN", "Andalusia", "Europe/Madrid", "sevilla"], + ["Bilbao", "PV", "Basque Country", "Europe/Madrid"], + ["Málaga", "AN", "Andalusia", "Europe/Madrid", "malaga"], + ["Granada", "AN", "Andalusia", "Europe/Madrid"], + ]), + ...build("IT", "Italy", "EUR", [ + ["Rome", "LZ", "Lazio", "Europe/Rome", "roma"], + ["Milan", "LM", "Lombardy", "Europe/Rome", "milano"], + ["Naples", "CM", "Campania", "Europe/Rome", "napoli"], + ["Turin", "PM", "Piedmont", "Europe/Rome", "torino"], + ["Florence", "TC", "Tuscany", "Europe/Rome", "firenze"], + ["Bologna", "ER", "Emilia-Romagna", "Europe/Rome"], + ["Venice", "VN", "Veneto", "Europe/Rome", "venezia"], + ["Genoa", "LG", "Liguria", "Europe/Rome", "genova"], + ["Palermo", "SC", "Sicily", "Europe/Rome"], + ["Verona", "VN", "Veneto", "Europe/Rome"], + ]), + ...build("NL", "Netherlands", "EUR", [ + ["Amsterdam", "NH", "North Holland", "Europe/Amsterdam"], + ["Rotterdam", "ZH", "South Holland", "Europe/Amsterdam"], + ["The Hague", "ZH", "South Holland", "Europe/Amsterdam", "den haag"], + ["Utrecht", "UT", "Utrecht", "Europe/Amsterdam"], + ["Eindhoven", "NB", "North Brabant", "Europe/Amsterdam"], + ]), + ...build("BE", "Belgium", "EUR", [ + ["Brussels", "BRU", "Brussels", "Europe/Brussels", "bruxelles"], + ["Antwerp", "VLG", "Flanders", "Europe/Brussels"], + ["Ghent", "VLG", "Flanders", "Europe/Brussels"], + ["Bruges", "VLG", "Flanders", "Europe/Brussels", "brugge"], + ]), + ...build("AT", "Austria", "EUR", [ + ["Vienna", "W", "Vienna", "Europe/Vienna", "wien"], + ["Graz", "ST", "Styria", "Europe/Vienna"], + ["Salzburg", "SB", "Salzburg", "Europe/Vienna"], + ["Innsbruck", "T", "Tyrol", "Europe/Vienna"], + ]), + ...build("CH", "Switzerland", "CHF", [ + ["Zurich", "ZH", "Zürich", "Europe/Zurich", "zürich"], + ["Geneva", "GE", "Geneva", "Europe/Zurich", "genève"], + ["Basel", "BS", "Basel", "Europe/Zurich"], + ["Bern", "BE", "Bern", "Europe/Zurich"], + ["Lausanne", "VD", "Vaud", "Europe/Zurich"], + ]), + ...build("SE", "Sweden", "SEK", [ + ["Stockholm", "AB", "Stockholm", "Europe/Stockholm"], + ["Gothenburg", "VG", "Västra Götaland", "Europe/Stockholm", "göteborg"], + ["Malmö", "M", "Skåne", "Europe/Stockholm", "malmo"], + ]), + ...build("DK", "Denmark", "DKK", [ + ["Copenhagen", "84", "Capital Region", "Europe/Copenhagen", "københavn"], + ["Aarhus", "82", "Central Denmark", "Europe/Copenhagen"], + ]), + ...build("NO", "Norway", "NOK", [ + ["Oslo", "03", "Oslo", "Europe/Oslo"], + ["Bergen", "46", "Vestland", "Europe/Oslo"], + ["Trondheim", "50", "Trøndelag", "Europe/Oslo"], + ]), + ...build("FI", "Finland", "EUR", [ + ["Helsinki", "18", "Uusimaa", "Europe/Helsinki"], + ["Tampere", "06", "Pirkanmaa", "Europe/Helsinki"], + ["Turku", "02", "Southwest Finland", "Europe/Helsinki"], + ]), + ...build("IE", "Ireland", "EUR", [ + ["Dublin", "L", "Leinster", "Europe/Dublin"], + ["Cork", "M", "Munster", "Europe/Dublin"], + ["Galway", "C", "Connacht", "Europe/Dublin"], + ["Limerick", "M", "Munster", "Europe/Dublin"], + ]), + ...build("PT", "Portugal", "EUR", [ + ["Lisbon", "11", "Lisbon", "Europe/Lisbon", "lisboa"], + ["Porto", "13", "Porto", "Europe/Lisbon"], + ]), + ...build("GR", "Greece", "EUR", [ + ["Athens", "I", "Attica", "Europe/Athens"], + ["Thessaloniki", "B", "Central Macedonia", "Europe/Athens"], + ]), + ...build("PL", "Poland", "PLN", [ + ["Warsaw", "MZ", "Masovia", "Europe/Warsaw", "warszawa"], + ["Kraków", "MA", "Lesser Poland", "Europe/Warsaw", "krakow", "cracow"], + ["Wrocław", "DS", "Lower Silesia", "Europe/Warsaw", "wroclaw"], + ["Gdańsk", "PM", "Pomerania", "Europe/Warsaw", "gdansk"], + ["Poznań", "WP", "Greater Poland", "Europe/Warsaw", "poznan"], + ]), + ...build("CZ", "Czech Republic", "CZK", [ + ["Prague", "PR", "Prague", "Europe/Prague", "praha"], + ["Brno", "JM", "South Moravia", "Europe/Prague"], + ]), + ...build("HU", "Hungary", "HUF", [ + ["Budapest", "BU", "Budapest", "Europe/Budapest"], + ]), + ...build("RO", "Romania", "RON", [ + ["Bucharest", "B", "Bucharest", "Europe/Bucharest", "bucurești"], + ["Cluj-Napoca", "CJ", "Cluj", "Europe/Bucharest"], + ]), + ...build("UA", "Ukraine", "UAH", [ + ["Kyiv", "30", "Kyiv", "Europe/Kyiv", "kiev"], + ["Lviv", "46", "Lviv", "Europe/Kyiv"], + ["Odesa", "51", "Odesa", "Europe/Kyiv", "odessa"], + ["Kharkiv", "63", "Kharkiv", "Europe/Kyiv"], + ]), + ...build("RU", "Russia", "RUB", [ + ["Moscow", "MOW", "Moscow", "Europe/Moscow", "москва"], + ["Saint Petersburg", "SPE", "Saint Petersburg", "Europe/Moscow", "st petersburg"], + ["Novosibirsk", "NVS", "Novosibirsk", "Asia/Novosibirsk"], + ["Yekaterinburg", "SVE", "Sverdlovsk", "Asia/Yekaterinburg"], + ["Kazan", "TA", "Tatarstan", "Europe/Moscow"], + ["Vladivostok", "PRI", "Primorsky", "Asia/Vladivostok"], + ]), + ...build("TR", "Turkey", "TRY", [ + ["Istanbul", "34", "Istanbul", "Europe/Istanbul"], + ["Ankara", "06", "Ankara", "Europe/Istanbul"], + ["Izmir", "35", "Izmir", "Europe/Istanbul"], + ["Antalya", "07", "Antalya", "Europe/Istanbul"], + ["Bursa", "16", "Bursa", "Europe/Istanbul"], + ]), +]; + +// ── Asia / Pacific ─────────────────────────────────────────────────── + +const APAC = [ + ...build("JP", "Japan", "JPY", [ + ["Tokyo", "13", "Tokyo", "Asia/Tokyo"], + ["Osaka", "27", "Osaka", "Asia/Tokyo"], + ["Kyoto", "26", "Kyoto", "Asia/Tokyo"], + ["Yokohama", "14", "Kanagawa", "Asia/Tokyo"], + ["Nagoya", "23", "Aichi", "Asia/Tokyo"], + ["Sapporo", "01", "Hokkaido", "Asia/Tokyo"], + ["Kobe", "28", "Hyogo", "Asia/Tokyo"], + ["Fukuoka", "40", "Fukuoka", "Asia/Tokyo"], + ["Hiroshima", "34", "Hiroshima", "Asia/Tokyo"], + ["Sendai", "04", "Miyagi", "Asia/Tokyo"], + ]), + ...build("KR", "South Korea", "KRW", [ + ["Seoul", "11", "Seoul", "Asia/Seoul"], + ["Busan", "26", "Busan", "Asia/Seoul"], + ["Incheon", "28", "Incheon", "Asia/Seoul"], + ["Daegu", "27", "Daegu", "Asia/Seoul"], + ["Daejeon", "30", "Daejeon", "Asia/Seoul"], + ]), + ...build("CN", "China", "CNY", [ + ["Beijing", "BJ", "Beijing", "Asia/Shanghai", "peking"], + ["Shanghai", "SH", "Shanghai", "Asia/Shanghai"], + ["Guangzhou", "GD", "Guangdong", "Asia/Shanghai", "canton"], + ["Shenzhen", "GD", "Guangdong", "Asia/Shanghai"], + ["Chengdu", "SC", "Sichuan", "Asia/Shanghai"], + ["Chongqing", "CQ", "Chongqing", "Asia/Shanghai"], + ["Wuhan", "HB", "Hubei", "Asia/Shanghai"], + ["Hangzhou", "ZJ", "Zhejiang", "Asia/Shanghai"], + ["Nanjing", "JS", "Jiangsu", "Asia/Shanghai"], + ["Xi'an", "SN", "Shaanxi", "Asia/Shanghai", "xian"], + ["Tianjin", "TJ", "Tianjin", "Asia/Shanghai"], + ["Suzhou", "JS", "Jiangsu", "Asia/Shanghai"], + ["Dongguan", "GD", "Guangdong", "Asia/Shanghai"], + ["Dalian", "LN", "Liaoning", "Asia/Shanghai"], + ["Qingdao", "SD", "Shandong", "Asia/Shanghai"], + ["Kunming", "YN", "Yunnan", "Asia/Shanghai"], + ["Harbin", "HL", "Heilongjiang", "Asia/Shanghai"], + ["Zhengzhou", "HA", "Henan", "Asia/Shanghai"], + ["Changsha", "HN", "Hunan", "Asia/Shanghai"], + ["Xiamen", "FJ", "Fujian", "Asia/Shanghai"], + ["Lhasa", "XZ", "Tibet", "Asia/Shanghai"], + ["Urumqi", "XJ", "Xinjiang", "Asia/Urumqi"], + ]), + ...build("HK", "Hong Kong", "HKD", [ + ["Hong Kong", "HK", "Hong Kong", "Asia/Hong_Kong", "hk"], + ]), + ...build("TW", "Taiwan", "TWD", [ + ["Taipei", "TPE", "Taipei", "Asia/Taipei"], + ["Kaohsiung", "KHH", "Kaohsiung", "Asia/Taipei"], + ["Taichung", "TXG", "Taichung", "Asia/Taipei"], + ]), + ...build("SG", "Singapore", "SGD", [ + ["Singapore", "", "", "Asia/Singapore", "sg"], + ]), + ...build("MY", "Malaysia", "MYR", [ + ["Kuala Lumpur", "14", "Kuala Lumpur", "Asia/Kuala_Lumpur", "kl"], + ["Penang", "07", "Penang", "Asia/Kuala_Lumpur", "george town"], + ["Johor Bahru", "01", "Johor", "Asia/Kuala_Lumpur"], + ]), + ...build("TH", "Thailand", "THB", [ + ["Bangkok", "10", "Bangkok", "Asia/Bangkok"], + ["Chiang Mai", "50", "Chiang Mai", "Asia/Bangkok"], + ["Phuket", "83", "Phuket", "Asia/Bangkok"], + ["Pattaya", "20", "Chonburi", "Asia/Bangkok"], + ]), + ...build("ID", "Indonesia", "IDR", [ + ["Jakarta", "JK", "Jakarta", "Asia/Jakarta"], + ["Surabaya", "JI", "East Java", "Asia/Jakarta"], + ["Bandung", "JB", "West Java", "Asia/Jakarta"], + ["Medan", "SU", "North Sumatra", "Asia/Jakarta"], + ["Bali", "BA", "Bali", "Asia/Makassar", "denpasar"], + ]), + ...build("PH", "Philippines", "PHP", [ + ["Manila", "00", "Metro Manila", "Asia/Manila"], + ["Quezon City", "00", "Metro Manila", "Asia/Manila"], + ["Cebu City", "07", "Central Visayas", "Asia/Manila"], + ["Davao City", "11", "Davao", "Asia/Manila"], + ]), + ...build("VN", "Vietnam", "VND", [ + ["Ho Chi Minh City", "SG", "Ho Chi Minh", "Asia/Ho_Chi_Minh", "saigon"], + ["Hanoi", "HN", "Hanoi", "Asia/Ho_Chi_Minh"], + ["Da Nang", "DN", "Da Nang", "Asia/Ho_Chi_Minh"], + ]), + ...build("BD", "Bangladesh", "BDT", [ + ["Dhaka", "13", "Dhaka", "Asia/Dhaka"], + ["Chittagong", "B", "Chittagong", "Asia/Dhaka"], + ]), + ...build("PK", "Pakistan", "PKR", [ + ["Karachi", "SD", "Sindh", "Asia/Karachi"], + ["Lahore", "PB", "Punjab", "Asia/Karachi"], + ["Islamabad", "IS", "Islamabad", "Asia/Karachi"], + ["Rawalpindi", "PB", "Punjab", "Asia/Karachi"], + ["Faisalabad", "PB", "Punjab", "Asia/Karachi"], + ["Peshawar", "KP", "Khyber Pakhtunkhwa", "Asia/Karachi"], + ]), + ...build("LK", "Sri Lanka", "LKR", [ + ["Colombo", "11", "Western", "Asia/Colombo"], + ]), + ...build("NP", "Nepal", "NPR", [ + ["Kathmandu", "BA", "Bagmati", "Asia/Kathmandu"], + ]), + ...build("MM", "Myanmar", "MMK", [ + ["Yangon", "06", "Yangon", "Asia/Yangon", "rangoon"], + ]), + ...build("KH", "Cambodia", "KHR", [ + ["Phnom Penh", "12", "Phnom Penh", "Asia/Phnom_Penh"], + ]), + ...build("AE", "United Arab Emirates", "AED", [ + ["Dubai", "DU", "Dubai", "Asia/Dubai"], + ["Abu Dhabi", "AZ", "Abu Dhabi", "Asia/Dubai"], + ["Sharjah", "SH", "Sharjah", "Asia/Dubai"], + ]), + ...build("SA", "Saudi Arabia", "SAR", [ + ["Riyadh", "01", "Riyadh", "Asia/Riyadh"], + ["Jeddah", "02", "Makkah", "Asia/Riyadh"], + ["Mecca", "02", "Makkah", "Asia/Riyadh", "makkah"], + ["Medina", "03", "Medina", "Asia/Riyadh"], + ["Dammam", "04", "Eastern", "Asia/Riyadh"], + ]), + ...build("QA", "Qatar", "QAR", [ + ["Doha", "DA", "Doha", "Asia/Qatar"], + ]), + ...build("KW", "Kuwait", "KWD", [ + ["Kuwait City", "KU", "Capital", "Asia/Kuwait"], + ]), + ...build("BH", "Bahrain", "BHD", [ + ["Manama", "13", "Capital", "Asia/Bahrain"], + ]), + ...build("OM", "Oman", "OMR", [ + ["Muscat", "MA", "Muscat", "Asia/Muscat"], + ]), + ...build("IL", "Israel", "ILS", [ + ["Tel Aviv", "TA", "Tel Aviv", "Asia/Jerusalem"], + ["Jerusalem", "JM", "Jerusalem", "Asia/Jerusalem"], + ["Haifa", "HA", "Haifa", "Asia/Jerusalem"], + ]), + ...build("AU", "Australia", "AUD", [ + ["Sydney", "NSW", "New South Wales", "Australia/Sydney"], + ["Melbourne", "VIC", "Victoria", "Australia/Melbourne"], + ["Brisbane", "QLD", "Queensland", "Australia/Brisbane"], + ["Perth", "WA", "Western Australia", "Australia/Perth"], + ["Adelaide", "SA", "South Australia", "Australia/Adelaide"], + ["Canberra", "ACT", "Australian Capital Territory", "Australia/Sydney"], + ["Hobart", "TAS", "Tasmania", "Australia/Hobart"], + ["Darwin", "NT", "Northern Territory", "Australia/Darwin"], + ["Gold Coast", "QLD", "Queensland", "Australia/Brisbane"], + ["Newcastle", "NSW", "New South Wales", "Australia/Sydney"], + ]), + ...build("NZ", "New Zealand", "NZD", [ + ["Auckland", "AUK", "Auckland", "Pacific/Auckland"], + ["Wellington", "WGN", "Wellington", "Pacific/Auckland"], + ["Christchurch", "CAN", "Canterbury", "Pacific/Auckland"], + ["Hamilton", "WKO", "Waikato", "Pacific/Auckland"], + ["Queenstown", "OTA", "Otago", "Pacific/Auckland"], + ]), +]; + +// ── Latin America ──────────────────────────────────────────────────── + +const LATAM = [ + ...build("MX", "Mexico", "MXN", [ + ["Mexico City", "CMX", "Mexico City", "America/Mexico_City", "cdmx", "ciudad de méxico"], + ["Guadalajara", "JAL", "Jalisco", "America/Mexico_City"], + ["Monterrey", "NLE", "Nuevo León", "America/Monterrey"], + ["Cancún", "ROO", "Quintana Roo", "America/Cancun", "cancun"], + ["Puebla", "PUE", "Puebla", "America/Mexico_City"], + ["Tijuana", "BCN", "Baja California", "America/Tijuana"], + ["Mérida", "YUC", "Yucatán", "America/Merida", "merida"], + ["León", "GUA", "Guanajuato", "America/Mexico_City", "leon"], + ["Querétaro", "QUE", "Querétaro", "America/Mexico_City", "queretaro"], + ]), + ...build("BR", "Brazil", "BRL", [ + ["São Paulo", "SP", "São Paulo", "America/Sao_Paulo", "sao paulo"], + ["Rio de Janeiro", "RJ", "Rio de Janeiro", "America/Sao_Paulo", "rio"], + ["Brasília", "DF", "Federal District", "America/Sao_Paulo", "brasilia"], + ["Salvador", "BA", "Bahia", "America/Bahia"], + ["Belo Horizonte", "MG", "Minas Gerais", "America/Sao_Paulo"], + ["Fortaleza", "CE", "Ceará", "America/Fortaleza"], + ["Curitiba", "PR", "Paraná", "America/Sao_Paulo"], + ["Recife", "PE", "Pernambuco", "America/Recife"], + ["Manaus", "AM", "Amazonas", "America/Manaus"], + ["Porto Alegre", "RS", "Rio Grande do Sul", "America/Sao_Paulo"], + ]), + ...build("AR", "Argentina", "ARS", [ + ["Buenos Aires", "C", "Buenos Aires", "America/Argentina/Buenos_Aires"], + ["Córdoba", "X", "Córdoba", "America/Argentina/Cordoba", "cordoba"], + ["Rosario", "S", "Santa Fe", "America/Argentina/Cordoba"], + ["Mendoza", "M", "Mendoza", "America/Argentina/Mendoza"], + ]), + ...build("CL", "Chile", "CLP", [ + ["Santiago", "RM", "Santiago Metropolitan", "America/Santiago"], + ["Valparaíso", "VS", "Valparaíso", "America/Santiago", "valparaiso"], + ]), + ...build("CO", "Colombia", "COP", [ + ["Bogotá", "DC", "Bogotá", "America/Bogota", "bogota"], + ["Medellín", "ANT", "Antioquia", "America/Bogota", "medellin"], + ["Cali", "VAC", "Valle del Cauca", "America/Bogota"], + ["Cartagena", "BOL", "Bolívar", "America/Bogota"], + ["Barranquilla", "ATL", "Atlántico", "America/Bogota"], + ]), + ...build("PE", "Peru", "PEN", [ + ["Lima", "LMA", "Lima", "America/Lima"], + ["Cusco", "CUS", "Cusco", "America/Lima", "cuzco"], + ["Arequipa", "ARE", "Arequipa", "America/Lima"], + ]), + ...build("VE", "Venezuela", "VES", [ + ["Caracas", "DC", "Capital District", "America/Caracas"], + ]), + ...build("EC", "Ecuador", "USD", [ + ["Quito", "P", "Pichincha", "America/Guayaquil"], + ["Guayaquil", "G", "Guayas", "America/Guayaquil"], + ]), + ...build("UY", "Uruguay", "UYU", [ + ["Montevideo", "MO", "Montevideo", "America/Montevideo"], + ]), + ...build("PY", "Paraguay", "PYG", [ + ["Asunción", "ASU", "Asunción", "America/Asuncion", "asuncion"], + ]), + ...build("BO", "Bolivia", "BOB", [ + ["La Paz", "L", "La Paz", "America/La_Paz"], + ["Santa Cruz", "S", "Santa Cruz", "America/La_Paz"], + ]), + ...build("CR", "Costa Rica", "CRC", [ + ["San José", "SJ", "San José", "America/Costa_Rica", "san jose"], + ]), + ...build("PA", "Panama", "PAB", [ + ["Panama City", "8", "Panamá", "America/Panama"], + ]), + ...build("CU", "Cuba", "CUP", [ + ["Havana", "HA", "Havana", "America/Havana"], + ]), + ...build("DO", "Dominican Republic", "DOP", [ + ["Santo Domingo", "01", "Nacional", "America/Santo_Domingo"], + ]), + ...build("PR", "Puerto Rico", "USD", [ + ["San Juan", "SJ", "San Juan", "America/Puerto_Rico"], + ]), + ...build("JM", "Jamaica", "JMD", [ + ["Kingston", "01", "Kingston", "America/Jamaica"], + ]), + ...build("GT", "Guatemala", "GTQ", [ + ["Guatemala City", "GU", "Guatemala", "America/Guatemala"], + ]), +]; + +// ── Africa ─────────────────────────────────────────────────────────── + +const AF = [ + ...build("EG", "Egypt", "EGP", [ + ["Cairo", "C", "Cairo", "Africa/Cairo"], + ["Alexandria", "ALX", "Alexandria", "Africa/Cairo"], + ["Giza", "GZ", "Giza", "Africa/Cairo"], + ]), + ...build("NG", "Nigeria", "NGN", [ + ["Lagos", "LA", "Lagos", "Africa/Lagos"], + ["Abuja", "FC", "Federal Capital Territory", "Africa/Lagos"], + ["Kano", "KN", "Kano", "Africa/Lagos"], + ["Ibadan", "OY", "Oyo", "Africa/Lagos"], + ]), + ...build("KE", "Kenya", "KES", [ + ["Nairobi", "30", "Nairobi", "Africa/Nairobi"], + ["Mombasa", "01", "Mombasa", "Africa/Nairobi"], + ]), + ...build("ZA", "South Africa", "ZAR", [ + ["Cape Town", "WC", "Western Cape", "Africa/Johannesburg"], + ["Johannesburg", "GP", "Gauteng", "Africa/Johannesburg", "joburg", "jhb"], + ["Pretoria", "GP", "Gauteng", "Africa/Johannesburg"], + ["Durban", "KZN", "KwaZulu-Natal", "Africa/Johannesburg"], + ]), + ...build("ET", "Ethiopia", "ETB", [ + ["Addis Ababa", "AA", "Addis Ababa", "Africa/Addis_Ababa"], + ]), + ...build("GH", "Ghana", "GHS", [ + ["Accra", "AA", "Greater Accra", "Africa/Accra"], + ]), + ...build("TZ", "Tanzania", "TZS", [ + ["Dar es Salaam", "02", "Dar es Salaam", "Africa/Dar_es_Salaam"], + ]), + ...build("MA", "Morocco", "MAD", [ + ["Casablanca", "06", "Casablanca-Settat", "Africa/Casablanca"], + ["Rabat", "04", "Rabat-Salé-Kénitra", "Africa/Casablanca"], + ["Marrakech", "07", "Marrakech-Safi", "Africa/Casablanca"], + ]), + ...build("SN", "Senegal", "XOF", [ + ["Dakar", "DK", "Dakar", "Africa/Dakar"], + ]), + ...build("TN", "Tunisia", "TND", [ + ["Tunis", "11", "Tunis", "Africa/Tunis"], + ]), + ...build("UG", "Uganda", "UGX", [ + ["Kampala", "C", "Central", "Africa/Kampala"], + ]), + ...build("RW", "Rwanda", "RWF", [ + ["Kigali", "01", "Kigali", "Africa/Kigali"], + ]), + ...build("CI", "Ivory Coast", "XOF", [ + ["Abidjan", "AB", "Abidjan", "Africa/Abidjan"], + ]), + ...build("CD", "Democratic Republic of Congo", "CDF", [ + ["Kinshasa", "KN", "Kinshasa", "Africa/Kinshasa"], + ]), + ...build("AO", "Angola", "AOA", [ + ["Luanda", "LUA", "Luanda", "Africa/Luanda"], + ]), +]; + +// ── Assemble & export ──────────────────────────────────────────────── + +export const CITIES = [...US, ...CA, ...GB, ...IN, ...EU, ...APAC, ...LATAM, ...AF]; + +// Build a lookup map for O(1) exact-name matching. Keys are normalized: +// lowercased, punctuation stripped, whitespace collapsed. +const lookup = new Map(); +export function normalize(s) { + return String(s || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") // strip diacritics + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +for (const city of CITIES) { + lookup.set(normalize(city.name), city); + for (const alias of city.aliases) { + lookup.set(normalize(alias), city); + } +} + +export function findCity(query) { + return lookup.get(normalize(query)) ?? null; +} + +/** + * Fuzzy fallback: find the closest city by Levenshtein distance, capped at + * `maxDistance`. Returns null if nothing is within the cap. + */ +export function findCityFuzzy(query, { maxDistance = 2 } = {}) { + const q = normalize(query); + if (!q) return null; + if (lookup.has(q)) return lookup.get(q); + + // Scale max distance by query length: short inputs (< 5 chars) only + // get distance-1 matches. This prevents spurious matches like + // "new" → "nyc" (distance 2, but a completely wrong city). + const effectiveMax = q.length < 5 ? Math.min(maxDistance, 1) : maxDistance; + + let best = null; + let bestDist = effectiveMax + 1; + for (const [key, city] of lookup.entries()) { + // Length guard: skip if the lengths are too far apart. + if (Math.abs(key.length - q.length) > effectiveMax) continue; + const d = levenshtein(q, key); + if (d < bestDist) { + bestDist = d; + best = city; + if (d === 0) break; + } + } + return best; +} + +function levenshtein(a, b) { + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + let prev = new Array(b.length + 1); + let curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min( + curr[j - 1] + 1, + prev[j] + 1, + prev[j - 1] + cost, + ); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} diff --git a/packages/sdk/src/hub-client.js b/packages/sdk/src/hub-client.js index ccdc00f..c54cba6 100644 --- a/packages/sdk/src/hub-client.js +++ b/packages/sdk/src/hub-client.js @@ -19,6 +19,7 @@ export class HubClient { this._pending = new Map(); this._listener = null; this._fallback = null; + this._tier = null; } _install() { @@ -31,6 +32,20 @@ export class HubClient { return this._ready; } + // If the Dhamaka browser extension is installed, prefer it. It + // sidesteps storage partitioning entirely by storing models in its own + // origin which is the same across every tab on the machine. + if (typeof window.__dhamaka_extension__ === "object") { + this._extension = true; + this._tier = "extension"; + this._ready = Promise.resolve({ + fallback: false, + extension: true, + tier: "extension", + }); + return this._ready; + } + this._ready = new Promise((resolve, reject) => { let settled = false; const finish = (val, err) => { @@ -45,7 +60,8 @@ export class HubClient { if (typeof msg.type !== "string" || !msg.type.startsWith("dhamaka:")) return; if (msg.type === "dhamaka:ready") { - finish({ fallback: false, origin: msg.origin }); + this._tier = msg.tier ?? "unknown"; + finish({ fallback: false, origin: msg.origin, tier: this._tier }); return; } @@ -91,10 +107,15 @@ export class HubClient { async _call(type, payload, onProgress) { const ready = await this._install(); + if (ready.fallback) { return this._fallback.handle({ type, ...payload }, onProgress); } + if (ready.extension) { + return this._callExtension(type, payload, onProgress); + } + const requestId = this._nextId++; return new Promise((resolve, reject) => { this._pending.set(requestId, { resolve, reject, onProgress }); @@ -105,6 +126,28 @@ export class HubClient { }); } + _callExtension(type, payload, onProgress) { + // The extension content script forwards window.postMessage to the + // background service worker over chrome.runtime.sendMessage, then posts + // the response back with the same requestId. + const requestId = this._nextId++; + return new Promise((resolve, reject) => { + const listener = (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || typeof data !== "object") return; + if (!data.__dhamakaFromExtension) return; + if (data.requestId !== requestId) return; + window.removeEventListener("message", listener); + if (data.type === "dhamaka:error") reject(new Error(data.error)); + else resolve(data); + }; + window.addEventListener("message", listener); + window.postMessage({ type, requestId, ...payload }, "*"); + void onProgress; + }); + } + async ping() { return this._call("dhamaka:ping", {}); } @@ -121,61 +164,203 @@ export class HubClient { return this._call("dhamaka:delete", { id }); } - /** Whether we ended up in fallback mode (site-local cache only). */ + /** + * Which storage tier this client is actually running on. One of: + * + * "shared" cross-site unpartitioned hub iframe (the dream) + * "storage-access" unpartitioned via the Storage Access API + * "partitioned" per-top-site hub iframe (still persistent, not shared) + * "site-local" hub unreachable → per-origin fallback + */ async mode() { const r = await this._install(); - return r.fallback ? "site-local" : "shared"; + if (r.fallback) return "site-local"; + return r.tier ?? this._tier ?? "partitioned"; + } + + /** + * Ask the hub to request unpartitioned storage via the Storage Access API. + * Must be called from a user gesture (click, keypress, etc). + */ + async requestStorageAccess() { + const ready = await this._install(); + if (ready.fallback) { + return { granted: false, tier: "site-local", reason: "hub unreachable" }; + } + return this._call("dhamaka:request-storage-access", {}); } } // ─────────────────────────────────────────────────────────────────────────── // FallbackStore // -// Used when the hub iframe can't be loaded. Stores models in a per-origin +// Used when the hub iframe can't be loaded. In a browser it uses a per-origin // IndexedDB so the site still works offline — just without cross-site sharing. -// In Node it uses an in-memory Map (no persistence). +// In Node (or any DOM-less environment) it falls back to an in-memory Map. // ─────────────────────────────────────────────────────────────────────────── +const FALLBACK_DB = "dhamaka-fallback"; +const FALLBACK_STORE = "models"; + +function hasIndexedDB() { + return typeof indexedDB !== "undefined"; +} + +function openFallbackDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(FALLBACK_DB, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(FALLBACK_STORE)) { + db.createObjectStore(FALLBACK_STORE, { keyPath: "id" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbFallbackGet(id) { + const db = await openFallbackDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(FALLBACK_STORE, "readonly"); + const req = tx.objectStore(FALLBACK_STORE).get(id); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbFallbackPut(record) { + const db = await openFallbackDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(FALLBACK_STORE, "readwrite"); + const req = tx.objectStore(FALLBACK_STORE).put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function idbFallbackDelete(id) { + const db = await openFallbackDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(FALLBACK_STORE, "readwrite"); + const req = tx.objectStore(FALLBACK_STORE).delete(id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function idbFallbackList() { + const db = await openFallbackDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(FALLBACK_STORE, "readonly"); + const req = tx.objectStore(FALLBACK_STORE).getAll(); + req.onsuccess = () => resolve(req.result ?? []); + req.onerror = () => reject(req.error); + }); +} + class FallbackStore { constructor() { this._mem = new Map(); + this._useIdb = hasIndexedDB(); } async handle(msg) { switch (msg.type) { case "dhamaka:ping": - return { pong: true, fallback: true }; + return { pong: true, fallback: true, persistent: this._useIdb }; case "dhamaka:get": return this._get(msg); case "dhamaka:list": - return { list: [...this._mem.values()].map((r) => ({ id: r.id, entry: r.entry })) }; + return this._list(); case "dhamaka:delete": - this._mem.delete(msg.id); - return { deleted: msg.id }; + return this._delete(msg.id); default: throw new Error(`fallback: unknown ${msg.type}`); } } + async _lookup(id) { + if (this._useIdb) return idbFallbackGet(id); + return this._mem.get(id); + } + + async _store(record) { + if (this._useIdb) return idbFallbackPut(record); + this._mem.set(record.id, record); + } + async _get(msg) { - const cached = this._mem.get(msg.id); + const cached = await this._lookup(msg.id); if (cached) return { cached: true, ...cached }; - const manifestUrl = msg.manifestUrl ?? "./manifest.json"; + // Resolve manifest URL. If the caller gave us one, use it; otherwise fall + // back to one relative to the current page (browser) or refuse (Node). + let manifestUrl = msg.manifestUrl; + if (!manifestUrl) { + if (typeof location !== "undefined" && location.href) { + manifestUrl = new URL("./manifest.json", location.href).href; + } else { + throw new Error( + "fallback: no manifestUrl provided and no page URL to resolve against", + ); + } + } const manifestRes = await fetch(manifestUrl); + if (!manifestRes.ok) { + throw new Error(`fallback manifest fetch failed: ${manifestRes.status}`); + } const manifest = await manifestRes.json(); - const entry = manifest.models.find((m) => m.id === msg.id); + const entry = (manifest.models ?? []).find((m) => m.id === msg.id); if (!entry) throw new Error(`unknown model: ${msg.id}`); const artifacts = {}; for (const [name, artifact] of Object.entries(entry.artifacts ?? {})) { - const res = await fetch(artifact.url); - if (!res.ok) throw new Error(`fallback fetch failed: ${res.status}`); + const absUrl = new URL(artifact.url, manifestUrl).href; + const res = await fetch(absUrl); + if (!res.ok) { + throw new Error(`fallback fetch failed: ${res.status} ${absUrl}`); + } artifacts[name] = new Uint8Array(await res.arrayBuffer()); } - const record = { id: msg.id, entry, artifacts }; - this._mem.set(msg.id, record); + const record = { id: msg.id, entry, artifacts, fetchedAt: Date.now() }; + await this._store(record); return { cached: false, ...record }; } + + async _list() { + if (this._useIdb) { + const rows = await idbFallbackList(); + return { + list: rows.map((r) => ({ + id: r.id, + entry: r.entry, + fetchedAt: r.fetchedAt, + size: Object.values(r.artifacts ?? {}).reduce( + (s, b) => s + (b?.byteLength ?? 0), + 0, + ), + })), + }; + } + return { + list: [...this._mem.values()].map((r) => ({ + id: r.id, + entry: r.entry, + fetchedAt: r.fetchedAt, + size: Object.values(r.artifacts ?? {}).reduce( + (s, b) => s + (b?.byteLength ?? 0), + 0, + ), + })), + }; + } + + async _delete(id) { + if (this._useIdb) await idbFallbackDelete(id); + else this._mem.delete(id); + return { deleted: id }; + } } diff --git a/packages/sdk/src/index.js b/packages/sdk/src/index.js index d6ac26c..37e725f 100644 --- a/packages/sdk/src/index.js +++ b/packages/sdk/src/index.js @@ -1,9 +1,15 @@ // ╭──────────────────────────────────────────────────────────────────────╮ // │ dhamaka — the public SDK │ // │ │ -// │ import { Dhamaka } from "dhamaka"; │ -// │ const llm = await Dhamaka.load(); │ -// │ for await (const t of llm.stream("Hello")) process.stdout.write(t)│ +// │ A reflex layer for every input on the web. Drop in a SmartField or │ +// │ SmartForm, get on-device intelligence (autofill, spellcheck, smart │ +// │ paste, cross-field inference) with zero network latency. │ +// │ │ +// │ import { SmartField, SmartForm, SmartText } from "dhamaka"; │ +// │ │ +// │ new SmartField(document.querySelector("#city"), { │ +// │ task: "city-to-state", │ +// │ }); │ // │ │ // ╰──────────────────────────────────────────────────────────────────────╯ @@ -11,21 +17,73 @@ import { createEngine } from "@dhamaka/runtime"; import { HubClient } from "./hub-client.js"; import { Chat } from "./chat.js"; +// Auto-register the Transform-family formula tasks. This is a +// side-effect import — pulling in `dhamaka` at all registers every +// built-in task so apps don't have to chase per-family imports. +import "./tasks/formula.js"; + +// Auto-register US tax tasks (sales tax + federal income tax). +import "./tasks/us-tax.js"; + +// ─── Reflex family ──────────────────────────────────────────────────── + +export { SmartField } from "./smart-field.js"; +export { SmartForm } from "./smart-form.js"; +export { SmartText } from "./smart-text.js"; +export { attachSmartPaste } from "./paste-extract.js"; + +// ─── Transform family ───────────────────────────────────────────────── + +export { Transform } from "./transform.js"; +export { + formulaTransformTask, + formulaExplainTask, + formulaDebugTask, +} from "./tasks/formula.js"; + +// ─── US Tax family ──────────────────────────────────────────────────── + +export { + usSalesTaxTask, + usFederalTaxTask, + STATE_TAX, + BRACKETS_2024, + STANDARD_DEDUCTION_2024, +} from "./tasks/us-tax.js"; + +// ─── shared infrastructure ──────────────────────────────────────────── + +export { reflex } from "./reflex.js"; +export { + runTask, + registerTask, + getTask, + listTasks, + cityToStateTask, + spellcheckTask, + pasteExtractTask, +} from "./tasks.js"; + +// ─── legacy / advanced surface ──────────────────────────────────────── +// Kept for people who want direct model access (chat, completion, +// streaming). Most users should use the SmartField API above. + const DEFAULT_MODEL = "dhamaka-micro"; const DEFAULT_HUB_URL = "https://hub.dhamaka.dev/"; /** * @typedef {object} DhamakaLoadOptions - * @property {string} [hubUrl] URL of the Dhamaka hub iframe. - * @property {string} [manifestUrl] Override for the model manifest. - * @property {"auto"|"mock"|"wasm"} [backend] Runtime backend. - * @property {string} [wasmUrl] URL of the WASM module. + * @property {string} [hubUrl] + * @property {string} [manifestUrl] + * @property {"auto"|"mock"|"wasm"|"window-ai"} [backend] + * @property {string} [wasmUrl] * @property {(p: object) => void} [onProgress] */ export class Dhamaka { /** - * Load a Dhamaka model. + * Load a Dhamaka model directly. Lower-level than SmartField — use this + * when you want raw completion / streaming / chat access. * @param {string} [modelId=DEFAULT_MODEL] * @param {DhamakaLoadOptions} [options] */ @@ -35,16 +93,23 @@ export class Dhamaka { return instance; } - /** @param {string} modelId @param {DhamakaLoadOptions} options */ constructor(modelId, options) { this.modelId = modelId; this.options = options; - this.hub = new HubClient({ - hubUrl: options.hubUrl ?? DEFAULT_HUB_URL, - }); + const hubUrl = options.hubUrl ?? DEFAULT_HUB_URL; + this.hub = new HubClient({ hubUrl }); + + let wasmUrl = options.wasmUrl; + if (!wasmUrl && typeof URL !== "undefined") { + try { + wasmUrl = new URL("runtime/dhamaka-runtime.wasm", hubUrl).href; + } catch { + /* fall through */ + } + } this.engine = createEngine({ backend: options.backend ?? "auto", - wasmUrl: options.wasmUrl, + wasmUrl, }); this._cached = false; this._loadedAt = 0; @@ -57,42 +122,22 @@ export class Dhamaka { onProgress: (p) => this.options.onProgress?.(p), }); this._cached = result.cached; - - await this.engine.load({ - entry: result.entry, - artifacts: result.artifacts, - }); + await this.engine.load({ entry: result.entry, artifacts: result.artifacts }); this._loadedAt = (globalThis.performance ?? Date).now() - t0; } - /** - * One-shot completion. - * @param {string} prompt - * @param {object} [options] - */ async complete(prompt, options) { return this.engine.complete(prompt, options); } - /** - * Stream tokens as an async iterator. - * @param {string} prompt - * @param {object} [options] - */ async *stream(prompt, options) { yield* this.engine.generate(prompt, options); } - /** - * Start a stateful chat session. - * @param {object} [options] - * @param {string} [options.system] - */ chat(options = {}) { return new Chat(this, options); } - /** Runtime + cache information. */ info() { return { model: this.modelId, @@ -102,12 +147,10 @@ export class Dhamaka { }; } - /** List models currently sitting in the hub's local storage. */ async localModels() { return this.hub.list(); } - /** Evict a model from the hub's local storage. */ async evict(id) { return this.hub.delete(id); } diff --git a/packages/sdk/src/openai-shim.js b/packages/sdk/src/openai-shim.js index 4e60811..b7314cf 100644 --- a/packages/sdk/src/openai-shim.js +++ b/packages/sdk/src/openai-shim.js @@ -19,7 +19,22 @@ export function installOpenAIShim(dhamaka, { path = "/v1/chat/completions" } = { const url = typeof input === "string" ? input : input?.url ?? ""; if (!url.endsWith(path)) return originalFetch(input, init); - const body = init?.body ? JSON.parse(init.body) : {}; + let body = {}; + const raw = init?.body; + if (raw) { + try { + if (typeof raw === "string") body = JSON.parse(raw); + else if (raw instanceof ArrayBuffer) body = JSON.parse(new TextDecoder().decode(raw)); + else if (ArrayBuffer.isView(raw)) body = JSON.parse(new TextDecoder().decode(raw)); + else if (typeof raw.text === "function") body = JSON.parse(await raw.text()); + else body = JSON.parse(String(raw)); + } catch { + return new Response( + JSON.stringify({ error: { message: "invalid JSON body" } }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + } const messages = body.messages ?? []; const stream = !!body.stream; diff --git a/packages/sdk/src/paste-extract.js b/packages/sdk/src/paste-extract.js new file mode 100644 index 0000000..f126c66 --- /dev/null +++ b/packages/sdk/src/paste-extract.js @@ -0,0 +1,67 @@ +// Smart-paste helper. +// +// Wires a
element so that when the user pastes a blob of text +// anywhere inside it (or into a designated drop zone), the paste-extract +// task splits the blob into structured fields and fills them in, as long +// as the user hasn't already manually typed a value there. + +import { reflex } from "./reflex.js"; + +/** + * @param {HTMLFormElement} form + * @param {object} [options] + * @param {HTMLElement} [options.dropZone] Optional element to watch for paste + * events separately from the form (e.g. a dashed "paste a business card here" + * panel). Falls back to the form itself. + * @param {Record} [options.fields] + * Map of task result fields to form input names, e.g. { name: "fullName" }. + * Defaults to identity — the result key is the input name. + */ +export function attachSmartPaste(form, options = {}) { + if (!form || form.tagName !== "FORM") { + throw new Error("attachSmartPaste: first argument must be a element"); + } + const target = options.dropZone ?? form; + const mapping = options.fields ?? {}; + + const handler = async (event) => { + const clipboard = event.clipboardData || window.clipboardData; + if (!clipboard) return; + const text = clipboard.getData("text/plain") || clipboard.getData("text"); + if (!text || !text.includes("\n") && text.length < 20) return; // probably a plain word-level paste + + // If the paste target is an input and it was empty, let the extraction + // run and populate structured fields — don't also let the raw text fall + // into the input. + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + const input = event.target; + if (input.value === "") { + event.preventDefault(); + } + } + + const result = await reflex.run("paste-extract", text, { threshold: 0.8 }); + const fields = result.fields ?? {}; + + for (const [key, value] of Object.entries(fields)) { + if (value == null || value === "") continue; + const targetName = mapping[key] ?? key; + const el = form.elements.namedItem(targetName); + if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) continue; + if (el.value && el.value !== text) continue; // user already typed here + el.value = Array.isArray(value) ? value[0] : String(value); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + + form.dispatchEvent( + new CustomEvent("smart-paste:extracted", { + detail: { text, result }, + bubbles: true, + }), + ); + }; + + target.addEventListener("paste", handler); + return () => target.removeEventListener("paste", handler); +} diff --git a/packages/sdk/src/reflex.js b/packages/sdk/src/reflex.js new file mode 100644 index 0000000..d392125 --- /dev/null +++ b/packages/sdk/src/reflex.js @@ -0,0 +1,109 @@ +// The reflex service. +// +// A module-level singleton that holds the "resident" inference engine for +// the page and routes all task calls through it. The first SmartField that +// needs a model kicks off the load; subsequent calls reuse the same warm +// engine with no cold start. +// +// v0.1 scope: a plain module singleton. v0.2 upgrades this to a +// SharedWorker so every tab on the same origin shares one engine instance. +// The public API is deliberately the same either way, so the upgrade is +// drop-in for consumers. + +import { createEngine } from "@dhamaka/runtime"; +import { runTask } from "./tasks.js"; + +let _state = { + engine: null, + loading: null, + options: null, + loaded: false, +}; + +/** + * Configure the reflex service. Safe to call multiple times — each call + * overrides the config for the next `ensure()` invocation. + * + * @param {object} options + * @param {"auto"|"mock"|"wasm"|"window-ai"|"transformers"} [options.backend] + * @param {string} [options.wasmUrl] + * @param {string} [options.model] Transformers.js HF model id + * @param {string} [options.task] Transformers.js pipeline task + * @param {string} [options.cdn] Transformers.js CDN override + * @param {string} [options.systemPrompt] + * @param {object} [options.entry] Model manifest entry hint + * @param {(p: object) => void} [options.onProgress] First-load progress callback + */ +export function configure(options = {}) { + _state.options = options; +} + +/** + * Lazily instantiate and load the engine. Subsequent calls return the same + * promise (so concurrent SmartFields on a page share one load). + */ +export function ensure() { + if (_state.loaded) return Promise.resolve(_state.engine); + if (_state.loading) return _state.loading; + + _state.loading = (async () => { + const engine = createEngine(_state.options ?? {}); + try { + await engine.load({ entry: _state.options?.entry ?? null }); + _state.engine = engine; + _state.loaded = true; + return engine; + } catch (err) { + _state.loading = null; + throw err; + } + })(); + + return _state.loading; +} + +/** + * Run a task against the resident engine. + * + * If `eager` is true we await the engine and always run through the full + * task pipeline (fast → slow). If false (default) we run the rules-only + * fast path synchronously and only defer to the model when the fast path + * is uncertain *and* the engine is already warm. + * + * @param {string} taskId + * @param {string} input + * @param {object} [options] + * @param {boolean} [options.eager=false] + * @param {number} [options.threshold=0.8] + * @param {object} [options.context] + */ +export async function run(taskId, input, options = {}) { + const eager = options.eager ?? false; + const threshold = options.threshold ?? 0.8; + + if (eager) { + const engine = await ensure(); + return runTask(taskId, input, { ...options, engine, threshold }); + } + + // Non-eager path: rules-only unless the engine is already loaded. + const engine = _state.loaded ? _state.engine : null; + return runTask(taskId, input, { ...options, engine, threshold }); +} + +/** For tests and demos that want to reach past the singleton. */ +export function __reset() { + _state = { engine: null, loading: null, options: null, loaded: false }; +} + +/** Inspect the current reflex state (for telemetry + debugging). */ +export function info() { + return { + loaded: _state.loaded, + loading: !!_state.loading && !_state.loaded, + backend: _state.engine?.info?.()?.backend ?? null, + options: _state.options ?? null, + }; +} + +export const reflex = { configure, ensure, run, info, __reset }; diff --git a/packages/sdk/src/smart-field.js b/packages/sdk/src/smart-field.js new file mode 100644 index 0000000..5327bac --- /dev/null +++ b/packages/sdk/src/smart-field.js @@ -0,0 +1,94 @@ +// SmartField. +// +// Wraps an element with on-device intelligence. The developer +// picks a task (e.g. "city-to-state") and the field does the rest: +// +// - listens on `input` events +// - runs the task against the reflex service +// - dispatches a synthetic `smart-field:resolved` CustomEvent +// whose `detail` is the task result +// +// The SmartField does not touch any other fields directly. Cross-field +// propagation is the job of SmartForm. + +import { reflex } from "./reflex.js"; + +const DEFAULT_DEBOUNCE_MS = 0; // zero-latency on-device → no debounce needed + +export class SmartField { + /** + * @param {HTMLInputElement} el + * @param {object} options + * @param {string} options.task Task id from the registry + * @param {number} [options.debounceMs] + * @param {number} [options.threshold] + * @param {boolean} [options.eager] If true, always hit the model path + * @param {(r: object) => void} [options.onResult] + */ + constructor(el, options) { + if (!el || typeof el.addEventListener !== "function") { + throw new Error("SmartField: first argument must be an Element"); + } + if (!options || typeof options.task !== "string") { + throw new Error("SmartField: options.task is required"); + } + this.el = el; + this.task = options.task; + this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + this.threshold = options.threshold ?? 0.6; + this.eager = options.eager ?? false; + this.onResult = options.onResult ?? null; + this._timer = null; + this._disposed = false; + this._lastResult = null; + + this._handler = () => this._onInput(); + this.el.addEventListener("input", this._handler); + + // Run once on construction in case the field already has a value + // (e.g. browser autofill or server-rendered pre-fill). + if (this.el.value) this._onInput(); + } + + _onInput() { + if (this._disposed) return; + const value = this.el.value ?? ""; + if (this.debounceMs > 0) { + clearTimeout(this._timer); + this._timer = setTimeout(() => this._run(value), this.debounceMs); + } else { + this._run(value); + } + } + + async _run(value) { + const result = await reflex.run(this.task, value, { + eager: this.eager, + threshold: this.threshold, + }); + if (this._disposed) return; + this._lastResult = result; + this.onResult?.(result); + this.el.dispatchEvent( + new CustomEvent("smart-field:resolved", { + detail: { task: this.task, input: value, result }, + bubbles: true, + }), + ); + } + + /** Force a re-run against the current value. */ + refresh() { + this._onInput(); + } + + get lastResult() { + return this._lastResult; + } + + dispose() { + this._disposed = true; + clearTimeout(this._timer); + this.el.removeEventListener("input", this._handler); + } +} diff --git a/packages/sdk/src/smart-form.js b/packages/sdk/src/smart-form.js new file mode 100644 index 0000000..8994e1f --- /dev/null +++ b/packages/sdk/src/smart-form.js @@ -0,0 +1,123 @@ +// SmartForm. +// +// Orchestrates cross-field inference on a element. +// +// The developer declares which source field feeds which target field via +// simple arrow strings: +// +// new SmartForm(document.querySelector("#checkout"), { +// infer: { +// "city → state": "city-to-state:stateName", +// "city → country": "city-to-state:countryName", +// "city → timezone": "city-to-state:tz", +// }, +// }); +// +// When a source field fires a `smart-field:resolved` event with a matching +// task result, the target fields are populated from the result's `fields` +// object using the suffix after the `:`. Manual edits to a target field +// disengage automatic propagation for that field. + +import { SmartField } from "./smart-field.js"; + +export class SmartForm { + /** + * @param {HTMLFormElement} form + * @param {object} options + * @param {Record} [options.infer] + * Map of "sourceName → targetName" to "taskId:resultField". + * @param {Record} [options.tasks] + * Map of field name to task id (to auto-attach SmartFields). + */ + constructor(form, options = {}) { + if (!form || form.tagName !== "FORM") { + throw new Error("SmartForm: first argument must be a element"); + } + this.form = form; + this.infer = options.infer ?? {}; + this.smartFields = new Map(); + this.manualEdits = new Set(); + this._disposed = false; + + // Auto-attach SmartFields when a task map is provided. + if (options.tasks) { + for (const [fieldName, taskId] of Object.entries(options.tasks)) { + const el = form.elements.namedItem(fieldName); + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + this.smartFields.set( + fieldName, + new SmartField(el, { task: taskId }), + ); + } + } + } + + // Listen for any resolved events bubbling up from child SmartFields. + this._onResolved = (e) => this._handleResolved(e); + form.addEventListener("smart-field:resolved", this._onResolved); + + // Track manual edits to target fields so we don't stomp them. + this._onInput = (e) => { + const t = e.target; + if (!(t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement)) return; + if (this._programmatic) return; + this.manualEdits.add(t.name); + }; + form.addEventListener("input", this._onInput, true); + } + + _handleResolved(event) { + const detail = event.detail; + if (!detail || !detail.result) return; + const sourceEl = event.target; + if (!sourceEl || !sourceEl.name) return; + + const sourceName = sourceEl.name; + const fields = detail.result.fields ?? {}; + + // Walk every declared inference rule whose source matches. + for (const [rule, mapping] of Object.entries(this.infer)) { + const [src, tgt] = rule.split(/\s*(?:→|->|>)\s*/).map((s) => s.trim()); + if (src !== sourceName) continue; + + const [taskId, resultKey] = mapping.split(":"); + if (taskId && detail.task !== taskId) continue; + if (!resultKey) continue; + + const targetEl = this.form.elements.namedItem(tgt); + if (!(targetEl instanceof HTMLInputElement || targetEl instanceof HTMLSelectElement || targetEl instanceof HTMLTextAreaElement)) continue; + if (this.manualEdits.has(tgt)) continue; // user has taken over this field + + // Set the value if present, or clear it when there's no match. + // This prevents stale data from intermediate keystrokes sticking + // (e.g., typing "newport" briefly matching "nyc" at "new"). + const value = fields[resultKey]; + this._programmatic = true; + try { + targetEl.value = (value != null && value !== "") ? String(value) : ""; + targetEl.dispatchEvent(new Event("change", { bubbles: true })); + } finally { + this._programmatic = false; + } + } + } + + /** Mark a target field as manually edited (won't be auto-filled again). */ + lock(fieldName) { + this.manualEdits.add(fieldName); + } + + /** Forget manual-edit flags and let inference take over again. */ + unlock(fieldName) { + if (fieldName) this.manualEdits.delete(fieldName); + else this.manualEdits.clear(); + } + + dispose() { + this._disposed = true; + this.form.removeEventListener("smart-field:resolved", this._onResolved); + this.form.removeEventListener("input", this._onInput, true); + for (const sf of this.smartFields.values()) sf.dispose(); + this.smartFields.clear(); + } +} diff --git a/packages/sdk/src/smart-text.js b/packages/sdk/src/smart-text.js new file mode 100644 index 0000000..9be7d22 --- /dev/null +++ b/packages/sdk/src/smart-text.js @@ -0,0 +1,75 @@ +// SmartText. +// +// Wraps a