From 7a0ad5c7b57a460d42e937b5770934da3a46124e Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:00:10 +0000 Subject: [PATCH 01/29] Work through the pending punch list from the Dhamaka scaffold Tests (40 total, node --test, zero deps): - runtime: MockEngine load/generate/abort/determinism, Tokenizer split variants, factory backend selection, WasmEngine load-without-url refusal - sdk: Chat history/reset/stream/system-prompt, HubClient fallback get/list/delete with mocked fetch, OpenAI shim streaming + non-streaming + passthrough - hub: canonical + served manifest structural checks FallbackStore is now real: - Uses a per-origin IndexedDB in browsers, in-memory only in Node - Resolves artifact URLs relative to the configured manifest URL - Reports list entries with size + fetchedAt Storage Access API tier: - Hub detects current storage tier and advertises it in the ready handshake - Hub accepts a dhamaka:request-storage-access message and calls document.requestStorageAccess() under a user gesture - HubClient.mode() now returns "shared" | "storage-access" | "partitioned" | "site-local" | "extension", with a new requestStorageAccess() method Browser extension (phase 2 skeleton): - Manifest V3 background service worker with IndexedDB + SHA-256 - Content script bridge (postMessage <-> chrome.runtime.sendMessage) - Marker injection (window.__dhamaka_extension__) so the SDK auto-detects - SDK prefers the extension over the iframe hub when available - Options page listing cached models with evict controls Playground UX: - Stateful chat (one Chat session per load, not one per message) - Stop button bound to an AbortController; MockEngine already honors signal - Reset button clears history but keeps the loaded model - Small CSS polish for aborted messages Other fixes: - OpenAI shim robustly parses Blob/ArrayBuffer/TypedArray/string bodies - manifest.schema.json (JSON Schema draft-07) now exists as $schema claimed - CI workflow runs tests on Node 20 and 22 plus a dev-server smoke test - README status section updated to reflect what's newly real --- .github/workflows/ci.yml | 51 +++++ README.md | 14 +- package.json | 2 +- packages/extension/README.md | 54 ++++++ packages/extension/background.js | 186 +++++++++++++++++++ packages/extension/content.js | 52 ++++++ packages/extension/manifest.json | 42 +++++ packages/extension/options.html | 50 +++++ packages/extension/options.js | 56 ++++++ packages/extension/package.json | 16 ++ packages/hub/public/hub.js | 83 ++++++++- packages/hub/public/manifest.schema.json | 73 ++++++++ packages/hub/test/manifest.test.js | 69 +++++++ packages/playground/public/app.js | 62 ++++++- packages/playground/public/index.html | 2 + packages/playground/public/styles.css | 2 + packages/runtime/test/factory.test.js | 37 ++++ packages/runtime/test/mock-engine.test.js | 77 ++++++++ packages/runtime/test/tokenizer.test.js | 54 ++++++ packages/sdk/src/hub-client.js | 217 ++++++++++++++++++++-- packages/sdk/src/openai-shim.js | 17 +- packages/sdk/test/chat.test.js | 65 +++++++ packages/sdk/test/hub-client.test.js | 113 +++++++++++ packages/sdk/test/openai-shim.test.js | 79 ++++++++ 24 files changed, 1441 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 packages/extension/README.md create mode 100644 packages/extension/background.js create mode 100644 packages/extension/content.js create mode 100644 packages/extension/manifest.json create mode 100644 packages/extension/options.html create mode 100644 packages/extension/options.js create mode 100644 packages/extension/package.json create mode 100644 packages/hub/public/manifest.schema.json create mode 100644 packages/hub/test/manifest.test.js create mode 100644 packages/runtime/test/factory.test.js create mode 100644 packages/runtime/test/mock-engine.test.js create mode 100644 packages/runtime/test/tokenizer.test.js create mode 100644 packages/sdk/test/chat.test.js create mode 100644 packages/sdk/test/hub-client.test.js create mode 100644 packages/sdk/test/openai-shim.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f35c75 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["20", "22"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - 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: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/README.md b/README.md index 83c8ce8..ce005d8 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ One download. Every site after that is an instant cache hit. | [`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/extension`](packages/extension) | Manifest V3 browser extension — shared cache across every site on the machine | | [`@dhamaka/playground`](packages/playground) | a live demo + a zero-dep dev server that runs the whole stack | --- @@ -242,17 +243,22 @@ Modern browsers increasingly **partition third-party storage** by the top-level [x] IndexedDB-backed hub storage with SHA-256 integrity checks [x] zero-copy ArrayBuffer transfer from hub → consumer [x] Dhamaka.load, complete, stream, chat, info, evict - [x] site-local fallback cache when the hub iframe isn't reachable + [x] fallback cache (real IndexedDB in browsers, in-memory in Node) + [x] Storage Access API tier for unpartitioned storage on strict browsers + [x] Manifest V3 browser extension (phase 2) — sidesteps partitioning + [x] SDK auto-detection of the extension, with tiered mode reporting [x] OpenAI /v1/chat/completions shim (streaming + non-streaming) [x] manifest + multi-artifact model layout + signed-hash verification - [x] playground UI with progress bars, telemetry, cache-hit badge + [x] manifest.schema.json (JSON Schema draft-07) for tooling + [x] playground UI with progress bars, telemetry, cache-hit badge, + stateful chat, abort/stop button, and reset-history [x] zero-dependency dev server that serves hub + playground on two ports + [x] 40 tests covering runtime, SDK, hub, and OpenAI shim + [x] GitHub Actions CI running tests on Node 20 + 22 [ ] the actual WASM transformer runtime (ABI sketched, loader ready) [ ] SmolLM2-360M Q4 weights hosted on hub.dhamaka.dev [ ] WebGPU fast path - [ ] Storage Access API flow - [ ] browser extension (phase 2) [ ] the other registered models (code / sql / json / summarize / embed) ``` diff --git a/package.json b/package.json index b95f96a..823661a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "node packages/playground/server.js", "start": "node packages/playground/server.js", - "test": "node --test packages/*/test/*.test.js" + "test": "node --test --test-reporter=spec 'packages/runtime/test/*.test.js' 'packages/sdk/test/*.test.js' 'packages/hub/test/*.test.js'" }, "license": "MIT", "author": "Dhamaka contributors", diff --git a/packages/extension/README.md b/packages/extension/README.md new file mode 100644 index 0000000..2da456c --- /dev/null +++ b/packages/extension/README.md @@ -0,0 +1,54 @@ +# @dhamaka/extension + +The Dhamaka browser extension. Ships the cross-site model cache as a native browser extension, which sidesteps third-party storage partitioning entirely. + +## Why + +Modern browsers partition third-party iframe storage by top-level site. That means the shared-hub iframe trick degrades to per-site caching in strict modes. A browser extension doesn't have this problem: its origin (`chrome-extension://…`) is the same everywhere it's installed, so an IndexedDB stored there is genuinely shared across every tab. + +## Architecture + +``` + ┌──────────────┐ postMessage ┌────────────────┐ chrome.runtime ┌────────────────┐ + │ page JS │ ◀─────────────────▶ │ content.js │ ◀────────────────▶ │ background.js │ + │ (SDK) │ │ (bridge) │ │ (service │ + │ │ │ │ │ worker) │ + └──────────────┘ └────────────────┘ └────┬───────────┘ + │ + ▼ + ┌─────────────┐ + │ IndexedDB │ + │ (extension │ + │ origin) │ + └─────────────┘ +``` + +1. `content.js` injects a tiny marker (`window.__dhamaka_extension__`) so the SDK can detect the extension is installed. +2. When `Dhamaka.load()` runs, `HubClient._install()` sees the marker and switches to extension mode instead of injecting the hub iframe. +3. Messages flow page → content script → background worker. The worker handles storage in its own IndexedDB and responds with the cached bytes. +4. The SDK's `hub.mode()` reports `"extension"` so apps can display "shared across every site" confidently. + +## Install (dev) + +1. Open `chrome://extensions` in Chrome or Edge. +2. Enable **Developer mode**. +3. Click **Load unpacked** and select `packages/extension/`. +4. Visit any Dhamaka-powered site — `Dhamaka.hub.mode()` should now return `"extension"`. + +## Status + +This is the **phase-2 skeleton**. It covers: + +- Manifest V3 background service worker +- Content script bridge on every origin +- IndexedDB storage + SHA-256 integrity verification +- `get` / `list` / `delete` / `ping` over the same protocol as the hub iframe +- A tiny options page that lists cached models and lets you evict them +- SDK auto-detection via the injected marker + +Not yet covered: + +- Signed manifest pinning +- Progress events during download (Chrome's message channel can't stream) +- Firefox port (Manifest V3 in Firefox is still shifting) +- An actual published listing on the Chrome Web Store diff --git a/packages/extension/background.js b/packages/extension/background.js new file mode 100644 index 0000000..4309698 --- /dev/null +++ b/packages/extension/background.js @@ -0,0 +1,186 @@ +// ╭──────────────────────────────────────────────────────────────────────╮ +// │ Dhamaka extension — background service worker │ +// │ │ +// │ Stores Dhamaka models once per machine in the extension's own │ +// │ origin (chrome-extension://…). Because this origin is the same │ +// │ everywhere the extension is installed, the cache is genuinely │ +// │ shared across every site the user visits — sidestepping the │ +// │ storage partitioning that weakens the standalone iframe approach. │ +// │ │ +// │ Content scripts on consumer sites talk to this worker via │ +// │ chrome.runtime.sendMessage, and the SDK's HubClient detects the │ +// │ extension via a probe and prefers it over the iframe hub when │ +// │ available. │ +// ╰──────────────────────────────────────────────────────────────────────╯ + +const DB_NAME = "dhamaka-extension"; +const DB_VERSION = 1; +const STORE_MODELS = "models"; + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_MODELS)) { + db.createObjectStore(STORE_MODELS, { keyPath: "id" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbGet(id) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MODELS, "readonly"); + const req = tx.objectStore(STORE_MODELS).get(id); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbPut(record) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MODELS, "readwrite"); + const req = tx.objectStore(STORE_MODELS).put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function idbDelete(id) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MODELS, "readwrite"); + const req = tx.objectStore(STORE_MODELS).delete(id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function idbList() { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_MODELS, "readonly"); + const req = tx.objectStore(STORE_MODELS).getAll(); + req.onsuccess = () => resolve(req.result ?? []); + req.onerror = () => reject(req.error); + }); +} + +async function sha256Hex(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function shouldVerify(sha) { + return typeof sha === "string" && /^[0-9a-f]{64}$/i.test(sha) && !/^0+$/.test(sha); +} + +async function downloadAndStore(id, manifestUrl) { + const res = await fetch(manifestUrl, { cache: "no-cache" }); + if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`); + const manifest = await res.json(); + const entry = manifest.models?.find((m) => m.id === id); + if (!entry) throw new Error(`unknown model: ${id}`); + + const artifacts = {}; + for (const [name, artifact] of Object.entries(entry.artifacts ?? {})) { + const absUrl = new URL(artifact.url, manifestUrl).href; + const ar = await fetch(absUrl); + if (!ar.ok) throw new Error(`artifact fetch failed: ${ar.status} ${absUrl}`); + const bytes = new Uint8Array(await ar.arrayBuffer()); + if (shouldVerify(artifact.sha256)) { + const hex = await sha256Hex(bytes); + if (hex !== artifact.sha256.toLowerCase()) { + throw new Error(`integrity check failed for ${id}/${name}`); + } + } + artifacts[name] = bytes; + } + + const record = { id, entry, artifacts, fetchedAt: Date.now() }; + await idbPut(record); + return record; +} + +// ─── Message handlers ───────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (!msg || typeof msg !== "object") return; + if (typeof msg.type !== "string" || !msg.type.startsWith("dhamaka:")) return; + + (async () => { + try { + switch (msg.type) { + case "dhamaka:ping": { + sendResponse({ + type: "dhamaka:response", + pong: true, + version: chrome.runtime.getManifest().version, + tier: "extension", + }); + break; + } + case "dhamaka:get": { + let record = await idbGet(msg.id); + const cached = !!record; + if (!record) { + record = await downloadAndStore(msg.id, msg.manifestUrl); + } + // We can't transfer ArrayBuffers over chrome.runtime.sendMessage. + // Instead we pass the record as a plain object — Chrome structured- + // clones it, which is still zero-alloc from JS's perspective. + sendResponse({ + type: "dhamaka:response", + cached, + id: msg.id, + entry: record.entry, + fetchedAt: record.fetchedAt, + artifacts: record.artifacts, + }); + break; + } + case "dhamaka:list": { + const rows = await idbList(); + sendResponse({ + type: "dhamaka:response", + 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, + ), + })), + }); + break; + } + case "dhamaka:delete": { + await idbDelete(msg.id); + sendResponse({ type: "dhamaka:response", deleted: msg.id }); + break; + } + default: + sendResponse({ + type: "dhamaka:error", + error: `unknown message type: ${msg.type}`, + }); + } + } catch (err) { + sendResponse({ + type: "dhamaka:error", + error: String(err?.message || err), + }); + } + })(); + + // Returning true keeps the message channel open for the async sendResponse. + return true; +}); diff --git a/packages/extension/content.js b/packages/extension/content.js new file mode 100644 index 0000000..9e0e119 --- /dev/null +++ b/packages/extension/content.js @@ -0,0 +1,52 @@ +// Dhamaka extension content script. +// +// Runs at document_start on every page and acts as a bridge between: +// +// page JS ←postMessage→ content script ←chrome.runtime→ background +// +// It also plants a tiny marker on window so the Dhamaka SDK can detect that +// the extension is installed and prefer it over the iframe hub. + +const MARKER = "__dhamaka_extension__"; + +// Announce presence to the page. The SDK's HubClient checks for this on +// startup and, if it finds it, routes all hub messages through here instead +// of through an iframe. +const script = document.createElement("script"); +script.textContent = ` + window.${MARKER} = { + version: ${JSON.stringify(chrome.runtime.getManifest().version)}, + tier: "extension" + }; + window.dispatchEvent(new CustomEvent("dhamaka:extension-ready")); +`; +(document.documentElement || document.head || document.body).appendChild(script); +script.remove(); + +// Listen for requests from the page and forward them to the background. +window.addEventListener("message", (event) => { + if (event.source !== window) return; + const msg = event.data; + if (!msg || typeof msg !== "object") return; + if (typeof msg.type !== "string" || !msg.type.startsWith("dhamaka:")) return; + if (msg.__dhamakaFromExtension) return; // our own echoes + + chrome.runtime.sendMessage(msg, (response) => { + if (chrome.runtime.lastError) { + window.postMessage( + { + type: "dhamaka:error", + requestId: msg.requestId, + error: chrome.runtime.lastError.message, + __dhamakaFromExtension: true, + }, + "*", + ); + return; + } + window.postMessage( + { ...response, requestId: msg.requestId, __dhamakaFromExtension: true }, + "*", + ); + }); +}); diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json new file mode 100644 index 0000000..e6a34c5 --- /dev/null +++ b/packages/extension/manifest.json @@ -0,0 +1,42 @@ +{ + "manifest_version": 3, + "name": "Dhamaka", + "short_name": "Dhamaka", + "version": "0.1.0", + "description": "A browser-native LLM cache. Downloads Dhamaka models once per machine and serves them to every site that uses the Dhamaka SDK.", + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "permissions": [ + "storage", + "unlimitedStorage" + ], + + "host_permissions": [ + "https://hub.dhamaka.dev/*", + "https://*.dhamaka.dev/*" + ], + + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": false, + "world": "ISOLATED" + } + ], + + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + + "action": { + "default_title": "Dhamaka", + "default_popup": "options.html" + } +} diff --git a/packages/extension/options.html b/packages/extension/options.html new file mode 100644 index 0000000..bb806b5 --- /dev/null +++ b/packages/extension/options.html @@ -0,0 +1,50 @@ + + + + + Dhamaka — cached models + + + +
+

dhamaka · cached models

+

Models stored by the Dhamaka extension. Shared across every site you visit.

+ +
+ + + diff --git a/packages/extension/options.js b/packages/extension/options.js new file mode 100644 index 0000000..2305435 --- /dev/null +++ b/packages/extension/options.js @@ -0,0 +1,56 @@ +// Simple options page that lists cached models and lets the user evict them. + +function fmtBytes(n) { + if (!n && n !== 0) return "—"; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +function fmtDate(ms) { + if (!ms) return "—"; + try { + return new Date(ms).toLocaleDateString(); + } catch { + return "—"; + } +} + +async function refresh() { + const list = document.getElementById("list"); + list.innerHTML = '
  • loading…
  • '; + chrome.runtime.sendMessage({ type: "dhamaka:list" }, (response) => { + if (chrome.runtime.lastError) { + list.innerHTML = `
  • error: ${chrome.runtime.lastError.message}
  • `; + return; + } + const rows = response?.list ?? []; + if (!rows.length) { + list.innerHTML = '
  • no models cached yet
  • '; + return; + } + list.innerHTML = ""; + for (const row of rows) { + const li = document.createElement("li"); + const left = document.createElement("div"); + const idEl = document.createElement("div"); + idEl.className = "id"; + idEl.textContent = row.id; + const metaEl = document.createElement("div"); + metaEl.className = "meta"; + metaEl.textContent = `${fmtBytes(row.size)} · cached ${fmtDate(row.fetchedAt)}`; + left.append(idEl, metaEl); + + const btn = document.createElement("button"); + btn.textContent = "evict"; + btn.addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "dhamaka:delete", id: row.id }, refresh); + }); + li.append(left, btn); + list.appendChild(li); + } + }); +} + +document.addEventListener("DOMContentLoaded", refresh); diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 0000000..f1d0e3b --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,16 @@ +{ + "name": "@dhamaka/extension", + "version": "0.1.0", + "description": "The Dhamaka browser extension. Stores models once per machine and serves them to every Dhamaka-powered site via a content script bridge — sidestepping storage partitioning entirely.", + "type": "module", + "private": true, + "files": [ + "manifest.json", + "background.js", + "content.js", + "options.html", + "options.js", + "icons" + ], + "license": "MIT" +} diff --git a/packages/hub/public/hub.js b/packages/hub/public/hub.js index fb07d40..89ed72d 100644 --- a/packages/hub/public/hub.js +++ b/packages/hub/public/hub.js @@ -245,9 +245,73 @@ async function handlePing({ requestId }, reply) { pong: true, version: "0.1.0", origin: location.origin, + tier: await currentStorageTier(), }); } +// ─── Storage Access API ──────────────────────────────────────────────────── +// +// Modern browsers partition third-party iframe storage by top-level site. A +// hub iframe embedded on site-A gets a different IndexedDB than the same +// hub iframe embedded on site-B, which kills the cross-site sharing trick. +// +// The Storage Access API lets the iframe ask for unpartitioned storage after +// the user has interacted with the hub origin at least once as a first party. +// This function tries to detect + request it, and reports which tier we got. +// +// Tiers: +// "shared" → cross-site unpartitioned storage (the dream) +// "storage-access"→ granted via Storage Access API +// "partitioned" → per-top-site IndexedDB (still persistent, not shared) +// "unknown" → couldn't determine + +async function currentStorageTier() { + try { + if (typeof document === "undefined") return "unknown"; + + // If we're not actually embedded in anything, storage is first-party. + if (window.top === window.self) return "shared"; + + if (typeof document.hasStorageAccess === "function") { + const has = await document.hasStorageAccess(); + if (has) return "storage-access"; + } + return "partitioned"; + } catch { + return "unknown"; + } +} + +async function handleRequestStorageAccess({ requestId }, reply) { + if (typeof document === "undefined" || typeof document.requestStorageAccess !== "function") { + reply({ + type: "dhamaka:response", + requestId, + granted: false, + tier: "partitioned", + reason: "Storage Access API not supported", + }); + return; + } + try { + await document.requestStorageAccess(); + reply({ + type: "dhamaka:response", + requestId, + granted: true, + tier: await currentStorageTier(), + }); + } catch (err) { + reply({ + type: "dhamaka:response", + requestId, + granted: false, + tier: "partitioned", + reason: String(err?.message || err), + }); + } +} + // ─── Message router ──────────────────────────────────────────────────────── function makeReply(source, origin) { @@ -287,6 +351,9 @@ window.addEventListener("message", async (event) => { case "dhamaka:delete": await handleDelete(msg, reply); break; + case "dhamaka:request-storage-access": + await handleRequestStorageAccess(msg, reply); + break; default: reply({ type: "dhamaka:error", @@ -304,7 +371,15 @@ window.addEventListener("message", async (event) => { }); // Announce ready so the parent can resolve its load promise deterministically. -window.parent?.postMessage( - { type: "dhamaka:ready", version: "0.1.0", origin: location.origin }, - "*", -); +(async () => { + const tier = await currentStorageTier(); + window.parent?.postMessage( + { + type: "dhamaka:ready", + version: "0.1.0", + origin: location.origin, + tier, + }, + "*", + ); +})(); diff --git a/packages/hub/public/manifest.schema.json b/packages/hub/public/manifest.schema.json new file mode 100644 index 0000000..90117f5 --- /dev/null +++ b/packages/hub/public/manifest.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://hub.dhamaka.dev/manifest.schema.json", + "title": "Dhamaka Manifest", + "description": "A manifest of models hosted by a Dhamaka hub.", + "type": "object", + "required": ["version", "models"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string", "format": "uri" }, + "version": { "type": "integer", "const": 1 }, + "updated": { "type": "string", "format": "date" }, + "default": { "type": "string", "description": "ID of the model to load when none is specified." }, + "models": { + "type": "array", + "items": { "$ref": "#/definitions/Model" }, + "minItems": 1 + } + }, + "definitions": { + "Model": { + "type": "object", + "required": ["id", "name", "artifacts"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "base": { "type": "string", "description": "Upstream model on HF Hub." }, + "family": { "type": "string", "description": "Architecture family (e.g. smollm2, minilm)." }, + "params": { "type": "string", "description": "Human-readable parameter count (e.g. 360M)." }, + "contextLength": { "type": "integer", "minimum": 1 }, + "quantization": { "type": "string", "description": "Quantization scheme (e.g. Q4_K_M, Q8_0)." }, + "size": { "type": "integer", "minimum": 0, "description": "Total artifact size in bytes." }, + "license": { "type": "string" }, + "capabilities": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "status": { + "type": "string", + "enum": ["shipping", "planned", "deprecated"] + }, + "default": { "type": "boolean" }, + "artifacts": { + "type": "object", + "required": ["weights"], + "additionalProperties": { "$ref": "#/definitions/Artifact" }, + "properties": { + "weights": { "$ref": "#/definitions/Artifact" }, + "tokenizer": { "$ref": "#/definitions/Artifact" }, + "config": { "$ref": "#/definitions/Artifact" } + } + } + } + }, + "Artifact": { + "type": "object", + "required": ["url", "sha256"], + "additionalProperties": false, + "properties": { + "url": { "type": "string", "format": "uri-reference" }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$", + "description": "Content-addressed hash. All zeroes means unverified (development only)." + }, + "size": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/packages/hub/test/manifest.test.js b/packages/hub/test/manifest.test.js new file mode 100644 index 0000000..9d3ef21 --- /dev/null +++ b/packages/hub/test/manifest.test.js @@ -0,0 +1,69 @@ +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"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", "..", ".."); + +// These tests don't pull in a full JSON Schema validator to stay dependency- +// free. They exercise the structural invariants we actually rely on at runtime. + +async function loadJson(relPath) { + const buf = await readFile(join(ROOT, relPath)); + return JSON.parse(buf.toString("utf8")); +} + +const HEX64 = /^[0-9a-fA-F]{64}$/; +const ID = /^[a-z0-9][a-z0-9-]*$/; + +test("canonical manifest parses", async () => { + const manifest = await loadJson("models/manifest.json"); + assert.equal(manifest.version, 1); + assert.ok(Array.isArray(manifest.models)); + assert.ok(manifest.models.length > 0); +}); + +test("every model has a valid id and required fields", async () => { + const manifest = await loadJson("models/manifest.json"); + for (const model of manifest.models) { + assert.match(model.id, ID, `bad id: ${model.id}`); + assert.ok(model.name, `${model.id}: missing name`); + assert.ok(model.artifacts, `${model.id}: missing artifacts`); + assert.ok(model.artifacts.weights, `${model.id}: missing weights artifact`); + } +}); + +test("every artifact has url + sha256 in the right format", async () => { + const manifest = await loadJson("models/manifest.json"); + for (const model of manifest.models) { + for (const [name, artifact] of Object.entries(model.artifacts)) { + assert.ok(artifact.url, `${model.id}/${name}: missing url`); + assert.ok(artifact.sha256, `${model.id}/${name}: missing sha256`); + assert.match( + artifact.sha256, + HEX64, + `${model.id}/${name}: sha256 not 64 hex chars`, + ); + } + } +}); + +test("default model exists in the models list", async () => { + const manifest = await loadJson("models/manifest.json"); + const def = manifest.default; + assert.ok(def, "manifest.default is unset"); + const found = manifest.models.find((m) => m.id === def); + assert.ok(found, `manifest.default=${def} not found in models`); +}); + +test("hub's served manifest mirrors the canonical model shape", async () => { + const hub = await loadJson("packages/hub/public/manifest.json"); + assert.equal(hub.version, 1); + assert.ok(hub.models.length > 0); + for (const model of hub.models) { + assert.match(model.id, ID); + assert.ok(model.artifacts?.weights); + } +}); diff --git a/packages/playground/public/app.js b/packages/playground/public/app.js index d05da29..1e7f23e 100644 --- a/packages/playground/public/app.js +++ b/packages/playground/public/app.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/index.html b/packages/playground/public/index.html index 68b5173..e966d08 100644 --- a/packages/playground/public/index.html +++ b/packages/playground/public/index.html @@ -107,6 +107,8 @@

    Storage

    disabled > + + 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/runtime/test/factory.test.js b/packages/runtime/test/factory.test.js new file mode 100644 index 0000000..ec75bcf --- /dev/null +++ b/packages/runtime/test/factory.test.js @@ -0,0 +1,37 @@ +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() refuses without a wasmUrl", async () => { + const engine = new WasmEngine(); + await assert.rejects( + () => engine.load({ entry: {}, artifacts: { weights: new Uint8Array(), config: new Uint8Array() } }), + /no WASM module configured/, + ); +}); 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/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/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/test/chat.test.js b/packages/sdk/test/chat.test.js new file mode 100644 index 0000000..5344c4e --- /dev/null +++ b/packages/sdk/test/chat.test.js @@ -0,0 +1,65 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Chat } from "../src/chat.js"; + +// Minimal fake Dhamaka instance for testing Chat in isolation. +function fakeLLM(reply = "mock reply") { + return { + async complete(_prompt) { + return reply; + }, + async *stream(_prompt) { + for (const piece of reply.split(" ")) yield piece + " "; + }, + }; +} + +test("Chat: send() appends user and assistant messages", async () => { + const chat = new Chat(fakeLLM("hi there")); + const out = await chat.send("hello"); + assert.equal(out, "hi there"); + assert.deepEqual(chat.messages, [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi there" }, + ]); +}); + +test("Chat: system prompt is added when provided", async () => { + const chat = new Chat(fakeLLM(), { system: "be nice" }); + assert.equal(chat.messages[0].role, "system"); + assert.equal(chat.messages[0].content, "be nice"); +}); + +test("Chat: stream() collects the full reply into the transcript", async () => { + const chat = new Chat(fakeLLM("one two three")); + const got = []; + for await (const token of chat.stream("go")) got.push(token); + assert.ok(got.join("").includes("one")); + const last = chat.messages[chat.messages.length - 1]; + assert.equal(last.role, "assistant"); + assert.ok(last.content.includes("three")); +}); + +test("Chat: history accumulates across turns", async () => { + const chat = new Chat(fakeLLM("ok")); + await chat.send("first"); + await chat.send("second"); + assert.equal(chat.messages.length, 4); + assert.equal(chat.messages[0].content, "first"); + assert.equal(chat.messages[2].content, "second"); +}); + +test("Chat: reset() keeps system prompt by default", async () => { + const chat = new Chat(fakeLLM(), { system: "be nice" }); + await chat.send("hi"); + chat.reset(); + assert.equal(chat.messages.length, 1); + assert.equal(chat.messages[0].role, "system"); +}); + +test("Chat: reset({ keepSystem: false }) clears everything", async () => { + const chat = new Chat(fakeLLM(), { system: "be nice" }); + await chat.send("hi"); + chat.reset({ keepSystem: false }); + assert.equal(chat.messages.length, 0); +}); diff --git a/packages/sdk/test/hub-client.test.js b/packages/sdk/test/hub-client.test.js new file mode 100644 index 0000000..412d6cd --- /dev/null +++ b/packages/sdk/test/hub-client.test.js @@ -0,0 +1,113 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { HubClient } from "../src/hub-client.js"; + +// In Node, HubClient skips the iframe path entirely and uses FallbackStore. + +test("HubClient: mode() is site-local in Node", async () => { + const c = new HubClient({ hubUrl: "http://example.test/" }); + assert.equal(await c.mode(), "site-local"); +}); + +test("HubClient: ping() works via fallback", async () => { + const c = new HubClient({ hubUrl: "http://example.test/" }); + const res = await c.ping(); + assert.equal(res.pong, true); + assert.equal(res.fallback, true); +}); + +test("HubClient: get() fetches manifest and artifacts via the configured fetch", async () => { + const c = new HubClient({ hubUrl: "http://example.test/" }); + + // Mock global fetch used by FallbackStore. + const manifest = { + models: [ + { + id: "test-model", + artifacts: { + weights: { url: "http://example.test/weights.bin" }, + config: { url: "http://example.test/config.json" }, + }, + }, + ], + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (url.endsWith("manifest.json")) { + return new Response(JSON.stringify(manifest), { status: 200 }); + } + if (url.endsWith("weights.bin")) { + return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200 }); + } + if (url.endsWith("config.json")) { + return new Response(new Uint8Array([5, 6]), { status: 200 }); + } + return new Response("404", { status: 404 }); + }; + + try { + const got = await c.get("test-model", { + manifestUrl: "http://example.test/manifest.json", + }); + assert.equal(got.cached, false); + assert.ok(got.artifacts?.weights instanceof Uint8Array); + assert.equal(got.artifacts.weights.byteLength, 4); + assert.equal(got.artifacts.config.byteLength, 2); + + // A second call should now be a cache hit. + const again = await c.get("test-model", { + manifestUrl: "http://example.test/manifest.json", + }); + assert.equal(again.cached, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("HubClient: list() and delete() work via fallback", async () => { + const c = new HubClient({ hubUrl: "http://example.test/" }); + + const manifest = { + models: [ + { + id: "test-model", + artifacts: { weights: { url: "http://example.test/w.bin" } }, + }, + ], + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => + url.endsWith("manifest.json") + ? new Response(JSON.stringify(manifest), { status: 200 }) + : new Response(new Uint8Array([9, 9, 9]), { status: 200 }); + + try { + await c.get("test-model", { manifestUrl: "http://example.test/manifest.json" }); + + const listed = await c.list(); + assert.ok(listed.list.length >= 1); + + const deleted = await c.delete("test-model"); + assert.equal(deleted.deleted, "test-model"); + + const afterDelete = await c.list(); + assert.equal(afterDelete.list.find((r) => r.id === "test-model"), undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("HubClient: get() throws a clean error for unknown model", async () => { + const c = new HubClient({ hubUrl: "http://example.test/" }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ models: [] }), { status: 200 }); + try { + await assert.rejects( + c.get("no-such-model", { manifestUrl: "http://example.test/manifest.json" }), + /unknown model/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/sdk/test/openai-shim.test.js b/packages/sdk/test/openai-shim.test.js new file mode 100644 index 0000000..e516f1a --- /dev/null +++ b/packages/sdk/test/openai-shim.test.js @@ -0,0 +1,79 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { installOpenAIShim } from "../src/openai-shim.js"; + +function fakeDhamaka({ reply = "hello from mock" } = {}) { + return { + modelId: "dhamaka-test", + async complete() { + return reply; + }, + async *stream() { + for (const piece of reply.split(" ")) yield piece + " "; + }, + }; +} + +test("openai shim: non-stream returns a well-formed ChatCompletion", async () => { + const originalFetch = globalThis.fetch; + try { + const llm = fakeDhamaka(); + installOpenAIShim(llm); + const res = await fetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + messages: [{ role: "user", content: "hi" }], + stream: false, + }), + }); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.object, "chat.completion"); + assert.equal(json.model, "dhamaka-test"); + assert.equal(json.choices[0].message.role, "assistant"); + assert.equal(json.choices[0].message.content, "hello from mock"); + assert.equal(json.choices[0].finish_reason, "stop"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("openai shim: stream returns SSE chunks ending with [DONE]", async () => { + const originalFetch = globalThis.fetch; + try { + const llm = fakeDhamaka({ reply: "one two three" }); + installOpenAIShim(llm); + const res = await fetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + messages: [{ role: "user", content: "hi" }], + stream: true, + }), + }); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /event-stream/); + const text = await res.text(); + assert.match(text, /data: \{/); + assert.match(text, /data: \[DONE\]/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("openai shim: passes through non-matching URLs to the original fetch", async () => { + const originalFetch = globalThis.fetch; + let called = false; + globalThis.fetch = async (_url) => { + called = true; + return new Response("passthrough", { status: 200 }); + }; + try { + const llm = fakeDhamaka(); + installOpenAIShim(llm); + const res = await fetch("https://example.test/other"); + assert.equal(called, true); + assert.equal(await res.text(), "passthrough"); + } finally { + globalThis.fetch = originalFetch; + } +}); From de73cf22d0d3623c21cc562c74204d5635caaef9 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:22:16 +0000 Subject: [PATCH 02/29] Build the real Rust inference runtime and wire it end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec said "a small transformer inference runtime compiled to WASM". Up to now that was a Rust-shaped hole in the tree filled with a JS MockEngine. This commit lands the actual Rust crate and makes the whole stack drive tokens through real compiled-to-WASM transformer math. crates/dhamaka-runtime/ (new, pure Rust, no deps): - tensor.rs matmul, RMSNorm (with eps), softmax (numerically stable, translation invariant), SwiGLU/SiLU, RoPE (pair-wise rotation preserving L2 norm), in-place add/mul - sampler.rs temperature + top-k + top-p + greedy in one pass, with a deterministic RNG so identical prompts yield identical output - rng.rs xorshift64* + FNV-1a seed hashing - transformer.rs minimal Llama-style block: RMSNorm → Q/K/V projections → RoPE → KV-cached self-attention → output projection → RMSNorm → SwiGLU FFN → residual. MAX_CTX=512. - model.rs tiny 32-dim / 2-layer / 64-vocab random-weights model with reproducible Box-Müller init - abi.rs #[no_mangle] extern "C" exports: dhamaka_version / _alloc / _free / _init / _destroy / _reset / _set_sampling / _feed_prompt / _next_token 27 native cargo tests cover every primitive, determinism, sampler truncation laws, and that position changes via RoPE + KV cache actually propagate to the output logits. packages/runtime/src/wasm-engine.js is now real: - Fetches dhamaka-runtime.wasm, instantiates it, verifies ABI_VERSION - Writes prompt bytes into WASM linear memory via dhamaka_alloc - Drives dhamaka_feed_prompt + dhamaka_next_token in a stream loop - Decodes UTF-8 on the way out, yields tokens, honors AbortSignal - Falls back cleanly when unreachable (keeps the test coverage honest) Factory now prefers WasmEngine in browsers and MockEngine in Node, so the existing JS tests still run in CI without a .wasm on disk, while any real browser gets real inference. 4 new Node integration tests stub fetch() to load the real .wasm off disk and exercise end-to-end generation, determinism, and abort. Infrastructure: - build.sh installs the wasm32 target on demand, compiles release, stages the .wasm into packages/hub/public/runtime/ - Dev server serves .wasm with application/wasm + CORS so the SDK on :5173 can cross-origin fetch the runtime from the hub on :5174 - CI now has two jobs: rust (cargo test + build + upload wasm artifact) and js (downloads the artifact and runs the JS suite + dev-server smoke test, matrix over Node 20 + 22) - .gitignore ignores crates/*/target/ but commits the built .wasm so fresh clones work without a Rust toolchain Test totals: - 27 Rust unit tests (cargo test), all green - 45 JS tests (node --test), all green, including 4 end-to-end WASM integration tests v0.1 is honest about what it is: the math is real, the weights are a 32-dim random tiny model, and output is stream-of-tokens not coherent English. SmolLM2-360M weights plug into the exact same dhamaka_init entry point when they arrive — the SDK doesn't move. --- .github/workflows/ci.yml | 37 ++- .gitignore | 6 + README.md | 57 +++-- crates/dhamaka-runtime/Cargo.toml | 18 ++ crates/dhamaka-runtime/README.md | 74 ++++++ crates/dhamaka-runtime/build.sh | 43 ++++ crates/dhamaka-runtime/src/abi.rs | 232 ++++++++++++++++++ crates/dhamaka-runtime/src/lib.rs | 47 ++++ crates/dhamaka-runtime/src/model.rs | 145 +++++++++++ crates/dhamaka-runtime/src/rng.rs | 78 ++++++ crates/dhamaka-runtime/src/sampler.rs | 151 ++++++++++++ crates/dhamaka-runtime/src/tensor.rs | 225 +++++++++++++++++ crates/dhamaka-runtime/src/transformer.rs | 232 ++++++++++++++++++ .../hub/public/runtime/dhamaka-runtime.wasm | Bin 0 -> 56392 bytes packages/playground/server.js | 4 + packages/runtime/src/factory.js | 14 +- packages/runtime/src/wasm-engine.js | 208 ++++++++++------ packages/runtime/test/factory.test.js | 22 +- packages/runtime/test/wasm-engine.test.js | 161 ++++++++++++ packages/sdk/src/index.js | 19 +- 20 files changed, 1666 insertions(+), 107 deletions(-) create mode 100644 crates/dhamaka-runtime/Cargo.toml create mode 100644 crates/dhamaka-runtime/README.md create mode 100755 crates/dhamaka-runtime/build.sh create mode 100644 crates/dhamaka-runtime/src/abi.rs create mode 100644 crates/dhamaka-runtime/src/lib.rs create mode 100644 crates/dhamaka-runtime/src/model.rs create mode 100644 crates/dhamaka-runtime/src/rng.rs create mode 100644 crates/dhamaka-runtime/src/sampler.rs create mode 100644 crates/dhamaka-runtime/src/tensor.rs create mode 100644 crates/dhamaka-runtime/src/transformer.rs create mode 100755 packages/hub/public/runtime/dhamaka-runtime.wasm create mode 100644 packages/runtime/test/wasm-engine.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f35c75..d585202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,35 @@ on: pull_request: jobs: - test: - name: test (node ${{ matrix.node }}) + 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: @@ -20,6 +46,12 @@ jobs: 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/*' \ @@ -37,6 +69,7 @@ jobs: "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 diff --git a/.gitignore b/.gitignore index 8738cb1..bbf7960 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ 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 diff --git a/README.md b/README.md index ce005d8..30ba7d5 100644 --- a/README.md +++ b/README.md @@ -81,24 +81,28 @@ One download. Every site after that is an instant cache hit. │ ┌────────────┐ ┌──────────────────┐ │ │ │ packages/ │ │ packages/runtime │ │ │ │ hub │ │ ┌────────────┐ │ │ - │ │ │ │ │ MockEngine │ │ dev/today │ + │ │ │ │ │ WasmEngine │ │ default │ │ │ iframe + │ │ ├────────────┤ │ │ - │ │ IndexedDB │ │ │ WasmEngine │ │ next up │ + │ │ IndexedDB │ │ │ MockEngine │ │ dev only │ │ │ + OPFS │ │ └─────┬──────┘ │ │ │ └────────────┘ │ │ │ │ │ │ ▼ │ │ - │ │ .wasm + SIMD │ │ - │ │ (WebGPU fast │ │ - │ │ path optional) │ │ - │ └──────────────────┘ │ + │ ┌──────────────────────────────────────┐ │ + │ │ crates/dhamaka-runtime (Rust) │ │ + │ │ matmul · RMSNorm · softmax │ │ + │ │ RoPE · KV cache · SwiGLU │ │ + │ │ temperature / top-k / top-p │ │ + │ │ → dhamaka-runtime.wasm (56 KB) │ │ + │ └──────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ``` | package | what it does | |-------------------------|---------------------------------------------------------------| +| [`dhamaka-runtime` (Rust)](crates/dhamaka-runtime) | the real inference engine — matmul, RMSNorm, softmax, RoPE, KV-cache, sampling — compiled to WebAssembly | | [`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/runtime`](packages/runtime) | the JS engine interface: `WasmEngine` (default) + `MockEngine` (dev) | +| [`@dhamaka/hub`](packages/hub) | the tiny static origin that hosts the cross-site model cache and the `.wasm` runtime | | [`@dhamaka/extension`](packages/extension) | Manifest V3 browser extension — shared cache across every site on the machine | | [`@dhamaka/playground`](packages/playground) | a live demo + a zero-dep dev server that runs the whole stack | @@ -153,6 +157,11 @@ Each variant is its own content-addressed artifact. Once a user downloads any on ```bash git clone https://github.com/protosphinx/dhamaka cd dhamaka + +# one-time: compile the Rust runtime to WebAssembly +crates/dhamaka-runtime/build.sh + +# run the dev stack npm run dev ``` @@ -163,7 +172,9 @@ npm run dev Dhamaka dev stack running. Ctrl+C to stop. ``` -Open **http://localhost:5173**, hit **load**, and you're chatting with a locally-served LLM. The playground hot-reads the SDK + runtime sources, so every edit shows up on refresh — no bundler, no build step. +Open **http://localhost:5173**, hit **load**, and you're chatting with a locally-served LLM whose every token comes out of real Rust-compiled-to-WASM transformer math. The playground hot-reads the SDK + runtime sources, so every JS edit shows up on refresh. Re-run `build.sh` to pick up Rust edits. + +> Don't have Rust installed? The compiled `.wasm` is checked in under `packages/hub/public/runtime/` so `npm run dev` works on a fresh clone too. Install Rust only if you want to modify the inference engine itself. --- @@ -239,6 +250,16 @@ Modern browsers increasingly **partition third-party storage** by the top-level ## ✦ what's real today ``` + [x] Rust inference runtime compiled to a 56 KB WebAssembly module + (matmul, RMSNorm, softmax, rotary, KV-cached self-attention, + SwiGLU/SiLU, top-k + top-p + temperature sampling) + [x] 27 native cargo tests covering every primitive + [x] C ABI (dhamaka_alloc/free/init/feed_prompt/next_token/…) exposed + to WebAssembly as #[no_mangle] extern "C" exports + [x] JS WasmEngine that loads the compiled .wasm and drives the ABI + end-to-end in both Node and browsers + [x] 4 Node-side integration tests that instantiate the real .wasm and + stream tokens through the Rust forward pass [x] hub ↔ sdk postMessage bridge (get / list / delete / progress) [x] IndexedDB-backed hub storage with SHA-256 integrity checks [x] zero-copy ArrayBuffer transfer from hub → consumer @@ -252,17 +273,19 @@ Modern browsers increasingly **partition third-party storage** by the top-level [x] manifest.schema.json (JSON Schema draft-07) for tooling [x] playground UI with progress bars, telemetry, cache-hit badge, stateful chat, abort/stop button, and reset-history - [x] zero-dependency dev server that serves hub + playground on two ports - [x] 40 tests covering runtime, SDK, hub, and OpenAI shim - [x] GitHub Actions CI running tests on Node 20 + 22 - - [ ] the actual WASM transformer runtime (ABI sketched, loader ready) - [ ] SmolLM2-360M Q4 weights hosted on hub.dhamaka.dev + [x] zero-dependency dev server that serves hub + playground + .wasm + on two ports with correct MIME + CORS + [x] 45 JS tests + 27 Rust tests, all green + [x] GitHub Actions CI that builds the Rust crate, uploads the .wasm + artifact, and runs the JS test suite against it on Node 20 + 22 + + [ ] Real SmolLM2-360M Q4 weights hosted on hub.dhamaka.dev + [ ] SIMD128 build of the runtime [ ] WebGPU fast path - [ ] the other registered models (code / sql / json / summarize / embed) + [ ] The other registered models (code / sql / json / summarize / embed) ``` -The entire developer-facing surface runs today against a `MockEngine` that streams canned responses at ~45 tok/s. When the WASM module lands, `createEngine` will prefer `WasmEngine` automatically — no SDK changes required. +**v0.1 honesty note:** the Rust runtime runs real transformer math — real matmul, real attention, real sampling, all inside WebAssembly — but the weights it loads for v0.1 are a tiny random model (32-dim hidden, 2 layers, 64-entry vocab). Output is stream-of-tokens, not coherent English. When the SmolLM2-360M Q4 artifacts drop, they flow through the exact same `dhamaka_init` entry point and the SDK doesn't move. --- diff --git a/crates/dhamaka-runtime/Cargo.toml b/crates/dhamaka-runtime/Cargo.toml new file mode 100644 index 0000000..fdec2cf --- /dev/null +++ b/crates/dhamaka-runtime/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "dhamaka-runtime" +version = "0.1.0" +edition = "2021" +description = "Dhamaka inference runtime. Compiles to WebAssembly for in-browser LLM inference." +license = "MIT" +repository = "https://github.com/protosphinx/dhamaka" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" diff --git a/crates/dhamaka-runtime/README.md b/crates/dhamaka-runtime/README.md new file mode 100644 index 0000000..08131e0 --- /dev/null +++ b/crates/dhamaka-runtime/README.md @@ -0,0 +1,74 @@ +# dhamaka-runtime + +The Dhamaka inference runtime, written in Rust, compiled to WebAssembly. + +This is the hot path. Everything in here — matmul, RMSNorm, softmax, rotary embeddings, SwiGLU, KV-cached self-attention, temperature/top-k/top-p sampling — runs inside a WASM module instantiated by the Dhamaka SDK in any modern browser tab. + +## Why Rust + +Transformer inference is a lot of f32 math repeated once per generated token. JavaScript can do it, Rust-compiled-to-WASM runs it at roughly native speed. That speed is the whole point of Dhamaka. The tradeoff is that you need a Rust toolchain to build the `.wasm` (or use the prebuilt one checked in under `packages/hub/public/runtime/`). + +## Build + +```sh +./build.sh # cargo build --release --target wasm32-unknown-unknown +./build.sh --check # also run the native test suite +``` + +The script installs `wasm32-unknown-unknown` on demand, compiles the crate at `opt-level = 3` with fat LTO, and stages the resulting `.wasm` at `packages/hub/public/runtime/dhamaka-runtime.wasm` where the dev server and the hub pick it up. + +## Tests + +```sh +cargo test +``` + +27 unit tests cover every primitive: + +- RNG determinism + value ranges (`rng.rs`) +- matmul, RMSNorm, softmax (numerical stability, translation invariance), SwiGLU/SiLU, in-place add/mul, rotary norm preservation (`tensor.rs`) +- greedy, top-k, top-p, temperature, RNG determinism for the sampler (`sampler.rs`) +- forward pass produces finite logits, is deterministic, and position-sensitive via RoPE + KV cache (`transformer.rs`) +- weight initialization is reproducible and the tokenize/detokenize round trip is safe (`model.rs`) + +## Module map + +``` +src/ +├── lib.rs crate entry, ABI version +├── abi.rs #[no_mangle] extern "C" surface +├── rng.rs xorshift64* + FNV-1a seed hashing +├── tensor.rs matmul, rmsnorm, softmax, silu, rope, add/mul +├── sampler.rs temperature + top-k + top-p + greedy +├── transformer.rs small transformer block + KV cache + forward() +└── model.rs random-weights model + prompt tokenizer + vocab +``` + +## ABI + +JavaScript talks to this crate over a tiny C ABI. The full list is in `src/abi.rs`: + +```text +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_reset(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) +``` + +JS writes prompt bytes into WASM linear memory via `dhamaka_alloc`, hands the pointer to `dhamaka_feed_prompt`, then loops on `dhamaka_next_token` to stream UTF-8 bytes back out. + +The SDK's `WasmEngine` (`packages/runtime/src/wasm-engine.js`) is the reference client and runs this ABI end-to-end in both Node (via `WebAssembly.instantiate`) and the browser (via `WebAssembly.instantiateStreaming`). + +## v0.1 caveats + +- The v0.1 model is a **tiny random-weights transformer**: 32-dim hidden, 2 layers, 1 head, 64-entry vocab. Real math, not real English. It exists to prove the stack works and to give us something that compiles to a 56 KB `.wasm` anyone can download and run. +- Real weight loading — quantized SmolLM2-360M tensors from the hub — lands when we ship the artifacts. +- No SIMD yet. `-C target-feature=+simd128` is a one-line build change once we have a baseline benchmark to measure against. +- No WebGPU fast path yet. + +None of these caveats change the ABI, so the SDK and playground don't need to move when the real model arrives. diff --git a/crates/dhamaka-runtime/build.sh b/crates/dhamaka-runtime/build.sh new file mode 100755 index 0000000..3fbaded --- /dev/null +++ b/crates/dhamaka-runtime/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the Dhamaka runtime crate to WebAssembly and stage the resulting +# .wasm into packages/hub/public/runtime/ so the dev server picks it up. +# +# Usage: ./build.sh [--check] + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +TARGET="wasm32-unknown-unknown" +STAGE="$ROOT/packages/hub/public/runtime/dhamaka-runtime.wasm" + +if ! command -v cargo >/dev/null; then + echo "error: cargo not found. Install Rust via https://rustup.rs" >&2 + exit 1 +fi + +if ! rustup target list --installed 2>/dev/null | grep -q "^$TARGET$"; then + echo "installing rust target $TARGET…" + rustup target add "$TARGET" +fi + +echo "› cargo build --release --target $TARGET" +cargo build --release --target "$TARGET" --manifest-path "$HERE/Cargo.toml" + +SRC="$HERE/target/$TARGET/release/dhamaka_runtime.wasm" +if [ ! -f "$SRC" ]; then + echo "error: expected wasm at $SRC" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$STAGE")" +cp "$SRC" "$STAGE" +SIZE=$(stat -c %s "$STAGE" 2>/dev/null || stat -f %z "$STAGE") +echo "› staged $STAGE ($(($SIZE / 1024)) KB)" + +if [ "${1:-}" = "--check" ]; then + echo "› cargo test" + cargo test --manifest-path "$HERE/Cargo.toml" +fi + +echo "✓ done" diff --git a/crates/dhamaka-runtime/src/abi.rs b/crates/dhamaka-runtime/src/abi.rs new file mode 100644 index 0000000..db90e8f --- /dev/null +++ b/crates/dhamaka-runtime/src/abi.rs @@ -0,0 +1,232 @@ +//! The C ABI Dhamaka exports to WebAssembly. +//! +//! JavaScript calls these functions directly by name via +//! `instance.exports.dhamaka_*`. All data crosses the JS/WASM boundary as +//! raw pointers into WASM linear memory, which JS writes and reads through +//! `Uint8Array(instance.exports.memory.buffer)`. +//! +//! Ownership rules: +//! +//! - `dhamaka_alloc(len)` gives JS a pointer it owns until it passes the +//! buffer back to a consumer function or calls `dhamaka_free(ptr, len)`. +//! - `dhamaka_init` returns a `*mut Context`. That pointer is opaque to JS +//! and is passed back into every subsequent call. JS must call +//! `dhamaka_destroy` when done. +//! - Strings are UTF-8 byte slices with an explicit length. No NUL sentinels. + +use crate::model::{detokenize, random_model, tokenize_prompt}; +use crate::rng::{fnv1a64, Xorshift64}; +use crate::sampler::{sample, SampleOptions}; +use crate::transformer::{forward, ModelWeights, Scratch}; +use crate::ABI_VERSION; + +/// Everything a single inference session owns. +pub struct Context { + model: ModelWeights, + scratch: Scratch, + rng: Xorshift64, + tokens: Vec, // full token history (prompt + generated) + pos: usize, // position counter for RoPE + opts: SampleOptions, + max_tokens: usize, + emitted: usize, + eos: bool, +} + +impl Context { + fn new(seed: u64) -> Self { + Self { + model: random_model(seed), + scratch: Scratch::new(), + rng: Xorshift64::new(seed ^ 0xA5A5_A5A5_A5A5_A5A5), + tokens: Vec::new(), + pos: 0, + opts: SampleOptions::default(), + max_tokens: 256, + emitted: 0, + eos: false, + } + } +} + +// ─── Memory management ───────────────────────────────────────────────────── + +/// Allocate `len` bytes of WASM linear memory. The returned pointer is +/// aligned the same way `Vec` allocates. +#[no_mangle] +pub extern "C" fn dhamaka_alloc(len: usize) -> *mut u8 { + let mut buf = Vec::::with_capacity(len); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +/// Free a buffer previously returned by `dhamaka_alloc`. `len` must match +/// the original allocation length. +#[no_mangle] +pub extern "C" fn dhamaka_free(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + unsafe { + let _ = Vec::from_raw_parts(ptr, 0, len); + } +} + +// ─── Lifecycle ───────────────────────────────────────────────────────────── + +/// Return the ABI version this runtime speaks. JS uses this to refuse to +/// load mismatched builds. +#[no_mangle] +pub extern "C" fn dhamaka_version() -> u32 { + ABI_VERSION +} + +/// Build a fresh inference context. +/// +/// For v0.1, `weights_ptr`/`weights_len` are ignored and the context uses a +/// deterministic random model seeded from the config bytes (or a fixed seed +/// if no config is provided). Real weight loading lands alongside the +/// quantized SmolLM2 artifacts. +#[no_mangle] +pub extern "C" fn dhamaka_init( + _weights_ptr: *const u8, + _weights_len: usize, + config_ptr: *const u8, + config_len: usize, +) -> *mut Context { + let seed = if !config_ptr.is_null() && config_len > 0 { + let bytes = unsafe { std::slice::from_raw_parts(config_ptr, config_len) }; + fnv1a64(bytes) + } else { + DEFAULT_SEED + }; + let ctx = Box::new(Context::new(seed)); + Box::into_raw(ctx) +} + +/// Destroy an inference context previously returned by `dhamaka_init`. +#[no_mangle] +pub extern "C" fn dhamaka_destroy(ctx: *mut Context) { + if ctx.is_null() { + return; + } + unsafe { + drop(Box::from_raw(ctx)); + } +} + +/// Reset an inference context's token history and KV cache without +/// destroying its model weights. +#[no_mangle] +pub extern "C" fn dhamaka_reset(ctx: *mut Context) { + if ctx.is_null() { + return; + } + let ctx = unsafe { &mut *ctx }; + ctx.tokens.clear(); + ctx.pos = 0; + ctx.emitted = 0; + ctx.eos = false; + ctx.scratch.clear_cache(); +} + +// ─── Configuration ───────────────────────────────────────────────────────── + +/// Configure sampling parameters. `temperature` ≤ 0 means greedy. +#[no_mangle] +pub extern "C" fn dhamaka_set_sampling( + ctx: *mut Context, + temperature: f32, + top_k: u32, + top_p: f32, + max_tokens: u32, +) { + if ctx.is_null() { + return; + } + let ctx = unsafe { &mut *ctx }; + ctx.opts = SampleOptions { + temperature, + top_k: top_k.max(1) as usize, + top_p: top_p.clamp(0.0, 1.0), + }; + ctx.max_tokens = max_tokens.max(1) as usize; +} + +// ─── Generation ──────────────────────────────────────────────────────────── + +/// Feed a prompt (UTF-8 bytes) into the context. Runs one forward pass per +/// prompt token to prime the model state. +#[no_mangle] +pub extern "C" fn dhamaka_feed_prompt( + ctx: *mut Context, + prompt_ptr: *const u8, + prompt_len: usize, +) { + if ctx.is_null() { + return; + } + let ctx = unsafe { &mut *ctx }; + ctx.eos = false; + ctx.emitted = 0; + + let bytes = if prompt_ptr.is_null() || prompt_len == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(prompt_ptr, prompt_len) } + }; + + // Seed the RNG from the prompt so each unique prompt has reproducible + // sampling while different prompts feel different. + ctx.rng = Xorshift64::new(fnv1a64(bytes).wrapping_mul(0x9E37_79B9_7F4A_7C15)); + + let prompt = std::str::from_utf8(bytes).unwrap_or(""); + let tokens = tokenize_prompt(prompt); + for &t in &tokens { + forward(&ctx.model, t, ctx.pos, &mut ctx.scratch); + ctx.pos += 1; + ctx.tokens.push(t); + } +} + +/// Generate the next token and write its UTF-8 bytes into `out_ptr`. Returns +/// the number of bytes written, or `-1` when the stream is done (either EOS +/// or `max_tokens` has been hit). +#[no_mangle] +pub extern "C" fn dhamaka_next_token( + ctx: *mut Context, + out_ptr: *mut u8, + out_cap: usize, +) -> i32 { + if ctx.is_null() || out_ptr.is_null() || out_cap == 0 { + return -1; + } + let ctx = unsafe { &mut *ctx }; + if ctx.eos || ctx.emitted >= ctx.max_tokens { + return -1; + } + + // Use the most-recent forward pass's logits (written by either + // `dhamaka_feed_prompt` or the previous `dhamaka_next_token`) to sample + // the next token. + let mut logits = ctx.scratch.logits.clone(); + let next_id = sample(&mut logits, ctx.opts, &mut ctx.rng); + + // Feed the sampled token back through the model so next time's logits + // reflect it. + forward(&ctx.model, next_id, ctx.pos, &mut ctx.scratch); + ctx.pos += 1; + ctx.tokens.push(next_id); + ctx.emitted += 1; + + // Detokenize and copy out. + let piece = detokenize(next_id).as_bytes(); + let n = piece.len().min(out_cap); + let out = unsafe { std::slice::from_raw_parts_mut(out_ptr, n) }; + out.copy_from_slice(&piece[..n]); + n as i32 +} + +/// Default RNG seed used when `dhamaka_init` is called with no config bytes. +const DEFAULT_SEED: u64 = 0x0D4A_D4AD_4AD4_AD4A; diff --git a/crates/dhamaka-runtime/src/lib.rs b/crates/dhamaka-runtime/src/lib.rs new file mode 100644 index 0000000..81627c9 --- /dev/null +++ b/crates/dhamaka-runtime/src/lib.rs @@ -0,0 +1,47 @@ +//! # dhamaka-runtime +//! +//! The Dhamaka inference runtime, written in Rust and compiled to WebAssembly. +//! +//! ## Why Rust +//! +//! Transformer inference is a lot of hot f32 math — matmul, RMSNorm, softmax, +//! rotary embeddings, residual adds — repeated once per generated token. +//! JavaScript can do this, but Rust compiled to WebAssembly runs it at +//! roughly native speed, inside any modern browser tab, with zero runtime +//! dependencies. That's the entire point of Dhamaka. +//! +//! ## What's in here +//! +//! - [`tensor`] — matmul, RMSNorm, softmax, rotary, SiLU, residual +//! - [`sampler`] — temperature + top-k + top-p + greedy +//! - [`transformer`] — a minimal forward-pass kernel using the primitives +//! - [`model`] — a tiny tied-weights model that the ABI drives end-to-end +//! - [`rng`] — deterministic xorshift RNG, seeded from the prompt +//! - [`abi`] — the `#[no_mangle] extern "C"` surface exposed to WebAssembly +//! +//! ## ABI (see `abi.rs` for the full list) +//! +//! ```text +//! 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_feed_prompt(ctx, p, l) -> void +//! dhamaka_next_token(ctx, o, ol) -> i32 (bytes written, or -1 on EOS) +//! dhamaka_reset(ctx) -> void +//! ``` +//! +//! JS calls `dhamaka_alloc` to get a pointer into wasm linear memory, writes +//! the prompt bytes there, hands the pointer to `dhamaka_feed_prompt`, and +//! then loops on `dhamaka_next_token` to stream UTF-8 token bytes back. + +pub mod abi; +pub mod model; +pub mod rng; +pub mod sampler; +pub mod tensor; +pub mod transformer; + +/// The ABI version this build of the runtime speaks. +pub const ABI_VERSION: u32 = 1; diff --git a/crates/dhamaka-runtime/src/model.rs b/crates/dhamaka-runtime/src/model.rs new file mode 100644 index 0000000..e4c1a71 --- /dev/null +++ b/crates/dhamaka-runtime/src/model.rs @@ -0,0 +1,145 @@ +//! The tiny random-weights model used by v0.1 of the runtime. +//! +//! Real Dhamaka releases will load SmolLM2-360M-Instruct from a quantized +//! binary format. Until those weights are packaged, this module builds a +//! deterministic random model from a seed, which is enough to exercise the +//! full inference pipeline end-to-end: embedding lookup → N transformer +//! blocks → LM head → sampling → detokenization. +//! +//! Output from this model is not coherent English — it's whatever the random +//! weights say. But every step is real transformer math executed in WASM +//! compiled from Rust, which is the entire point of Dhamaka's runtime layer. + +use crate::rng::Xorshift64; +use crate::transformer::{LayerWeights, ModelWeights, FFN_HIDDEN, HIDDEN, N_LAYERS, VOCAB}; + +/// A tiny character-level vocabulary built from a restricted alphabet. The +/// model samples token ids in `0..VOCAB`, and the ABI converts each id back +/// into one or more bytes using this table when it streams output to JS. +/// +/// It is deliberately small (64 entries) so `VOCAB = 64` matches the +/// transformer's LM head. +pub const VOCAB_TABLE: [&str; 64] = [ + " the ", " a ", " of ", " to ", " and ", " in ", " that ", " it ", + " is ", " for ", " on ", " with ", " as ", " was ", " are ", " be ", + "Dhamaka ", "browser ", "WASM ", "Rust ", "model ", "tensor ", "token ", + "weights ", "inference ", "cache ", "matrix ", "softmax ", "attention ", + "transformer ", "fast ", "small ", "local ", "private ", "yours ", + "run ", "ship ", "tab ", "site ", "share ", "download ", "once ", + "forever ", "now ", "live ", ".", ",", "!", "?", "\n", + " ", "-", ":", ";", "'", "\"", "(", ")", "[", "]", + "→", "✦", "✓", "…", +]; + +fn random_vector(rng: &mut Xorshift64, len: usize, scale: f32) -> Vec { + let mut out = Vec::with_capacity(len); + for _ in 0..len { + // Box–Muller-lite: two uniforms → one normal sample. + let u1 = rng.next_f32().max(1e-7); + let u2 = rng.next_f32(); + let r = (-2.0 * u1.ln()).sqrt(); + let theta = 2.0 * std::f32::consts::PI * u2; + out.push(r * theta.cos() * scale); + } + out +} + +fn random_layer(rng: &mut Xorshift64) -> LayerWeights { + // Scale analogous to `1/sqrt(fan_in)` so activations stay near unit norm. + let s_hidden = 1.0 / (HIDDEN as f32).sqrt(); + let s_ffn_in = 1.0 / (HIDDEN as f32).sqrt(); + let s_ffn_out = 1.0 / (FFN_HIDDEN as f32).sqrt(); + LayerWeights { + attn_norm: random_vector(rng, HIDDEN, 0.1).into_iter().map(|v| 1.0 + v).collect(), + wq: random_vector(rng, HIDDEN * HIDDEN, s_hidden), + wk: random_vector(rng, HIDDEN * HIDDEN, s_hidden), + wv: random_vector(rng, HIDDEN * HIDDEN, s_hidden), + wo: random_vector(rng, HIDDEN * HIDDEN, s_hidden), + ffn_norm: random_vector(rng, HIDDEN, 0.1).into_iter().map(|v| 1.0 + v).collect(), + w_gate: random_vector(rng, HIDDEN * FFN_HIDDEN, s_ffn_in), + w_up: random_vector(rng, HIDDEN * FFN_HIDDEN, s_ffn_in), + w_down: random_vector(rng, FFN_HIDDEN * HIDDEN, s_ffn_out), + } +} + +/// Build a fresh random model from a seed. +pub fn random_model(seed: u64) -> ModelWeights { + let mut rng = Xorshift64::new(seed); + let s_embed = 1.0 / (HIDDEN as f32).sqrt(); + let token_embedding = random_vector(&mut rng, VOCAB * HIDDEN, s_embed); + let mut layers = Vec::with_capacity(N_LAYERS); + for _ in 0..N_LAYERS { + layers.push(random_layer(&mut rng)); + } + let final_norm: Vec = random_vector(&mut rng, HIDDEN, 0.1) + .into_iter() + .map(|v| 1.0 + v) + .collect(); + let lm_head = random_vector(&mut rng, HIDDEN * VOCAB, 1.0 / (HIDDEN as f32).sqrt()); + ModelWeights { + token_embedding, + layers, + final_norm, + lm_head, + } +} + +/// Naive prompt tokenizer. Maps each input byte to a token id in `0..VOCAB` +/// by hashing it, so we always produce a valid starting context even when +/// the prompt contains characters outside the vocab. The real runtime will +/// use the SmolLM2 BPE tokenizer. +pub fn tokenize_prompt(prompt: &str) -> Vec { + if prompt.is_empty() { + return vec![0]; + } + prompt + .bytes() + .map(|b| (b as usize) % VOCAB) + .collect() +} + +/// Look up a vocab entry for streaming back to JS. +pub fn detokenize(id: usize) -> &'static str { + VOCAB_TABLE[id % VOCAB] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_model_is_deterministic() { + let a = random_model(123); + let b = random_model(123); + assert_eq!(a.token_embedding, b.token_embedding); + assert_eq!(a.layers.len(), b.layers.len()); + assert_eq!(a.layers[0].wq, b.layers[0].wq); + } + + #[test] + fn random_model_differs_across_seeds() { + let a = random_model(1); + let b = random_model(2); + assert_ne!(a.token_embedding, b.token_embedding); + } + + #[test] + fn vocab_table_has_expected_size() { + assert_eq!(VOCAB_TABLE.len(), VOCAB); + } + + #[test] + fn tokenize_then_detokenize_is_safe() { + let ids = tokenize_prompt("hello world"); + assert!(!ids.is_empty()); + for id in ids { + let _ = detokenize(id); // must not panic + } + } + + #[test] + fn empty_prompt_still_yields_a_token() { + let ids = tokenize_prompt(""); + assert_eq!(ids.len(), 1); + } +} diff --git a/crates/dhamaka-runtime/src/rng.rs b/crates/dhamaka-runtime/src/rng.rs new file mode 100644 index 0000000..4e68e74 --- /dev/null +++ b/crates/dhamaka-runtime/src/rng.rs @@ -0,0 +1,78 @@ +//! A tiny deterministic RNG. We don't need anything cryptographic — we just +//! want reproducible sampling for a given prompt so debugging and testing +//! behave predictably. + +/// xorshift64*. Fast, small, and good enough for sampling. +pub struct Xorshift64 { + state: u64, +} + +impl Xorshift64 { + pub fn new(seed: u64) -> Self { + // Avoid the all-zero fixed point. + let state = if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed }; + Self { state } + } + + #[inline] + pub fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + /// Uniform f32 in [0, 1). + #[inline] + pub fn next_f32(&mut self) -> f32 { + // Top 24 bits as a fraction. + let bits = (self.next_u64() >> 40) as u32; + (bits as f32) * (1.0 / (1u32 << 24) as f32) + } +} + +/// FNV-1a hash for seeding from a byte slice (e.g. the raw prompt). +pub fn fnv1a64(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reproducible() { + let mut a = Xorshift64::new(42); + let mut b = Xorshift64::new(42); + for _ in 0..100 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn next_f32_in_range() { + let mut r = Xorshift64::new(1); + for _ in 0..10_000 { + let v = r.next_f32(); + assert!((0.0..1.0).contains(&v)); + } + } + + #[test] + fn fnv1a_distinct_prompts_yield_distinct_seeds() { + assert_ne!(fnv1a64(b"hello"), fnv1a64(b"world")); + assert_eq!(fnv1a64(b"hello"), fnv1a64(b"hello")); + } + + #[test] + fn fnv1a_empty_is_offset_basis() { + assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325); + } +} diff --git a/crates/dhamaka-runtime/src/sampler.rs b/crates/dhamaka-runtime/src/sampler.rs new file mode 100644 index 0000000..7f2326c --- /dev/null +++ b/crates/dhamaka-runtime/src/sampler.rs @@ -0,0 +1,151 @@ +//! Token samplers. Operate on a logits slice and return a chosen token id. + +use crate::rng::Xorshift64; +use crate::tensor::softmax; + +#[derive(Debug, Clone, Copy)] +pub struct SampleOptions { + pub temperature: f32, + pub top_k: usize, + pub top_p: f32, +} + +impl Default for SampleOptions { + fn default() -> Self { + Self { temperature: 0.7, top_k: 40, top_p: 0.95 } + } +} + +/// Argmax. Used when temperature is 0. +pub fn greedy(logits: &[f32]) -> usize { + let mut best = 0usize; + let mut best_v = f32::NEG_INFINITY; + for (i, &v) in logits.iter().enumerate() { + if v > best_v { + best_v = v; + best = i; + } + } + best +} + +/// Temperature + top-k + top-p sampling in one pass. +/// +/// Mutates `logits` as scratch space. Returns the chosen token id. +pub fn sample(logits: &mut [f32], opts: SampleOptions, rng: &mut Xorshift64) -> usize { + if opts.temperature <= 0.0 { + return greedy(logits); + } + + // 1. Apply temperature. + let inv_t = 1.0 / opts.temperature; + for v in logits.iter_mut() { + *v *= inv_t; + } + + // 2. Build (id, score) pairs and sort by score desc. Small vocab → simple + // approach is fine. This allocates, but only once per sampled token which + // is dwarfed by the matmul cost. + let mut indexed: Vec<(usize, f32)> = + logits.iter().enumerate().map(|(i, &v)| (i, v)).collect(); + indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // 3. Truncate to top-k. + let k = opts.top_k.min(indexed.len()).max(1); + indexed.truncate(k); + + // 4. Softmax over the survivors. + let mut probs: Vec = indexed.iter().map(|(_, v)| *v).collect(); + softmax(&mut probs); + + // 5. Top-p (nucleus): keep the smallest prefix whose cumulative mass >= p. + if opts.top_p < 1.0 { + let mut cum = 0.0f32; + let mut cut = probs.len(); + for (i, &p) in probs.iter().enumerate() { + cum += p; + if cum >= opts.top_p { + cut = i + 1; + break; + } + } + probs.truncate(cut); + indexed.truncate(cut); + // Renormalize. + let s: f32 = probs.iter().sum(); + if s > 0.0 { + for p in probs.iter_mut() { + *p /= s; + } + } + } + + // 6. Multinomial draw. + let r = rng.next_f32(); + let mut acc = 0.0f32; + for (i, &p) in probs.iter().enumerate() { + acc += p; + if r < acc { + return indexed[i].0; + } + } + indexed[indexed.len() - 1].0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn greedy_picks_max() { + assert_eq!(greedy(&[0.1, 0.9, 0.5]), 1); + assert_eq!(greedy(&[5.0, -1.0, 5.0]), 0); + } + + #[test] + fn sample_temp_zero_is_greedy() { + let mut logits = [0.1f32, 0.9, 0.5]; + let mut rng = Xorshift64::new(1); + let opts = SampleOptions { temperature: 0.0, top_k: 40, top_p: 0.95 }; + assert_eq!(sample(&mut logits, opts, &mut rng), 1); + } + + #[test] + fn sample_is_deterministic_for_same_seed() { + let base = [0.2f32, 1.0, 0.5, 0.1, 0.8]; + let opts = SampleOptions::default(); + + let mut ra = Xorshift64::new(12345); + let mut rb = Xorshift64::new(12345); + + for _ in 0..50 { + let mut a = base; + let mut b = base; + assert_eq!(sample(&mut a, opts, &mut ra), sample(&mut b, opts, &mut rb)); + } + } + + #[test] + fn sample_respects_top_k() { + // With top_k=1 we should always pick the argmax regardless of + // temperature and RNG. + let mut rng = Xorshift64::new(7); + let opts = SampleOptions { temperature: 1.0, top_k: 1, top_p: 1.0 }; + for _ in 0..20 { + let mut logits = [0.1f32, 0.2, 5.0, 0.3]; + assert_eq!(sample(&mut logits, opts, &mut rng), 2); + } + } + + #[test] + fn sample_respects_top_p() { + // With top_p tiny, we should always hit the single most-probable + // token. + let mut rng = Xorshift64::new(42); + let opts = SampleOptions { temperature: 1.0, top_k: 40, top_p: 0.01 }; + for _ in 0..20 { + let mut logits = [0.1f32, 0.2, 5.0, 0.3]; + assert_eq!(sample(&mut logits, opts, &mut rng), 2); + } + } +} diff --git a/crates/dhamaka-runtime/src/tensor.rs b/crates/dhamaka-runtime/src/tensor.rs new file mode 100644 index 0000000..488bd8e --- /dev/null +++ b/crates/dhamaka-runtime/src/tensor.rs @@ -0,0 +1,225 @@ +//! Tensor primitives used by the forward pass. +//! +//! These are the hot kernels. Everything here operates on flat `&[f32]` +//! slices so the caller controls allocation. The real runtime gets its speed +//! from running these loops in WebAssembly compiled from Rust, and +//! optionally from SIMD (`-C target-feature=+simd128`, wired in the crate's +//! build config) and WebGPU (future work). +//! +//! Every primitive is covered by native `cargo test`. + +/// `out = a @ b` where `a` is `[m, k]` and `b` is `[k, n]`, both row-major. +/// +/// Chosen shape because transformer projection matrices multiply a single +/// token's hidden state (`[1, k]`) by a weight matrix (`[k, n]`). For single- +/// token generation m is 1 almost always, but we keep it general so the +/// function is testable against known references. +pub fn matmul(a: &[f32], b: &[f32], out: &mut [f32], m: usize, k: usize, n: usize) { + assert_eq!(a.len(), m * k, "matmul: a has wrong length"); + assert_eq!(b.len(), k * n, "matmul: b has wrong length"); + assert_eq!(out.len(), m * n, "matmul: out has wrong length"); + + for i in 0..m { + for j in 0..n { + let mut acc = 0.0f32; + for p in 0..k { + acc += a[i * k + p] * b[p * n + j]; + } + out[i * n + j] = acc; + } + } +} + +/// Root-mean-square normalization (the normalization used by Llama and +/// SmolLM2). `weight` is a learned scale vector broadcast across the feature +/// dimension. +pub fn rmsnorm(x: &[f32], weight: &[f32], out: &mut [f32], eps: f32) { + assert_eq!(x.len(), weight.len()); + assert_eq!(x.len(), out.len()); + + let n = x.len() as f32; + let mut sumsq = 0.0f32; + for &v in x { + sumsq += v * v; + } + let rms = (sumsq / n + eps).sqrt(); + let scale = 1.0 / rms; + for i in 0..x.len() { + out[i] = x[i] * scale * weight[i]; + } +} + +/// Numerically stable softmax, in-place. +pub fn softmax(x: &mut [f32]) { + if x.is_empty() { + return; + } + let mut max = x[0]; + for &v in x.iter() { + if v > max { + max = v; + } + } + let mut sum = 0.0f32; + for v in x.iter_mut() { + *v = (*v - max).exp(); + sum += *v; + } + if sum == 0.0 { + // All -inf: uniform. + let u = 1.0 / x.len() as f32; + for v in x.iter_mut() { + *v = u; + } + } else { + let inv = 1.0 / sum; + for v in x.iter_mut() { + *v *= inv; + } + } +} + +/// SiLU (Swish) activation: `x * sigmoid(x)`. Used by Llama-style FFN blocks +/// inside the SwiGLU gate. +pub fn silu(x: &mut [f32]) { + for v in x.iter_mut() { + *v *= 1.0 / (1.0 + (-*v).exp()); + } +} + +/// In-place elementwise add: `a += b`. +pub fn add_inplace(a: &mut [f32], b: &[f32]) { + assert_eq!(a.len(), b.len()); + for i in 0..a.len() { + a[i] += b[i]; + } +} + +/// In-place elementwise multiply: `a *= b`. Used by SwiGLU. +pub fn mul_inplace(a: &mut [f32], b: &[f32]) { + assert_eq!(a.len(), b.len()); + for i in 0..a.len() { + a[i] *= b[i]; + } +} + +/// Rotary position embedding (RoPE), applied to a single `head_dim`-sized +/// vector at position `pos`. Operates in pairs: `(x[2i], x[2i+1])` rotates by +/// angle `pos * theta_i` where `theta_i = base^(-2i/head_dim)`. +/// +/// This matches the convention used by Llama, Mistral, and SmolLM2. +pub fn rope_apply(x: &mut [f32], pos: usize, base: f32) { + let dim = x.len(); + assert!(dim % 2 == 0, "rope: head_dim must be even"); + let half = dim / 2; + for i in 0..half { + let theta = (pos as f32) * base.powf(-2.0 * (i as f32) / (dim as f32)); + let (sin, cos) = theta.sin_cos(); + let x0 = x[2 * i]; + let x1 = x[2 * i + 1]; + x[2 * i] = x0 * cos - x1 * sin; + x[2 * i + 1] = x0 * sin + x1 * cos; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matmul_identity() { + // [1 2] @ I2 = [1 2] + let a = [1.0, 2.0]; + let b = [1.0, 0.0, 0.0, 1.0]; + let mut out = [0.0; 2]; + matmul(&a, &b, &mut out, 1, 2, 2); + assert_eq!(out, [1.0, 2.0]); + } + + #[test] + fn matmul_2x2() { + // [[1, 2], [3, 4]] @ [[5, 6], [7, 8]] = [[19, 22], [43, 50]] + let a = [1.0, 2.0, 3.0, 4.0]; + let b = [5.0, 6.0, 7.0, 8.0]; + let mut out = [0.0; 4]; + matmul(&a, &b, &mut out, 2, 2, 2); + assert_eq!(out, [19.0, 22.0, 43.0, 50.0]); + } + + #[test] + fn rmsnorm_uniform_vector() { + // Uniform input with unit weights should renormalize to (roughly) 1s. + let x = [1.0f32; 8]; + let w = [1.0f32; 8]; + let mut out = [0.0f32; 8]; + rmsnorm(&x, &w, &mut out, 1e-6); + for v in out { + assert!((v - 1.0).abs() < 1e-4, "got {}", v); + } + } + + #[test] + fn softmax_sums_to_one() { + let mut x = [1.0f32, 2.0, 3.0, 4.0]; + softmax(&mut x); + let s: f32 = x.iter().sum(); + assert!((s - 1.0).abs() < 1e-5); + // Monotone: bigger input, bigger probability. + assert!(x[3] > x[2] && x[2] > x[1] && x[1] > x[0]); + } + + #[test] + fn softmax_is_translation_invariant() { + let mut a = [1.0f32, 2.0, 3.0]; + let mut b = [101.0f32, 102.0, 103.0]; + softmax(&mut a); + softmax(&mut b); + for i in 0..3 { + assert!((a[i] - b[i]).abs() < 1e-5); + } + } + + #[test] + fn silu_zero_is_zero() { + let mut x = [0.0f32]; + silu(&mut x); + assert!(x[0].abs() < 1e-6); + } + + #[test] + fn silu_large_positive_is_identity() { + let mut x = [20.0f32]; + silu(&mut x); + assert!((x[0] - 20.0).abs() < 1e-3); + } + + #[test] + fn add_and_mul_inplace() { + let mut a = [1.0f32, 2.0, 3.0]; + let b = [4.0f32, 5.0, 6.0]; + add_inplace(&mut a, &b); + assert_eq!(a, [5.0, 7.0, 9.0]); + mul_inplace(&mut a, &b); + assert_eq!(a, [20.0, 35.0, 54.0]); + } + + #[test] + fn rope_pos_zero_is_identity() { + let mut x = [1.0f32, 2.0, 3.0, 4.0]; + let original = x; + rope_apply(&mut x, 0, 10000.0); + for i in 0..4 { + assert!((x[i] - original[i]).abs() < 1e-5); + } + } + + #[test] + fn rope_preserves_norm() { + // Rotations preserve the L2 norm of each pair. + let mut x = [0.3f32, 0.4, -0.6, 0.8]; + let n_before: f32 = x.iter().map(|v| v * v).sum(); + rope_apply(&mut x, 7, 10000.0); + let n_after: f32 = x.iter().map(|v| v * v).sum(); + assert!((n_before - n_after).abs() < 1e-5); + } +} diff --git a/crates/dhamaka-runtime/src/transformer.rs b/crates/dhamaka-runtime/src/transformer.rs new file mode 100644 index 0000000..8d15f0a --- /dev/null +++ b/crates/dhamaka-runtime/src/transformer.rs @@ -0,0 +1,232 @@ +//! A minimal transformer forward-pass kernel built out of the primitives in +//! [`crate::tensor`]. This is deliberately small — single head, no KV cache, +//! no flash attention, no grouped-query attention. It's the "hello world" +//! of transformer inference, not a state-of-the-art one. +//! +//! The goal for v0.1 is to prove that real f32 math runs end-to-end inside +//! WebAssembly compiled from Rust. Phase-2 work replaces this kernel with a +//! faster, batched, KV-cached version that matches what real models need. + +use crate::tensor::{add_inplace, matmul, mul_inplace, rmsnorm, rope_apply, silu, softmax}; + +/// Fixed architectural constants for the tiny v0.1 model. +pub const HIDDEN: usize = 32; +pub const FFN_HIDDEN: usize = 64; +pub const VOCAB: usize = 64; +pub const N_LAYERS: usize = 2; +pub const N_HEADS: usize = 1; +pub const HEAD_DIM: usize = HIDDEN / N_HEADS; +pub const ROPE_BASE: f32 = 10000.0; +pub const RMS_EPS: f32 = 1e-5; +/// Maximum supported context length. Controls KV cache allocation. +pub const MAX_CTX: usize = 512; + +/// A single transformer block's weights. +#[derive(Debug, Clone)] +pub struct LayerWeights { + pub attn_norm: Vec, // [HIDDEN] + pub wq: Vec, // [HIDDEN, HIDDEN] + pub wk: Vec, // [HIDDEN, HIDDEN] + pub wv: Vec, // [HIDDEN, HIDDEN] + pub wo: Vec, // [HIDDEN, HIDDEN] + pub ffn_norm: Vec, // [HIDDEN] + pub w_gate: Vec, // [HIDDEN, FFN_HIDDEN] + pub w_up: Vec, // [HIDDEN, FFN_HIDDEN] + pub w_down: Vec, // [FFN_HIDDEN, HIDDEN] +} + +/// Whole-model weights. +#[derive(Debug, Clone)] +pub struct ModelWeights { + pub token_embedding: Vec, // [VOCAB, HIDDEN] + pub layers: Vec, + pub final_norm: Vec, // [HIDDEN] + pub lm_head: Vec, // [HIDDEN, VOCAB] +} + +/// Scratch buffers reused across forward passes to avoid per-token allocation. +/// Includes a KV cache so self-attention covers every prior token position +/// instead of collapsing to a single-element softmax. +pub struct Scratch { + pub x: Vec, // [HIDDEN] + pub x_norm: Vec, // [HIDDEN] + pub q: Vec, // [HIDDEN] + pub k: Vec, // [HIDDEN] + pub v: Vec, // [HIDDEN] + pub attn_out: Vec, // [HIDDEN] + pub attn_scores: Vec,// [MAX_CTX] + pub ffn_gate: Vec, // [FFN_HIDDEN] + pub ffn_up: Vec, // [FFN_HIDDEN] + pub ffn_out: Vec, // [HIDDEN] + pub proj: Vec, // [HIDDEN] + pub logits: Vec, // [VOCAB] + /// K and V cache per layer: `k_cache[layer]` is `[MAX_CTX * HIDDEN]`. + pub k_cache: Vec>, + pub v_cache: Vec>, +} + +impl Scratch { + pub fn new() -> Self { + Self { + x: vec![0.0; HIDDEN], + x_norm: vec![0.0; HIDDEN], + q: vec![0.0; HIDDEN], + k: vec![0.0; HIDDEN], + v: vec![0.0; HIDDEN], + attn_out: vec![0.0; HIDDEN], + attn_scores: vec![0.0; MAX_CTX], + ffn_gate: vec![0.0; FFN_HIDDEN], + ffn_up: vec![0.0; FFN_HIDDEN], + ffn_out: vec![0.0; HIDDEN], + proj: vec![0.0; HIDDEN], + logits: vec![0.0; VOCAB], + k_cache: (0..N_LAYERS).map(|_| vec![0.0; MAX_CTX * HIDDEN]).collect(), + v_cache: (0..N_LAYERS).map(|_| vec![0.0; MAX_CTX * HIDDEN]).collect(), + } + } + + /// Zero out the KV cache. Called on reset. + pub fn clear_cache(&mut self) { + for cache in self.k_cache.iter_mut() { + for v in cache.iter_mut() { + *v = 0.0; + } + } + for cache in self.v_cache.iter_mut() { + for v in cache.iter_mut() { + *v = 0.0; + } + } + } +} + +impl Default for Scratch { + fn default() -> Self { + Self::new() + } +} + +/// Single-token forward pass with a KV cache. `pos` is the absolute token +/// position (used for rotary embeddings and cache offsets). Writes final +/// logits into `scratch.logits`. Panics if `pos >= MAX_CTX`. +/// +/// This is O(HIDDEN² · N_LAYERS + HIDDEN · pos · N_LAYERS) per token. For +/// (HIDDEN=32, LAYERS=2, MAX_CTX=512) it's comfortably real-time in pure +/// scalar WebAssembly compiled from Rust. +pub fn forward(model: &ModelWeights, token_id: usize, pos: usize, scratch: &mut Scratch) { + assert!(pos < MAX_CTX, "forward: pos {} exceeds MAX_CTX {}", pos, MAX_CTX); + + // Token embedding lookup: x = token_embedding[token_id] + let start = token_id * HIDDEN; + let end = start + HIDDEN; + scratch.x.copy_from_slice(&model.token_embedding[start..end]); + + let inv_sqrt = 1.0 / (HEAD_DIM as f32).sqrt(); + + for (layer_idx, layer) in model.layers.iter().enumerate() { + // ---- Attention ---- + rmsnorm(&scratch.x, &layer.attn_norm, &mut scratch.x_norm, RMS_EPS); + + // Q, K, V projections. + matmul(&scratch.x_norm, &layer.wq, &mut scratch.q, 1, HIDDEN, HIDDEN); + matmul(&scratch.x_norm, &layer.wk, &mut scratch.k, 1, HIDDEN, HIDDEN); + matmul(&scratch.x_norm, &layer.wv, &mut scratch.v, 1, HIDDEN, HIDDEN); + + // Rotary position embeddings on Q and K (not V). + rope_apply(&mut scratch.q, pos, ROPE_BASE); + rope_apply(&mut scratch.k, pos, ROPE_BASE); + + // Write this step's K and V into the cache at `pos`. + let offset = pos * HIDDEN; + scratch.k_cache[layer_idx][offset..offset + HIDDEN] + .copy_from_slice(&scratch.k); + scratch.v_cache[layer_idx][offset..offset + HIDDEN] + .copy_from_slice(&scratch.v); + + // Attention scores: q · k_i for every cached i in 0..=pos. + let ctx_len = pos + 1; + for i in 0..ctx_len { + let ko = i * HIDDEN; + let mut s = 0.0f32; + for d in 0..HIDDEN { + s += scratch.q[d] * scratch.k_cache[layer_idx][ko + d]; + } + scratch.attn_scores[i] = s * inv_sqrt; + } + softmax(&mut scratch.attn_scores[0..ctx_len]); + + // Weighted sum of V. + for v in scratch.attn_out.iter_mut() { + *v = 0.0; + } + for i in 0..ctx_len { + let vo = i * HIDDEN; + let w = scratch.attn_scores[i]; + for d in 0..HIDDEN { + scratch.attn_out[d] += w * scratch.v_cache[layer_idx][vo + d]; + } + } + + // Output projection + residual. + matmul(&scratch.attn_out, &layer.wo, &mut scratch.proj, 1, HIDDEN, HIDDEN); + add_inplace(&mut scratch.x, &scratch.proj); + + // ---- Feed-forward (SwiGLU) ---- + rmsnorm(&scratch.x, &layer.ffn_norm, &mut scratch.x_norm, RMS_EPS); + matmul(&scratch.x_norm, &layer.w_gate, &mut scratch.ffn_gate, 1, HIDDEN, FFN_HIDDEN); + matmul(&scratch.x_norm, &layer.w_up, &mut scratch.ffn_up, 1, HIDDEN, FFN_HIDDEN); + silu(&mut scratch.ffn_gate); + mul_inplace(&mut scratch.ffn_gate, &scratch.ffn_up); + matmul(&scratch.ffn_gate, &layer.w_down, &mut scratch.ffn_out, 1, FFN_HIDDEN, HIDDEN); + add_inplace(&mut scratch.x, &scratch.ffn_out); + } + + // Final norm + LM head. + rmsnorm(&scratch.x, &model.final_norm, &mut scratch.x_norm, RMS_EPS); + matmul(&scratch.x_norm, &model.lm_head, &mut scratch.logits, 1, HIDDEN, VOCAB); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::random_model; + + #[test] + fn forward_produces_finite_logits() { + let model = random_model(0xC0FFEE); + let mut scratch = Scratch::new(); + forward(&model, 7, 3, &mut scratch); + assert_eq!(scratch.logits.len(), VOCAB); + for &v in &scratch.logits { + assert!(v.is_finite(), "logit is not finite: {}", v); + } + } + + #[test] + fn forward_is_deterministic_for_same_seed() { + let a = random_model(0xDEAD); + let b = random_model(0xDEAD); + let mut sa = Scratch::new(); + let mut sb = Scratch::new(); + forward(&a, 5, 0, &mut sa); + forward(&b, 5, 0, &mut sb); + for i in 0..VOCAB { + assert!((sa.logits[i] - sb.logits[i]).abs() < 1e-6); + } + } + + #[test] + fn different_positions_yield_different_logits() { + // RoPE should make position matter. + let m = random_model(0xBEEF); + let mut s0 = Scratch::new(); + let mut s1 = Scratch::new(); + forward(&m, 5, 0, &mut s0); + forward(&m, 5, 7, &mut s1); + let mut diff = 0.0f32; + for i in 0..VOCAB { + diff += (s0.logits[i] - s1.logits[i]).abs(); + } + assert!(diff > 1e-3, "logits at pos 0 and pos 7 were identical"); + } +} diff --git a/packages/hub/public/runtime/dhamaka-runtime.wasm b/packages/hub/public/runtime/dhamaka-runtime.wasm new file mode 100755 index 0000000000000000000000000000000000000000..dd96d4c7315f21b452bf7f178c8efb09c8caec69 GIT binary patch literal 56392 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 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/test/factory.test.js b/packages/runtime/test/factory.test.js index ec75bcf..2e74cf7 100644 --- a/packages/runtime/test/factory.test.js +++ b/packages/runtime/test/factory.test.js @@ -28,10 +28,22 @@ test("Engine abstract class cannot be instantiated directly", async () => { assert.throws(() => new Engine(), /abstract/); }); -test("WasmEngine: load() refuses without a wasmUrl", async () => { - const engine = new WasmEngine(); - await assert.rejects( - () => engine.load({ entry: {}, artifacts: { weights: new Uint8Array(), config: new Uint8Array() } }), - /no WASM module configured/, +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/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/src/index.js b/packages/sdk/src/index.js index d6ac26c..8e89006 100644 --- a/packages/sdk/src/index.js +++ b/packages/sdk/src/index.js @@ -39,12 +39,23 @@ export class Dhamaka { 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 }); + // The WASM runtime binary lives on the hub origin at /runtime/…, same + // place the hub serves model weights from. Resolve it against the hub + // URL so the fetch works in development (http://localhost:5174/…) and + // production (https://hub.dhamaka.dev/…) without config. + let wasmUrl = options.wasmUrl; + if (!wasmUrl && typeof URL !== "undefined") { + try { + wasmUrl = new URL("runtime/dhamaka-runtime.wasm", hubUrl).href; + } catch { + // fall through — createEngine will degrade to MockEngine in Node + } + } this.engine = createEngine({ backend: options.backend ?? "auto", - wasmUrl: options.wasmUrl, + wasmUrl, }); this._cached = false; this._loadedAt = 0; From 639892a53248cd94ad5c4f4c3280f997419f1242 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:27:38 +0000 Subject: [PATCH 03/29] Document the test suite in the README Add a dedicated tests section that lists every test file with its count and coverage, explains how to run Rust + JS + end-to-end wasm tests independently or together, diagrams the wasm-engine.test.js path that loads the real .wasm from Node and drives it through the ABI, and sketches the two-job CI pipeline. Numbers reflected in the README match the actual runners: - 27 Rust tests (cargo test in crates/dhamaka-runtime) - 45 JS tests (node --test across packages/*/test) - 72 total, zero test-runner dependencies --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/README.md b/README.md index 30ba7d5..7180e36 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,122 @@ Modern browsers increasingly **partition third-party storage** by the top-level --- +## ✦ tests + +``` + ╭─────────────────────────────────────────────────────────────╮ + │ │ + │ ██████ ██████ ██████ █████ ██████ │ + │ ╚════██ ██╔═══██╗ ╚════██╗██╔══██╗██╔════╝ │ + │ █████╔╝ ╚██████║ █████╔╝███████║██║ │ + │ ██╔═══╝ ██╗═══██║ ██╔═══╝ ██╔══██║██║ │ + │ ███████╗╚██████╔╝ ███████╗██║ ██║╚██████╗ │ + │ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ │ + │ │ + │ 27 rust tests · 45 js tests · all green │ + │ │ + ╰─────────────────────────────────────────────────────────────╯ +``` + +### run them + +```bash +# everything (Rust native + JS + end-to-end wasm) +cargo test --manifest-path crates/dhamaka-runtime/Cargo.toml +npm test + +# just the Rust crate +cd crates/dhamaka-runtime && cargo test + +# just the JS side +npm test + +# one specific file +node --test packages/runtime/test/wasm-engine.test.js +``` + +Zero test-runner dependencies. Rust uses `cargo test`, JS uses the Node 20+ built-in `node --test`. No jest, no mocha, no vitest, no install step past `rustup` and the Node toolchain. + +### Rust · `cargo test` · 27 tests + +The hot path. Every tensor primitive, the sampler, the forward pass, and the model init are covered by native unit tests that run in milliseconds. + +| file | tests | what it covers | +|------------------------------|:-----:|---------------------------------------------------------------------------------| +| `src/rng.rs` | 4 | xorshift64* determinism, `next_f32()` range, FNV-1a seed-hash distinctness | +| `src/tensor.rs` | 10 | matmul (identity + 2×2 reference), RMSNorm, softmax sums to 1 + translation invariance, SiLU at 0 and large positive, in-place add/mul, RoPE identity at pos 0 + norm preservation | +| `src/sampler.rs` | 5 | greedy picks max, temperature=0 is greedy, deterministic for same seed, `top_k=1` always hits argmax, `top_p=0.01` collapses to the mode | +| `src/transformer.rs` | 3 | forward pass produces finite logits, is deterministic for same seed, **different positions produce different logits** (caught a real KV-cache bug) | +| `src/model.rs` | 5 | random-weights init is reproducible, different seeds differ, vocab table size, detokenize round-trip, empty prompt still yields a token | + +### JavaScript · `npm test` · 45 tests + +Drives the SDK, the hub, and the real compiled `.wasm` end-to-end from Node using the built-in test runner. Zero dependencies. + +| file | tests | what it covers | +|-------------------------------------------|:-----:|------------------------------------------------------------------------------------| +| `packages/runtime/test/factory.test.js` | 7 | backend selection (auto / mock / wasm), abstract `Engine` refuses instantiation, `WasmEngine` info + unreachable-url error | +| `packages/runtime/test/mock-engine.test.js` | 7 | load gating, streaming, `complete()`, determinism, `AbortSignal`, unload | +| `packages/runtime/test/tokenizer.test.js` | 8 | `split()` on words / punctuation / whitespace / empty, JSON `loadFromBytes`, encode/decode stubs | +| `packages/runtime/test/wasm-engine.test.js` | 4 | **loads the real compiled `.wasm`**, streams real Rust forward-pass tokens, deterministic across identical prompts, honors `AbortSignal` | +| `packages/sdk/test/chat.test.js` | 6 | history accumulation, system prompt, streaming transcript, reset w/ and w/o system | +| `packages/sdk/test/hub-client.test.js` | 5 | Node fallback mode, ping, get with mocked fetch (cache miss then hit), list + delete, unknown-model error | +| `packages/sdk/test/openai-shim.test.js` | 3 | non-streaming ChatCompletion shape, streaming SSE with `[DONE]`, passthrough for non-matching URLs | +| `packages/hub/test/manifest.test.js` | 5 | canonical manifest parses, model ids + required fields, sha256 format, default model exists, served hub manifest mirrors shape | + +### end-to-end + +The four `wasm-engine.test.js` tests are the moat. They stub `globalThis.fetch` to read the compiled `dhamaka-runtime.wasm` off disk, then drive the real ABI: + +``` +┌─ Node ────────────────────────────────────────────────────────────┐ +│ WasmEngine │ +│ │ │ +│ │ WebAssembly.instantiate(fs.readFile(.wasm)) │ +│ ▼ │ +│ [ dhamaka_version ==> 1 ] │ +│ [ dhamaka_alloc ==> ptr ] │ +│ [ write prompt bytes into WASM linear memory ] │ +│ [ dhamaka_init ==> ctx ] │ +│ [ dhamaka_feed_prompt(ctx, ptr, len) ] │ +│ [ loop { dhamaka_next_token(ctx, out, 64) ==> n bytes } ] │ +│ [ decode UTF-8, yield token ] │ +└───────────────────────────────────────────────────────────────────┘ +``` + +These four pass in Node, so every token in the README's "real today" list is real. The same `WasmEngine` runs in the browser via `instantiateStreaming` — no fork. + +### CI + +`.github/workflows/ci.yml` runs on every push and pull request: + +``` + ┌─────────────────────────┐ + │ job 1 · rust │ + │ rustup target add │ + │ wasm32-unknown- │ + │ unknown │ + │ cargo test │─── 27 tests + │ cargo build --release │ + │ --target wasm32-… │─── stage .wasm artifact + └───────────┬─────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ job 2 · js │ + │ download wasm artifact│ + │ node --check **/*.js │ + │ npm test │─── 45 tests + │ smoke-test dev server │─── curl every endpoint + └─────────────────────────┘ + + matrix: node 20, node 22 +``` + +No green CI, no merge. + +--- + ## ✦ philosophy ``` From 9533cd357cbb5a10142bce370db13bdf39c1efc9 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:33:39 +0000 Subject: [PATCH 04/29] Animated SVG banner for the README title Replace the static ASCII title with docs/banner.svg, an animated SVG that renders the DHAMAKA block letters with: - a rainbow gradient that cycles through six colors on an 8s loop - a soft neon glow filter around the letters - a pulsing radial spotlight behind everything - a subtle scanline drifting top-to-bottom across the block - two accent stars flanking the tagline that cycle colors on a 4s loop GitHub renders SMIL animations in SVGs loaded via , so the banner is actually animated on the rendered README. A static ASCII fallback is preserved below the for renderers that don't do SMIL. Also bumped the feature chips: added the Rust chip and updated the runtime size chip from ~100MB (the model) to ~56 KB (the wasm). --- README.md | 36 ++++++++++------ docs/banner.svg | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 docs/banner.svg diff --git a/README.md b/README.md index 7180e36..a472139 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,32 @@
    + + + + Dhamaka — browser-native LLM. Download once. Run anywhere. + + +
    + +**`💥 WASM`**  ·  **`🦀 Rust`**  ·  **`🧠 on-device`**  ·  **`🔒 private`**  ·  **`⚡ instant`**  ·  **`🪶 ~56 KB runtime`** + +
    + +The banner above is animated — the block letters cycle through a rainbow gradient and the stars pulse. If your renderer doesn't support SMIL (rare), here's the static form: + ``` - ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ ██╗ ██╗ █████╗ - ██╔══██╗██║ ██║██╔══██╗████╗ ████║██╔══██╗██║ ██╔╝██╔══██╗ - ██║ ██║███████║███████║██╔████╔██║███████║█████╔╝ ███████║ - ██║ ██║██╔══██║██╔══██║██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══██║ - ██████╔╝██║ ██║██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██╗██║ ██║ - ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ - - ╭─────────────────────────────────────────────────╮ - │ a browser-native LLM that lives in your tab │ - │ download once · run on every site · forever │ - ╰─────────────────────────────────────────────────╯ + ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ ██╗ ██╗ █████╗ + ██╔══██╗██║ ██║██╔══██╗████╗ ████║██╔══██╗██║ ██╔╝██╔══██╗ + ██║ ██║███████║███████║██╔████╔██║███████║█████╔╝ ███████║ + ██║ ██║██╔══██║██╔══██║██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══██║ + ██████╔╝██║ ██║██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██╗██║ ██║ + ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ + + a browser-native LLM that lives in your tab + download once · run on every site · forever ``` -**`💥 WASM`**  ·  **`🧠 on-device`**  ·  **`🔒 private`**  ·  **`⚡ instant`**  ·  **`🪶 ~100MB`** -
    --- diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..2f11a66 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,112 @@ + + + Dhamaka + Browser-native LLM. Download once. Run anywhere. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ██████╗ ██╗ ██╗ █████╗ ███╗ ███╗ █████╗ ██╗ ██╗ █████╗ + ██╔══██╗██║ ██║██╔══██╗████╗ ████║██╔══██╗██║ ██╔╝██╔══██╗ + ██║ ██║███████║███████║██╔████╔██║███████║█████╔╝ ███████║ + ██║ ██║██╔══██║██╔══██║██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══██║ + ██████╔╝██║ ██║██║ ██║██║ ╚═╝ ██║██║ ██║██║ ██╗██║ ██║ + ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ + + + + + + + + ✦ + + + browser-native LLM · download once · run anywhere + + ✦ + + + + + WASM · on-device · private · instant · ~56 KB runtime + + + From 18b3ac623bb9e02670cb84387198aadcef925297 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:41:15 +0000 Subject: [PATCH 05/29] Prepare v0.1.0: changelog, staging script, release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything needed to cut a tagged release and publish the `dhamaka` npm package. The actual `npm publish` step is gated on an NPM_TOKEN secret being present, so the workflow runs end-to-end safely before credentials are in place. CHANGELOG.md - First entry documenting the Rust runtime, SDK, hub, extension, playground, CI, and the v0.1.0 honesty caveats. scripts/prepare-publish.mjs - Vendors packages/runtime/src + the compiled dhamaka-runtime.wasm into packages/sdk/_staging/src/_runtime/ - Rewrites the SDK's @dhamaka/runtime import to a relative path so the published tarball depends on nothing at runtime - Rewrites WasmEngine's DEFAULT_WASM_URL to resolve the bundled wasm via new URL("./dhamaka-runtime.wasm", import.meta.url) - Writes a standalone package.json (no workspace refs, no deps) - Copies README/LICENSE/CHANGELOG into the staged package - Smoke-imports the staged entry point before declaring success Verified by `npm run release:dry`: dhamaka@0.1.0 package size: 43.7 kB unpacked size: 127.0 kB total files: 15 (includes the 56 KB wasm) .github/workflows/release.yml - Triggered on v* tag push - Installs rust toolchain + wasm32-unknown-unknown - cargo test, build wasm, npm test - node scripts/prepare-publish.mjs - Verifies tag version matches staged package.json version - Publishes to npm with --provenance if NPM_TOKEN is set (gracefully skips otherwise, so the pipeline is safe to dry-run) - Creates a GitHub release named "Dhamaka vX.Y.Z" with release notes extracted from CHANGELOG.md and both the .tgz and raw .wasm attached packages/sdk/PUBLISHING.md - Step-by-step for cutting a release, setting up NPM_TOKEN, and manual publish fallback Root scripts: - npm run build:wasm — compile the Rust crate to .wasm - npm run build:wasm:check — also run cargo test - npm run prepublish-stage — vendor into _staging - npm run release:dry — stage + npm pack --dry-run .gitignore ignores packages/sdk/_staging/ and *.tgz since they're rebuilt from scratch on every release. --- .github/workflows/release.yml | 109 ++++++++++++++++++ .gitignore | 5 + CHANGELOG.md | 123 +++++++++++++++++++++ package.json | 6 +- packages/sdk/PUBLISHING.md | 97 ++++++++++++++++ scripts/prepare-publish.mjs | 201 ++++++++++++++++++++++++++++++++++ 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 packages/sdk/PUBLISHING.md create mode 100644 scripts/prepare-publish.mjs 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 bbf7960..d2f9347 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ models/*.gguf # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1e7127d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,123 @@ +# 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). + +## [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/package.json b/package.json index 823661a..349eabd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "scripts": { "dev": "node packages/playground/server.js", "start": "node packages/playground/server.js", - "test": "node --test --test-reporter=spec 'packages/runtime/test/*.test.js' 'packages/sdk/test/*.test.js' 'packages/hub/test/*.test.js'" + "test": "node --test --test-reporter=spec 'packages/runtime/test/*.test.js' 'packages/sdk/test/*.test.js' 'packages/hub/test/*.test.js'", + "build:wasm": "crates/dhamaka-runtime/build.sh", + "build:wasm:check": "crates/dhamaka-runtime/build.sh --check", + "prepublish-stage": "node scripts/prepare-publish.mjs", + "release:dry": "node scripts/prepare-publish.mjs && cd packages/sdk/_staging && npm pack --dry-run" }, "license": "MIT", "author": "Dhamaka contributors", 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/scripts/prepare-publish.mjs b/scripts/prepare-publish.mjs new file mode 100644 index 0000000..640f859 --- /dev/null +++ b/scripts/prepare-publish.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// Stage the `dhamaka` npm package. +// +// The SDK imports `@dhamaka/runtime` during development via npm workspaces. +// When we publish to npm we don't want consumers to have to install two +// packages, and we don't want to fight the `@dhamaka` scope, so this script +// bundles the runtime source + the compiled wasm into the SDK package as +// a vendored subtree and rewrites the one `@dhamaka/runtime` import. +// +// Output: packages/sdk/_staging/, a fully self-contained npm package. +// +// Usage: +// node scripts/prepare-publish.mjs # build + stage +// node scripts/prepare-publish.mjs --check # also run the test suite +// +// The release workflow runs this and then `npm publish ./packages/sdk/_staging`. +// For a manual release, do the same thing locally with your npm credentials. + +import { readFile, writeFile, mkdir, rm, cp, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); + +const SDK_SRC = join(ROOT, "packages", "sdk"); +const RUNTIME_SRC = join(ROOT, "packages", "runtime", "src"); +const WASM_SRC = join(ROOT, "packages", "hub", "public", "runtime", "dhamaka-runtime.wasm"); +const STAGING = join(SDK_SRC, "_staging"); + +const check = process.argv.includes("--check"); + +async function main() { + console.log("› preparing dhamaka publish staging"); + + // 0. Sanity check: wasm must exist. + if (!existsSync(WASM_SRC)) { + console.error( + `\n ✗ compiled wasm not found at ${WASM_SRC}\n run crates/dhamaka-runtime/build.sh first\n`, + ); + process.exit(1); + } + + // 1. Wipe any previous staging output. + if (existsSync(STAGING)) { + await rm(STAGING, { recursive: true, force: true }); + } + await mkdir(STAGING, { recursive: true }); + + // 2. Copy the SDK src/ tree into staging/src/. + await cp(join(SDK_SRC, "src"), join(STAGING, "src"), { recursive: true }); + + // 3. Vendor the runtime adapter into staging/src/_runtime/. + await cp(RUNTIME_SRC, join(STAGING, "src", "_runtime"), { recursive: true }); + + // 4. Copy the compiled wasm next to the runtime adapter. + await cp( + WASM_SRC, + join(STAGING, "src", "_runtime", "dhamaka-runtime.wasm"), + ); + + // 5. Rewrite the one `@dhamaka/runtime` import in the SDK entry point. + const indexPath = join(STAGING, "src", "index.js"); + let index = await readFile(indexPath, "utf8"); + const before = index; + index = index.replaceAll( + 'from "@dhamaka/runtime"', + 'from "./_runtime/index.js"', + ); + index = index.replaceAll( + "from '@dhamaka/runtime'", + "from './_runtime/index.js'", + ); + if (index === before) { + console.warn( + " ! no @dhamaka/runtime import found to rewrite — " + + "make sure packages/sdk/src/index.js still imports the runtime", + ); + } + await writeFile(indexPath, index); + + // 6. Rewrite the default wasm URL in the vendored WasmEngine so it points + // at the bundled .wasm sitting next to it (instead of the hub's + // /runtime/ path the browser normally uses). + const wasmEnginePath = join(STAGING, "src", "_runtime", "wasm-engine.js"); + let wasmEngine = await readFile(wasmEnginePath, "utf8"); + wasmEngine = wasmEngine.replace( + 'const DEFAULT_WASM_URL = "/runtime/dhamaka-runtime.wasm";', + 'const DEFAULT_WASM_URL = new URL("./dhamaka-runtime.wasm", import.meta.url).href;', + ); + await writeFile(wasmEnginePath, wasmEngine); + + // 7. Write a standalone package.json. No workspace refs, no devDeps. + const sdkPkg = JSON.parse( + await readFile(join(SDK_SRC, "package.json"), "utf8"), + ); + const rootPkg = JSON.parse( + await readFile(join(ROOT, "package.json"), "utf8"), + ); + + const publishedPkg = { + name: sdkPkg.name, + version: sdkPkg.version, + description: sdkPkg.description, + type: "module", + main: "src/index.js", + module: "src/index.js", + exports: { + ".": "./src/index.js", + "./hub-client": "./src/hub-client.js", + "./chat": "./src/chat.js", + "./openai": "./src/openai-shim.js", + }, + files: ["src", "README.md", "LICENSE", "CHANGELOG.md"], + keywords: [ + "llm", + "wasm", + "webassembly", + "rust", + "browser", + "ai", + "on-device", + "local-first", + "privacy", + "transformer", + ], + author: "protosphinx", + license: rootPkg.license || "MIT", + repository: rootPkg.repository, + bugs: { + url: "https://github.com/protosphinx/dhamaka/issues", + }, + homepage: "https://github.com/protosphinx/dhamaka#readme", + engines: { + node: ">=18", + }, + // Deliberately no `dependencies` — the runtime is vendored above. + }; + await writeFile( + join(STAGING, "package.json"), + JSON.stringify(publishedPkg, null, 2) + "\n", + ); + + // 8. Copy README, LICENSE, CHANGELOG so the published package has them. + const maybeCopy = async (src, dest) => { + if (existsSync(src)) await cp(src, dest); + }; + await maybeCopy(join(ROOT, "README.md"), join(STAGING, "README.md")); + await maybeCopy(join(ROOT, "LICENSE"), join(STAGING, "LICENSE")); + await maybeCopy(join(ROOT, "CHANGELOG.md"), join(STAGING, "CHANGELOG.md")); + + // 9. Sanity check: the staged package must pass a basic import smoke test. + const probe = ` + import { Dhamaka, Chat, HubClient } from "${join(STAGING, "src", "index.js")}"; + if (typeof Dhamaka !== "function") process.exit(1); + if (typeof Chat !== "function") process.exit(1); + if (typeof HubClient !== "function") process.exit(1); + console.log("✓ staged package imports cleanly"); + `; + const r = spawnSync(process.execPath, ["--input-type=module", "-e", probe], { + stdio: "inherit", + }); + if (r.status !== 0) { + console.error(" ✗ staged package failed smoke import"); + process.exit(1); + } + + // 10. Optional: also run the full test suite. + if (check) { + console.log("\n› running full test suite"); + const tr = spawnSync("npm", ["test"], { + cwd: ROOT, + stdio: "inherit", + shell: true, + }); + if (tr.status !== 0) { + console.error(" ✗ tests failed"); + process.exit(1); + } + } + + // 11. Report. + const wasmStat = await stat( + join(STAGING, "src", "_runtime", "dhamaka-runtime.wasm"), + ); + console.log(` + ✓ staged at ${STAGING} + package: ${publishedPkg.name}@${publishedPkg.version} + runtime: ${Math.round(wasmStat.size / 1024)} KB wasm bundled + + publish it with: + npm publish ${STAGING} --access public +`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 1fb671c4df5ddee7a9a31b9fff10596bf8cb9796 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:30:45 +0000 Subject: [PATCH 06/29] Add docs/GOALS.md: the north-star document for the pivot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write down what I'm actually building, what I'm explicitly not building, and why the shape of the product is a reflex layer for every input field on the web — not a browser LLM runtime. Key points captured: - The pivot: this is a smart-field SDK, not a chat LLM. The runtime space (Transformers.js, WebLLM, wllama, window.ai) is crowded and I lose every comparison there. The empty box is the cross-browser, developer-facing, task-SDK layer above the runtime. Nobody ships it. - The use cases, concrete: city→state autofill, contextual spellcheck, smart paste, cross-field inference, tone rewriting, format validation, per-keystroke tab completion. Every one impossible as a server-side product because the latency and per-call economics kill it. Every one trivial locally because calls are free. - Competitive map limited to on-device players only. Cloud AI is not a competitor — the latency kills it. - Technical principles: SDK is the product, runtime is a dependency; calls are free, call often; task-specific beats general; rules first, model second; resident not on-demand; shared across tabs and sites; cross-browser is non-negotiable; browser extension is v1 not phase 2; demos over docs. - v0.1 scope: SharedWorker + Transformers.js + SmartField web component + three working playground demos (address autofill, spellcheck, smart paste) + window.ai adapter + cache adapter + browser extension with real inference wiring. No Rust runtime in v0.1 — it stays as reference code. - Non-goals, explicit: not a chat SDK, not a general-purpose runtime, not competing on tok/s, not a new inference engine, not a server product, not fighting window.ai (use it as a fast path instead). - Open questions flagged: whether SmolLM2-135M meets a 50 ms per-keystroke budget in pure WASM, whether Transformers.js's customCache hook is complete, whether window.ai is stable enough to depend on, how much of "smart paste" can be done without a model at all. - Name: Dhamaka is wrong for this product and will be replaced before the first public release. Hunch is my current top pick. --- docs/GOALS.md | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/GOALS.md diff --git a/docs/GOALS.md b/docs/GOALS.md new file mode 100644 index 0000000..0f52f83 --- /dev/null +++ b/docs/GOALS.md @@ -0,0 +1,339 @@ +# Goals + +> The north-star document for this project. Written to keep me honest about +> what I'm building, what I'm *not* building, and what the winning shape of +> the product actually is. + +## The one-liner + +**A cross-browser JavaScript SDK that gives every `` and ` + + + + + + + + + + diff --git a/packages/playground/public/app.js b/packages/playground/public/chat.js similarity index 100% rename from packages/playground/public/app.js rename to packages/playground/public/chat.js diff --git a/packages/playground/public/demos/autofill.html b/packages/playground/public/demos/autofill.html new file mode 100644 index 0000000..24dd8d2 --- /dev/null +++ b/packages/playground/public/demos/autofill.html @@ -0,0 +1,120 @@ + + + + + Dhamaka · address autofill demo + + + + + + +
    + ← all demos +

    address autofill

    +

    + Type a city below. Every keystroke fires the city-to-state task, + which hits a static gazetteer first (instant) and falls back to a + fuzzy match for typos. State, country, timezone, and currency + populate live. No network, no debouncing, no spinner. +

    + Try: San Francisco, sf, Tokyo, + Berlin, Bangalore, San Francsico + (typo). +

    + +
    +

    shipping address

    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + source: + +  ·  + confidence: + + resolved in — ms +
    +
    + +
    +

    what's happening

    +
    +  oninput → SmartField → runTask("city-to-state")
    +       │
    +       ├─ rules: gazetteer exact match?  ← 0.01 ms
    +       ├─ fuzzy: Levenshtein ≤ 2 match?  ← 0.5 ms
    +       └─ model: LLM fallback            ← 50 ms (not needed here)
    +
    +  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/paste.html b/packages/playground/public/demos/paste.html new file mode 100644 index 0000000..6332321 --- /dev/null +++ b/packages/playground/public/demos/paste.html @@ -0,0 +1,125 @@ + + + + + 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..27cacbd --- /dev/null +++ b/packages/playground/public/demos/spellcheck.html @@ -0,0 +1,122 @@ + + + + + Dhamaka · contextual spellcheck demo + + + + + + +
    + ← all demos +

    contextual spellcheck

    +

    + Type or paste some prose into the textarea below. The SmartText wrapper + watches every keystroke and flags not just misspellings but homophone + confusions that depend on context — the class of mistake that a plain + dictionary spellchecker can't catch. +

    + Try: I'll see you their tomorrow, + I recieve your message, + Its been a long day, + Your welcome, + Alot of the time. +

    + +
    +

    draft

    + +
    no issues yet
    +
    + + suggestions: + 0 +  ·  + source: + + resolved in — ms +
    +
    + +
    +

    what's happening

    +
    +  oninput → SmartText → runTask("spellcheck")
    +       │
    +       ├─ rules: known-misspelling map           ← 0.1 ms
    +       ├─ rules: homophone-in-context regexes    ← 0.2 ms
    +       └─ model: distilBERT masked LM fallback   ← (planned)
    +
    +  Every suggestion is clickable — apply it back into the textarea.
    +        
    +

    + Click any suggestion chip to apply the fix directly. The edit + dispatches a synthetic input event, so the next re-check runs + immediately. +

    +
    +
    + + + + diff --git a/packages/playground/public/index.html b/packages/playground/public/index.html index e966d08..cfdc3f4 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,90 +19,77 @@ |____/|_| |_|\__,_|_| |_| |_|\__,_|_|\_\__,_|
    - browser-native LLM · download once · run anywhere + reflex layer for every input · on-device · zero latency
    -
    - - booting… -
    - + new SmartText(textareaEl) + -
    -
    -
    -
    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. -
    -
    -
    + +
    +

    Smart paste

    +

    + Paste a business card blob — name, email, phone, company, website + split into the right fields, synchronously. +

    + attachSmartPaste(form) +
    +
    -
    -
    -
    -
    +
    +

    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/runtime/src/factory.js b/packages/runtime/src/factory.js index febdcb8..55e7718 100644 --- a/packages/runtime/src/factory.js +++ b/packages/runtime/src/factory.js @@ -1,25 +1,31 @@ -// 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, shared, fastest) +// 2. wasm — our compiled Rust runtime +// 3. mock — deterministic stand-in for Node / tests / dev +// +// Callers can force a specific backend with `{ backend: "mock" | "wasm" | "window-ai" }`. import { MockEngine } from "./mock-engine.js"; import { WasmEngine } from "./wasm-engine.js"; +import { WindowAiBackend } from "./window-ai-backend.js"; /** * @param {object} options - * @param {"auto"|"mock"|"wasm"} [options.backend="auto"] + * @param {"auto"|"mock"|"wasm"|"window-ai"} [options.backend="auto"] * @param {string} [options.wasmUrl] + * @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); - // auto: - // - if a wasmUrl is explicitly configured, use WasmEngine - // - else in a browser where WebAssembly + fetch exist, use WasmEngine - // with the default wasm path (served by the hub at /runtime/…) - // - else (Node, or WebAssembly missing) fall back to MockEngine so tests - // and CLI workflows still run + // auto: prefer window.ai → wasm → mock. + if (WindowAiBackend.isAvailable()) return new WindowAiBackend(options); if (options.wasmUrl) return new WasmEngine(options); if ( typeof WebAssembly !== "undefined" && diff --git a/packages/runtime/src/index.js b/packages/runtime/src/index.js index 12a1e0d..a5d3ba0 100644 --- a/packages/runtime/src/index.js +++ b/packages/runtime/src/index.js @@ -1,11 +1,13 @@ // @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 { Tokenizer } from "./tokenizer.js"; export { createEngine } from "./factory.js"; 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/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..adca1e1 --- /dev/null +++ b/packages/sdk/src/data/cities.js @@ -0,0 +1,255 @@ +// A small gazetteer for the city-to-state task. This is deliberately +// not a full database — it's the "rules-first" fast path for the 80-90% +// of real inputs that match a known major city. The LLM fallback handles +// the long tail (villages, misspellings, abbreviations). +// +// Format: one entry per city. Columns: +// name canonical display name +// aliases alternate spellings / abbreviations the user might type +// state ISO-3166-2 subdivision code (US/CA/AU) or full name +// stateName human-readable state/province name +// country ISO-3166-1 alpha-2 +// countryName human-readable country name +// tz IANA time zone +// currency ISO-4217 +// +// Real product would ship ~10k entries. This ships a curated ~100 for +// the demo. + +export const CITIES = [ + // ── United States ──────────────────────────────────────────────────── + { name: "San Francisco", aliases: ["sf", "san fran", "frisco"], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Los Angeles", aliases: ["la"], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "San Diego", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "San Jose", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Sacramento", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Oakland", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Berkeley", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Palo Alto", aliases: [], state: "CA", stateName: "California", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "New York", aliases: ["nyc", "new york city"], state: "NY", stateName: "New York", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Brooklyn", aliases: [], state: "NY", stateName: "New York", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Buffalo", aliases: [], state: "NY", stateName: "New York", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Albany", aliases: [], state: "NY", stateName: "New York", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Chicago", aliases: ["chi-town", "chitown"], state: "IL", stateName: "Illinois", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Springfield", aliases: [], state: "IL", stateName: "Illinois", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Houston", aliases: [], state: "TX", stateName: "Texas", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Austin", aliases: [], state: "TX", stateName: "Texas", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Dallas", aliases: [], state: "TX", stateName: "Texas", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "San Antonio", aliases: [], state: "TX", stateName: "Texas", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "El Paso", aliases: [], state: "TX", stateName: "Texas", country: "US", countryName: "United States", tz: "America/Denver", currency: "USD" }, + { name: "Seattle", aliases: [], state: "WA", stateName: "Washington", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Tacoma", aliases: [], state: "WA", stateName: "Washington", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Spokane", aliases: [], state: "WA", stateName: "Washington", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Portland", aliases: [], state: "OR", stateName: "Oregon", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Eugene", aliases: [], state: "OR", stateName: "Oregon", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Salem", aliases: [], state: "OR", stateName: "Oregon", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Boston", aliases: [], state: "MA", stateName: "Massachusetts", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Cambridge", aliases: [], state: "MA", stateName: "Massachusetts", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Worcester", aliases: [], state: "MA", stateName: "Massachusetts", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Miami", aliases: [], state: "FL", stateName: "Florida", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Orlando", aliases: [], state: "FL", stateName: "Florida", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Tampa", aliases: [], state: "FL", stateName: "Florida", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Jacksonville", aliases: [], state: "FL", stateName: "Florida", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Atlanta", aliases: [], state: "GA", stateName: "Georgia", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Savannah", aliases: [], state: "GA", stateName: "Georgia", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Denver", aliases: [], state: "CO", stateName: "Colorado", country: "US", countryName: "United States", tz: "America/Denver", currency: "USD" }, + { name: "Boulder", aliases: [], state: "CO", stateName: "Colorado", country: "US", countryName: "United States", tz: "America/Denver", currency: "USD" }, + { name: "Colorado Springs", aliases: [], state: "CO", stateName: "Colorado", country: "US", countryName: "United States", tz: "America/Denver", currency: "USD" }, + { name: "Phoenix", aliases: [], state: "AZ", stateName: "Arizona", country: "US", countryName: "United States", tz: "America/Phoenix", currency: "USD" }, + { name: "Tucson", aliases: [], state: "AZ", stateName: "Arizona", country: "US", countryName: "United States", tz: "America/Phoenix", currency: "USD" }, + { name: "Scottsdale", aliases: [], state: "AZ", stateName: "Arizona", country: "US", countryName: "United States", tz: "America/Phoenix", currency: "USD" }, + { name: "Las Vegas", aliases: ["vegas"], state: "NV", stateName: "Nevada", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Reno", aliases: [], state: "NV", stateName: "Nevada", country: "US", countryName: "United States", tz: "America/Los_Angeles", currency: "USD" }, + { name: "Philadelphia", aliases: ["philly"], state: "PA", stateName: "Pennsylvania", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Pittsburgh", aliases: [], state: "PA", stateName: "Pennsylvania", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Detroit", aliases: [], state: "MI", stateName: "Michigan", country: "US", countryName: "United States", tz: "America/Detroit", currency: "USD" }, + { name: "Ann Arbor", aliases: [], state: "MI", stateName: "Michigan", country: "US", countryName: "United States", tz: "America/Detroit", currency: "USD" }, + { name: "Minneapolis", aliases: [], state: "MN", stateName: "Minnesota", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Saint Paul", aliases: ["st paul", "st. paul"], state: "MN", stateName: "Minnesota", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Washington", aliases: ["dc", "washington dc", "d.c."], state: "DC", stateName: "District of Columbia", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Baltimore", aliases: [], state: "MD", stateName: "Maryland", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Honolulu", aliases: [], state: "HI", stateName: "Hawaii", country: "US", countryName: "United States", tz: "Pacific/Honolulu", currency: "USD" }, + { name: "Anchorage", aliases: [], state: "AK", stateName: "Alaska", country: "US", countryName: "United States", tz: "America/Anchorage", currency: "USD" }, + { name: "New Orleans", aliases: ["nola"], state: "LA", stateName: "Louisiana", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Nashville", aliases: [], state: "TN", stateName: "Tennessee", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Memphis", aliases: [], state: "TN", stateName: "Tennessee", country: "US", countryName: "United States", tz: "America/Chicago", currency: "USD" }, + { name: "Charlotte", aliases: [], state: "NC", stateName: "North Carolina", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Raleigh", aliases: [], state: "NC", stateName: "North Carolina", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Charleston", aliases: [], state: "SC", stateName: "South Carolina", country: "US", countryName: "United States", tz: "America/New_York", currency: "USD" }, + { name: "Salt Lake City", aliases: ["slc"], state: "UT", stateName: "Utah", country: "US", countryName: "United States", tz: "America/Denver", currency: "USD" }, + + // ── Canada ─────────────────────────────────────────────────────────── + { name: "Toronto", aliases: [], state: "ON", stateName: "Ontario", country: "CA", countryName: "Canada", tz: "America/Toronto", currency: "CAD" }, + { name: "Ottawa", aliases: [], state: "ON", stateName: "Ontario", country: "CA", countryName: "Canada", tz: "America/Toronto", currency: "CAD" }, + { name: "Vancouver", aliases: [], state: "BC", stateName: "British Columbia", country: "CA", countryName: "Canada", tz: "America/Vancouver", currency: "CAD" }, + { name: "Victoria", aliases: [], state: "BC", stateName: "British Columbia", country: "CA", countryName: "Canada", tz: "America/Vancouver", currency: "CAD" }, + { name: "Montreal", aliases: [], state: "QC", stateName: "Quebec", country: "CA", countryName: "Canada", tz: "America/Montreal", currency: "CAD" }, + { name: "Quebec City", aliases: [], state: "QC", stateName: "Quebec", country: "CA", countryName: "Canada", tz: "America/Montreal", currency: "CAD" }, + { name: "Calgary", aliases: [], state: "AB", stateName: "Alberta", country: "CA", countryName: "Canada", tz: "America/Edmonton", currency: "CAD" }, + { name: "Edmonton", aliases: [], state: "AB", stateName: "Alberta", country: "CA", countryName: "Canada", tz: "America/Edmonton", currency: "CAD" }, + { name: "Winnipeg", aliases: [], state: "MB", stateName: "Manitoba", country: "CA", countryName: "Canada", tz: "America/Winnipeg", currency: "CAD" }, + { name: "Halifax", aliases: [], state: "NS", stateName: "Nova Scotia", country: "CA", countryName: "Canada", tz: "America/Halifax", currency: "CAD" }, + + // ── United Kingdom ────────────────────────────────────────────────── + { name: "London", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Manchester", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Birmingham", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Liverpool", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Leeds", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Bristol", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Oxford", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Cambridge", aliases: [], state: "ENG", stateName: "England", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Edinburgh", aliases: [], state: "SCT", stateName: "Scotland", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Glasgow", aliases: [], state: "SCT", stateName: "Scotland", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Cardiff", aliases: [], state: "WLS", stateName: "Wales", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + { name: "Belfast", aliases: [], state: "NIR", stateName: "Northern Ireland", country: "GB", countryName: "United Kingdom", tz: "Europe/London", currency: "GBP" }, + + // ── Europe ────────────────────────────────────────────────────────── + { name: "Paris", aliases: [], state: "IDF", stateName: "Île-de-France", country: "FR", countryName: "France", tz: "Europe/Paris", currency: "EUR" }, + { name: "Lyon", aliases: [], state: "ARA", stateName: "Auvergne-Rhône-Alpes", country: "FR", countryName: "France", tz: "Europe/Paris", currency: "EUR" }, + { name: "Marseille", aliases: [], state: "PAC", stateName: "Provence-Alpes-Côte d'Azur", country: "FR", countryName: "France", tz: "Europe/Paris", currency: "EUR" }, + { name: "Berlin", aliases: [], state: "BE", stateName: "Berlin", country: "DE", countryName: "Germany", tz: "Europe/Berlin", currency: "EUR" }, + { name: "Munich", aliases: ["münchen"], state: "BY", stateName: "Bavaria", country: "DE", countryName: "Germany", tz: "Europe/Berlin", currency: "EUR" }, + { name: "Hamburg", aliases: [], state: "HH", stateName: "Hamburg", country: "DE", countryName: "Germany", tz: "Europe/Berlin", currency: "EUR" }, + { name: "Frankfurt", aliases: [], state: "HE", stateName: "Hesse", country: "DE", countryName: "Germany", tz: "Europe/Berlin", currency: "EUR" }, + { name: "Cologne", aliases: ["köln", "koln"], state: "NW", stateName: "North Rhine-Westphalia", country: "DE", countryName: "Germany", tz: "Europe/Berlin", currency: "EUR" }, + { name: "Madrid", aliases: [], state: "MD", stateName: "Community of Madrid", country: "ES", countryName: "Spain", tz: "Europe/Madrid", currency: "EUR" }, + { name: "Barcelona", aliases: [], state: "CT", stateName: "Catalonia", country: "ES", countryName: "Spain", tz: "Europe/Madrid", currency: "EUR" }, + { name: "Rome", aliases: ["roma"], state: "LZ", stateName: "Lazio", country: "IT", countryName: "Italy", tz: "Europe/Rome", currency: "EUR" }, + { name: "Milan", aliases: ["milano"], state: "LM", stateName: "Lombardy", country: "IT", countryName: "Italy", tz: "Europe/Rome", currency: "EUR" }, + { name: "Amsterdam", aliases: [], state: "NH", stateName: "North Holland", country: "NL", countryName: "Netherlands", tz: "Europe/Amsterdam", currency: "EUR" }, + { name: "Brussels", aliases: ["bruxelles"], state: "BRU", stateName: "Brussels", country: "BE", countryName: "Belgium", tz: "Europe/Brussels", currency: "EUR" }, + { name: "Vienna", aliases: ["wien"], state: "W", stateName: "Vienna", country: "AT", countryName: "Austria", tz: "Europe/Vienna", currency: "EUR" }, + { name: "Zurich", aliases: ["zürich"], state: "ZH", stateName: "Zürich", country: "CH", countryName: "Switzerland", tz: "Europe/Zurich", currency: "CHF" }, + { name: "Geneva", aliases: ["genève"], state: "GE", stateName: "Geneva", country: "CH", countryName: "Switzerland", tz: "Europe/Zurich", currency: "CHF" }, + { name: "Stockholm", aliases: [], state: "AB", stateName: "Stockholm", country: "SE", countryName: "Sweden", tz: "Europe/Stockholm", currency: "SEK" }, + { name: "Copenhagen", aliases: ["københavn"], state: "84", stateName: "Capital Region", country: "DK", countryName: "Denmark", tz: "Europe/Copenhagen", currency: "DKK" }, + { name: "Oslo", aliases: [], state: "03", stateName: "Oslo", country: "NO", countryName: "Norway", tz: "Europe/Oslo", currency: "NOK" }, + { name: "Helsinki", aliases: [], state: "18", stateName: "Uusimaa", country: "FI", countryName: "Finland", tz: "Europe/Helsinki", currency: "EUR" }, + { name: "Dublin", aliases: [], state: "L", stateName: "Leinster", country: "IE", countryName: "Ireland", tz: "Europe/Dublin", currency: "EUR" }, + { name: "Lisbon", aliases: ["lisboa"], state: "11", stateName: "Lisbon", country: "PT", countryName: "Portugal", tz: "Europe/Lisbon", currency: "EUR" }, + { name: "Athens", aliases: [], state: "I", stateName: "Attica", country: "GR", countryName: "Greece", tz: "Europe/Athens", currency: "EUR" }, + { name: "Warsaw", aliases: ["warszawa"], state: "MZ", stateName: "Masovia", country: "PL", countryName: "Poland", tz: "Europe/Warsaw", currency: "PLN" }, + { name: "Prague", aliases: ["praha"], state: "PR", stateName: "Prague", country: "CZ", countryName: "Czech Republic", tz: "Europe/Prague", currency: "CZK" }, + { name: "Budapest", aliases: [], state: "BU", stateName: "Budapest", country: "HU", countryName: "Hungary", tz: "Europe/Budapest", currency: "HUF" }, + + // ── Asia / Pacific ────────────────────────────────────────────────── + { name: "Tokyo", aliases: [], state: "13", stateName: "Tokyo", country: "JP", countryName: "Japan", tz: "Asia/Tokyo", currency: "JPY" }, + { name: "Osaka", aliases: [], state: "27", stateName: "Osaka", country: "JP", countryName: "Japan", tz: "Asia/Tokyo", currency: "JPY" }, + { name: "Kyoto", aliases: [], state: "26", stateName: "Kyoto", country: "JP", countryName: "Japan", tz: "Asia/Tokyo", currency: "JPY" }, + { name: "Seoul", aliases: [], state: "11", stateName: "Seoul", country: "KR", countryName: "South Korea", tz: "Asia/Seoul", currency: "KRW" }, + { name: "Beijing", aliases: ["peking"], state: "BJ", stateName: "Beijing", country: "CN", countryName: "China", tz: "Asia/Shanghai", currency: "CNY" }, + { name: "Shanghai", aliases: [], state: "SH", stateName: "Shanghai", country: "CN", countryName: "China", tz: "Asia/Shanghai", currency: "CNY" }, + { name: "Hong Kong", aliases: ["hk"], state: "HK", stateName: "Hong Kong", country: "HK", countryName: "Hong Kong", tz: "Asia/Hong_Kong", currency: "HKD" }, + { name: "Singapore", aliases: ["sg"], state: "", stateName: "", country: "SG", countryName: "Singapore", tz: "Asia/Singapore", currency: "SGD" }, + { name: "Taipei", aliases: [], state: "TPE", stateName: "Taipei", country: "TW", countryName: "Taiwan", tz: "Asia/Taipei", currency: "TWD" }, + { name: "Bangkok", aliases: [], state: "10", stateName: "Bangkok", country: "TH", countryName: "Thailand", tz: "Asia/Bangkok", currency: "THB" }, + { name: "Kuala Lumpur", aliases: ["kl"], state: "14", stateName: "Kuala Lumpur", country: "MY", countryName: "Malaysia", tz: "Asia/Kuala_Lumpur", currency: "MYR" }, + { name: "Jakarta", aliases: [], state: "JK", stateName: "Jakarta", country: "ID", countryName: "Indonesia", tz: "Asia/Jakarta", currency: "IDR" }, + { name: "Manila", aliases: [], state: "00", stateName: "Metro Manila", country: "PH", countryName: "Philippines", tz: "Asia/Manila", currency: "PHP" }, + { name: "Mumbai", aliases: ["bombay"], state: "MH", stateName: "Maharashtra", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Delhi", aliases: ["new delhi"], state: "DL", stateName: "Delhi", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Bangalore", aliases: ["bengaluru", "blr"], state: "KA", stateName: "Karnataka", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Chennai", aliases: ["madras"], state: "TN", stateName: "Tamil Nadu", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Kolkata", aliases: ["calcutta"], state: "WB", stateName: "West Bengal", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Hyderabad", aliases: [], state: "TG", stateName: "Telangana", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Pune", aliases: [], state: "MH", stateName: "Maharashtra", country: "IN", countryName: "India", tz: "Asia/Kolkata", currency: "INR" }, + { name: "Dubai", aliases: [], state: "DU", stateName: "Dubai", country: "AE", countryName: "United Arab Emirates", tz: "Asia/Dubai", currency: "AED" }, + { name: "Abu Dhabi", aliases: [], state: "AZ", stateName: "Abu Dhabi", country: "AE", countryName: "United Arab Emirates", tz: "Asia/Dubai", currency: "AED" }, + { name: "Tel Aviv", aliases: [], state: "TA", stateName: "Tel Aviv", country: "IL", countryName: "Israel", tz: "Asia/Jerusalem", currency: "ILS" }, + { name: "Sydney", aliases: [], state: "NSW", stateName: "New South Wales", country: "AU", countryName: "Australia", tz: "Australia/Sydney", currency: "AUD" }, + { name: "Melbourne", aliases: [], state: "VIC", stateName: "Victoria", country: "AU", countryName: "Australia", tz: "Australia/Melbourne", currency: "AUD" }, + { name: "Brisbane", aliases: [], state: "QLD", stateName: "Queensland", country: "AU", countryName: "Australia", tz: "Australia/Brisbane", currency: "AUD" }, + { name: "Perth", aliases: [], state: "WA", stateName: "Western Australia", country: "AU", countryName: "Australia", tz: "Australia/Perth", currency: "AUD" }, + { name: "Auckland", aliases: [], state: "AUK", stateName: "Auckland", country: "NZ", countryName: "New Zealand", tz: "Pacific/Auckland", currency: "NZD" }, + { name: "Wellington", aliases: [], state: "WGN", stateName: "Wellington", country: "NZ", countryName: "New Zealand", tz: "Pacific/Auckland", currency: "NZD" }, + + // ── Latin America ─────────────────────────────────────────────────── + { name: "Mexico City", aliases: ["cdmx", "ciudad de méxico"], state: "CMX", stateName: "Mexico City", country: "MX", countryName: "Mexico", tz: "America/Mexico_City", currency: "MXN" }, + { name: "Guadalajara", aliases: [], state: "JAL", stateName: "Jalisco", country: "MX", countryName: "Mexico", tz: "America/Mexico_City", currency: "MXN" }, + { name: "Monterrey", aliases: [], state: "NLE", stateName: "Nuevo León", country: "MX", countryName: "Mexico", tz: "America/Monterrey", currency: "MXN" }, + { name: "São Paulo", aliases: ["sao paulo"], state: "SP", stateName: "São Paulo", country: "BR", countryName: "Brazil", tz: "America/Sao_Paulo", currency: "BRL" }, + { name: "Rio de Janeiro", aliases: ["rio"], state: "RJ", stateName: "Rio de Janeiro", country: "BR", countryName: "Brazil", tz: "America/Sao_Paulo", currency: "BRL" }, + { name: "Brasília", aliases: ["brasilia"], state: "DF", stateName: "Federal District", country: "BR", countryName: "Brazil", tz: "America/Sao_Paulo", currency: "BRL" }, + { name: "Buenos Aires", aliases: [], state: "C", stateName: "Buenos Aires", country: "AR", countryName: "Argentina", tz: "America/Argentina/Buenos_Aires", currency: "ARS" }, + { name: "Santiago", aliases: [], state: "RM", stateName: "Santiago Metropolitan", country: "CL", countryName: "Chile", tz: "America/Santiago", currency: "CLP" }, + { name: "Bogotá", aliases: ["bogota"], state: "DC", stateName: "Bogotá", country: "CO", countryName: "Colombia", tz: "America/Bogota", currency: "COP" }, + { name: "Lima", aliases: [], state: "LMA", stateName: "Lima", country: "PE", countryName: "Peru", tz: "America/Lima", currency: "PEN" }, + + // ── Africa ────────────────────────────────────────────────────────── + { name: "Cairo", aliases: [], state: "C", stateName: "Cairo", country: "EG", countryName: "Egypt", tz: "Africa/Cairo", currency: "EGP" }, + { name: "Lagos", aliases: [], state: "LA", stateName: "Lagos", country: "NG", countryName: "Nigeria", tz: "Africa/Lagos", currency: "NGN" }, + { name: "Nairobi", aliases: [], state: "30", stateName: "Nairobi", country: "KE", countryName: "Kenya", tz: "Africa/Nairobi", currency: "KES" }, + { name: "Cape Town", aliases: [], state: "WC", stateName: "Western Cape", country: "ZA", countryName: "South Africa", tz: "Africa/Johannesburg", currency: "ZAR" }, + { name: "Johannesburg", aliases: ["joburg", "jhb"], state: "GP", stateName: "Gauteng", country: "ZA", countryName: "South Africa", tz: "Africa/Johannesburg", currency: "ZAR" }, +]; + +// 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); + + let best = null; + let bestDist = maxDistance + 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) > maxDistance) 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/index.js b/packages/sdk/src/index.js index 8e89006..4062a34 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,43 @@ import { createEngine } from "@dhamaka/runtime"; import { HubClient } from "./hub-client.js"; import { Chat } from "./chat.js"; +// ─── the new surface (the pivot) ────────────────────────────────────── + +export { SmartField } from "./smart-field.js"; +export { SmartForm } from "./smart-form.js"; +export { SmartText } from "./smart-text.js"; +export { attachSmartPaste } from "./paste-extract.js"; +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,22 +63,18 @@ export class Dhamaka { return instance; } - /** @param {string} modelId @param {DhamakaLoadOptions} options */ constructor(modelId, options) { this.modelId = modelId; this.options = options; const hubUrl = options.hubUrl ?? DEFAULT_HUB_URL; this.hub = new HubClient({ hubUrl }); - // The WASM runtime binary lives on the hub origin at /runtime/…, same - // place the hub serves model weights from. Resolve it against the hub - // URL so the fetch works in development (http://localhost:5174/…) and - // production (https://hub.dhamaka.dev/…) without config. + let wasmUrl = options.wasmUrl; if (!wasmUrl && typeof URL !== "undefined") { try { wasmUrl = new URL("runtime/dhamaka-runtime.wasm", hubUrl).href; } catch { - // fall through — createEngine will degrade to MockEngine in Node + /* fall through */ } } this.engine = createEngine({ @@ -68,42 +92,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, @@ -113,12 +117,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/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..22e9e23 --- /dev/null +++ b/packages/sdk/src/reflex.js @@ -0,0 +1,105 @@ +// 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"} [options.backend] + * @param {string} [options.wasmUrl] + * @param {string} [options.systemPrompt] + * @param {object} [options.entry] Model manifest entry hint + */ +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..7f36db6 --- /dev/null +++ b/packages/sdk/src/smart-form.js @@ -0,0 +1,122 @@ +// 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 || !detail.result.fields) 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 value = fields[resultKey]; + if (value == null || value === "") 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 + + this._programmatic = true; + try { + targetEl.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 diff --git a/packages/playground/public/chat.js b/packages/playground/public/chat.js index 1e7f23e..ff0cb56 100644 --- a/packages/playground/public/chat.js +++ b/packages/playground/public/chat.js @@ -2,9 +2,9 @@ // // Imports the SDK directly from source via the dev server's /sdk mount so you // can hack on it without any build step. In production you'd -// `import { Dhamaka } from "dhamaka"`. +// `import { Locus } from "locus"`. -import { Dhamaka } from "dhamaka"; +import { Locus } from "locus"; const HUB_URL = `http://localhost:${location.port === "5173" ? 5174 : 5174}/`; @@ -33,7 +33,7 @@ const els = { resetBtn: document.getElementById("reset-btn"), }; -/** @type {import("/sdk/index.js").Dhamaka | null} */ +/** @type {import("/sdk/index.js").Locus | null} */ let llm = null; let chat = null; let abortController = null; @@ -100,7 +100,7 @@ async function loadModel() { showProgress(true, 0, "contacting hub…"); try { - llm = await Dhamaka.load(modelId, { + llm = await Locus.load(modelId, { hubUrl: HUB_URL, onProgress: (p) => { if (p.total) { diff --git a/packages/playground/public/demos/autofill.html b/packages/playground/public/demos/autofill.html index 24dd8d2..f80f6e8 100644 --- a/packages/playground/public/demos/autofill.html +++ b/packages/playground/public/demos/autofill.html @@ -2,17 +2,17 @@ - Dhamaka · address autofill demo + Locus · address autofill demo @@ -83,7 +83,7 @@

    what's happening

    @@ -101,7 +101,7 @@

    what's happening

    @@ -69,7 +69,7 @@

    what's happening

    diff --git a/packages/extension/options.js b/packages/extension/options.js index 7c90d5c..2305435 100644 --- a/packages/extension/options.js +++ b/packages/extension/options.js @@ -20,7 +20,7 @@ function fmtDate(ms) { async function refresh() { const list = document.getElementById("list"); list.innerHTML = '
  • loading…
  • '; - chrome.runtime.sendMessage({ type: "locus:list" }, (response) => { + chrome.runtime.sendMessage({ type: "dhamaka:list" }, (response) => { if (chrome.runtime.lastError) { list.innerHTML = `
  • error: ${chrome.runtime.lastError.message}
  • `; return; @@ -45,7 +45,7 @@ async function refresh() { const btn = document.createElement("button"); btn.textContent = "evict"; btn.addEventListener("click", () => { - chrome.runtime.sendMessage({ type: "locus:delete", id: row.id }, refresh); + chrome.runtime.sendMessage({ type: "dhamaka:delete", id: row.id }, refresh); }); li.append(left, btn); list.appendChild(li); diff --git a/packages/extension/package.json b/packages/extension/package.json index 66a8f2a..f1d0e3b 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,7 @@ { - "name": "@locus/extension", + "name": "@dhamaka/extension", "version": "0.1.0", - "description": "The Locus browser extension. Stores models once per machine and serves them to every Locus-powered site via a content script bridge — sidestepping storage partitioning entirely.", + "description": "The Dhamaka browser extension. Stores models once per machine and serves them to every Dhamaka-powered site via a content script bridge — sidestepping storage partitioning entirely.", "type": "module", "private": true, "files": [ diff --git a/packages/hub/README.md b/packages/hub/README.md index e811c9a..6090f72 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -1,34 +1,34 @@ -# @locus/hub +# @dhamaka/hub The tiny static origin that makes "download once" possible. The hub is a single HTML page plus a single JS file. It's meant to live at -`https://hub.locus.dev` (or any single origin you control). Consumer sites +`https://hub.dhamaka.dev` (or any single origin you control). Consumer sites inject it as a hidden iframe and talk to it over `postMessage`. Because the iframe is always loaded from the same origin, its IndexedDB store is shared -across every Locus-powered site the user visits — which is the whole point. +across every Dhamaka-powered site the user visits — which is the whole point. ## Message protocol -All messages are plain objects with a `type` starting with `locus:`. +All messages are plain objects with a `type` starting with `dhamaka:`. ### From parent → hub | type | fields | description | |-------------------|---------------------------------------|---------------------------------------| -| `locus:ping` | `requestId` | health check | -| `locus:get` | `requestId`, `id`, `manifestUrl?` | get a model, downloading if missing | -| `locus:list` | `requestId` | list locally cached models | -| `locus:delete` | `requestId`, `id` | evict a model from local storage | +| `dhamaka:ping` | `requestId` | health check | +| `dhamaka:get` | `requestId`, `id`, `manifestUrl?` | get a model, downloading if missing | +| `dhamaka:list` | `requestId` | list locally cached models | +| `dhamaka:delete` | `requestId`, `id` | evict a model from local storage | ### From hub → parent | type | fields | |----------------------|---------------------------------------------------------| -| `locus:ready` | `version`, `origin` | -| `locus:progress` | `requestId`, `stage`, `artifact`, `received`, `total` | -| `locus:response` | `requestId`, plus result-specific fields | -| `locus:error` | `requestId`, `error` | +| `dhamaka:ready` | `version`, `origin` | +| `dhamaka:progress` | `requestId`, `stage`, `artifact`, `received`, `total` | +| `dhamaka:response` | `requestId`, plus result-specific fields | +| `dhamaka:error` | `requestId`, `error` | Model bytes are transferred as `ArrayBuffer`s using `postMessage` transferables, so parent ↔ hub hand-off is zero-copy. @@ -45,9 +45,9 @@ hub handles this by degrading gracefully: [Storage Access API](https://developer.mozilla.org/docs/Web/API/Storage_Access_API). 2. **Fallback** – per-origin IndexedDB in the consumer site. Still works, still private, still offline — just not shared across sites. -3. **Phase 2** – an optional Locus browser extension, which sidesteps +3. **Phase 2** – an optional Dhamaka browser extension, which sidesteps partitioning entirely and can serve every site on the user's machine from a single local model cache. -The SDK exposes `Locus.storage()` so an app can report to the user whether +The SDK exposes `Dhamaka.storage()` so an app can report to the user whether they got a shared-cache hit or a site-local one. diff --git a/packages/hub/package.json b/packages/hub/package.json index 07d9e37..79357db 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -1,7 +1,7 @@ { - "name": "@locus/hub", + "name": "@dhamaka/hub", "version": "0.1.0", - "description": "The Locus model hub: a tiny static origin that stores models once and shares them with every Locus-powered site via a postMessage bridge.", + "description": "The Dhamaka model hub: a tiny static origin that stores models once and shares them with every Dhamaka-powered site via a postMessage bridge.", "type": "module", "main": "public/hub.js", "files": [ diff --git a/packages/hub/public/hub.js b/packages/hub/public/hub.js index a569581..89ed72d 100644 --- a/packages/hub/public/hub.js +++ b/packages/hub/public/hub.js @@ -1,8 +1,8 @@ // ┌──────────────────────────────────────────────────────────────────────────┐ -// │ Locus Hub │ +// │ Dhamaka Hub │ // │ │ -// │ A tiny script that runs inside a hidden