diff --git a/libraries/llm/.gitignore b/libraries/llm/.gitignore
new file mode 100644
index 0000000..a7c4753
--- /dev/null
+++ b/libraries/llm/.gitignore
@@ -0,0 +1 @@
+types/
diff --git a/libraries/llm/README.md b/libraries/llm/README.md
new file mode 100644
index 0000000..0e7a68c
--- /dev/null
+++ b/libraries/llm/README.md
@@ -0,0 +1,116 @@
+# @patchwork/llm
+
+An LLM toolkit for Patchwork tools, extracted from `chat` and enriched with
+`rlm`'s teaching telemetry. It gives you:
+
+- **`popup()` / `dom()`** — a `
` model picker (Browser / OpenRouter
+ / Ollama + sampling parameters, prompts, and tools). `popup()` is framed
+ (header + Cancel/Done); `dom()` is the bare panel to embed. Writes the choice
+ to the user's **account settings doc**, so the model + API key are shared
+ across every tool and synced across devices.
+- **A refresh-surviving `SharedWorker`** that runs all three providers off the
+ main thread (cross-tab, survives reload — keyed by an optional `sessionKey`).
+- **A streaming API** (`generate` callback-style, `stream` async-iterator-style)
+ that carries **rich telemetry alongside the text** so you can build UIs that
+ show *how the model thinks*:
+ - **`prediction`** events — the model's top-k next-token distribution at each
+ step (`[{token, p}]`). Works for **local** (via a transformers.js
+ `logits_processor`) **and OpenRouter** (via `logprobs`/`top_logprobs`).
+ - **`stats`** events — prompt/gen token counts, time-to-first-token,
+ tokens/sec, and the exact decode settings used (`temperature`, `top_p`,
+ greedy, …).
+
+Plain vanilla JS, no build step. Its only npm dependency is
+`@inkandswitch/patchwork-providers` (the request/provide config plumbing);
+transformers.js — the model runtime — is imported from a CDN inside the worker,
+only when the local provider is used.
+
+## Install / consume
+
+The package lives at `libraries/llm` and is named `@patchwork/llm`.
+
+- **Bundled tools (vite):** add a resolve alias and the bundler inlines it,
+ worker included (vite understands `new URL("./worker.js", import.meta.url)`):
+
+ ```js
+ // vite.config.js
+ resolve: {
+ alias: {
+ "@patchwork/llm": fileURLToPath(new URL("../libraries/llm/index.js", import.meta.url)),
+ },
+ }
+ ```
+
+- **Bundleless tools:** import by relative path, or add `@patchwork/llm` to the
+ host importmap to share one copy across tools.
+
+## Usage
+
+```js
+import { popup, stream, generate, readConfig } from "@patchwork/llm"
+
+// 1. Let the user choose a model / paste their OpenRouter key.
+const el = popup()
+document.body.append(el)
+el.showPopover()
+await el.result // resolves to the config on close (null if cancelled)
+
+// 2a. Stream with telemetry (async iterator):
+let text = ""
+for await (const ev of stream(messages, { topk: 5 })) {
+ switch (ev.type) {
+ case "status": setStatus(ev.message); break // model loading…
+ case "token": text += ev.delta; render(text); break
+ case "prediction": renderCandidates(ev.step, ev.candidates); break
+ case "stats": renderStats(ev); break // ttftMs, tokPerSec, decode…
+ case "done": finish(ev.text); break
+ }
+}
+
+// 2b. …or callback style:
+const { text, stats } = await generate(messages, {
+ topk: 5,
+ temperature: 0.7,
+ onToken: (delta, full) => render(full),
+ onPrediction: (candidates, step) => renderCandidates(step, candidates),
+ onStats: (s) => renderStats(s),
+ onStatus: (m) => setStatus(m),
+ signal, // AbortSignal
+})
+```
+
+### Config (on the account doc)
+
+Everything lives under `accountDoc.llm`:
+
+```js
+{
+ provider: "local" | "openrouter" | "ollama",
+ temperature: 0.7,
+ local: { model },
+ openrouter: { apiKey, model, contextLength, maxCompletionTokens },
+ ollama: { url, model },
+}
+```
+
+`readConfig()` / `writeConfig(patch)` read/write it (defaulting missing fields);
+`popup()` / `dom()` are the UI over them.
+
+### Resume after refresh
+
+Pass a stable `sessionKey` (e.g. a doc URL) to `generate`/`stream`; after a
+reload, `resume(sessionKey, { onToken, onDone })` re-attaches to the still-running
+stream in the worker.
+
+## Events reference
+
+| event | fields | local | openrouter | ollama |
+|--------------|------------------------------------------------------------------|:-----:|:----------:|:------:|
+| `token` | `delta`, `text` | ✓ | ✓ | ✓ |
+| `prediction` | `step`, `candidates: [{token, p}]` | ✓ | ✓ | — |
+| `stats` | `promptTokens`, `genTokens`, `ttftMs`, `totalMs`, `tokPerSec`, `decode` | ✓ | ✓ | ✓* |
+| `status` | `message` (model download / shader compile) | ✓ | — | — |
+| `done` | `text`, `stats` | ✓ | ✓ | ✓ |
+
+\* Ollama stats come from its final `done` chunk (`eval_count`, `eval_duration`).
+OpenRouter needs a model that supports `logprobs` for `prediction` events.
diff --git a/libraries/llm/builtin.js b/libraries/llm/builtin.js
new file mode 100644
index 0000000..d2c7490
--- /dev/null
+++ b/libraries/llm/builtin.js
@@ -0,0 +1,135 @@
+/**
+ * Chrome built-in AI (the Prompt API — on-device Gemini Nano).
+ *
+ * Runs on the MAIN thread: the `LanguageModel` global is a window API, not a
+ * worker one, so this path bypasses the SharedWorker. No next-token logprobs are
+ * exposed, so `predict()` (the typing popup) doesn't work with built-in —
+ * generation + streaming do.
+ */
+
+function getLM() {
+ if (typeof self === "undefined") return null
+ return self.LanguageModel || (self.ai && self.ai.languageModel) || null
+}
+
+/** Is the Prompt API present at all (i.e. show the option)? */
+export function builtinSupported() {
+ return !!getLM()
+}
+
+/** "available" | "downloadable" | "downloading" | "unavailable" */
+export async function builtinAvailability() {
+ const LM = getLM()
+ if (!LM) return "unavailable"
+ try {
+ if (LM.availability) return await LM.availability()
+ if (LM.capabilities) {
+ const c = await LM.capabilities()
+ return c?.available === "readily"
+ ? "available"
+ : c?.available === "after-download"
+ ? "downloadable"
+ : "unavailable"
+ }
+ } catch {}
+ return "unavailable"
+}
+
+/** @param {import("./config.js").ChatMessage[]} messages */
+function messagesToText(messages) {
+ return messages
+ .filter((m) => m.role !== "system")
+ .map((m) => m.content)
+ .join("\n\n")
+}
+
+/** @param {ReadableStream
} stream */
+async function* readStream(stream) {
+ const reader = stream.getReader()
+ try {
+ while (true) {
+ const {done, value} = await reader.read()
+ if (done) break
+ yield value
+ }
+ } finally {
+ try {
+ reader.releaseLock()
+ } catch {}
+ }
+}
+
+/**
+ * @typedef {Object} BuiltinOpts
+ * @property {number} [temperature]
+ * @property {number} [topK]
+ * @property {string} [system]
+ * @property {(delta: string, full: string) => void} [onToken]
+ * @property {(status: string) => void} [onStatus]
+ * @property {AbortSignal} [signal]
+ */
+
+/**
+ * Generate via the Prompt API. `onToken(delta, full)` per chunk; returns full
+ * text. Handles both the old (cumulative chunk) and new (delta chunk) shapes.
+ * @param {string|import("./config.js").ChatMessage[]} input
+ * @param {BuiltinOpts} [opts]
+ */
+export async function builtinGenerate(input, opts = {}) {
+ const {temperature, topK, system, onToken, onStatus, signal} = opts
+ const LM = getLM()
+ if (!LM)
+ throw new Error(
+ "Built-in AI isn't available here (needs Chrome with the Prompt API)."
+ )
+ /** @type {any} */
+ const createOpts = {}
+ if (typeof temperature === "number") {
+ createOpts.temperature = Math.min(2, Math.max(0, temperature))
+ createOpts.topK = topK && topK > 0 ? topK : 8 // Prompt API needs topK alongside temperature
+ }
+ if (system) createOpts.initialPrompts = [{role: "system", content: system}]
+ createOpts.monitor = (/** @type {any} */ m) => {
+ try {
+ m.addEventListener("downloadprogress", (/** @type {any} */ e) =>
+ onStatus?.(
+ "Downloading built-in model… " + Math.round((e.loaded || 0) * 100) + "%"
+ )
+ )
+ } catch {}
+ }
+ let session
+ try {
+ session = await LM.create(createOpts)
+ } catch {
+ // retry without sampling params (version differences)
+ session = await LM.create(system ? {initialPrompts: createOpts.initialPrompts} : {})
+ }
+ try {
+ const text = typeof input === "string" ? input : messagesToText(input)
+ let full = ""
+ if (session.promptStreaming) {
+ const stream = session.promptStreaming(text, signal ? {signal} : undefined)
+ for await (const chunk of readStream(stream)) {
+ if (typeof chunk !== "string") continue
+ let delta
+ if (chunk.startsWith(full)) {
+ delta = chunk.slice(full.length)
+ full = chunk
+ } else {
+ delta = chunk
+ full += chunk
+ }
+ if (delta) onToken?.(delta, full)
+ }
+ } else {
+ full = await session.prompt(text)
+ onToken?.(full, full)
+ }
+ return full
+ } finally {
+ try {
+ session.destroy?.()
+ } catch {}
+ }
+}
diff --git a/libraries/llm/client.js b/libraries/llm/client.js
new file mode 100644
index 0000000..f9b4076
--- /dev/null
+++ b/libraries/llm/client.js
@@ -0,0 +1,883 @@
+/**
+ * Main-thread client for the @patchwork/llm worker.
+ *
+ * Connects to a dedicated Worker (one per page), routes
+ * messages back to the right in-flight generation, and exposes two call styles:
+ *
+ * const { text, stats } = await generate(messages, { onToken, onPrediction, onStats })
+ * for await (const ev of stream(messages, { topk: 5 })) { ... }
+ *
+ * Provider / model / key / temperature come from the account-doc config
+ * (see config.js) unless overridden via opts.
+ */
+
+import {readConfig, ensureConfig, callConfig, applyPrompts, effectiveSystem} from "./config.js"
+import {builtinGenerate} from "./builtin.js"
+import {resolveTools, toToolSchemas, buildToolsSystem, parseToolCalls, runTool, resolveCfgPrompts, sanitizeToolName} from "./tools.js"
+
+/**
+ * Per-call options. A superset of every option any exported function accepts;
+ * individual functions document the subset they use. Open-ended on purpose.
+ * @typedef {Object} GenOpts
+ * @property {import("./config.js").LLMConfig} [config]
+ * @property {import("./config.js").Scope} [scope]
+ * @property {boolean} [continuation]
+ * @property {number} [topk]
+ * @property {number} [temperature]
+ * @property {string} [model]
+ * @property {string} [provider]
+ * @property {number} [maxNewTokens]
+ * @property {string} [sessionKey]
+ * @property {string} [system]
+ * @property {any[]} [tools]
+ * @property {boolean} [sandbox]
+ * @property {number} [maxRounds]
+ * @property {AbortSignal} [signal]
+ * @property {(delta:string, full:string, round?:number)=>void} [onToken]
+ * @property {(candidates:{token:string,p:number}[], step:number)=>void} [onPrediction]
+ * @property {(stats:any)=>void} [onStats]
+ * @property {(message:string)=>void} [onStatus]
+ * @property {(info:{name?:string,tool?:string,args?:any,result?:any,error?:any})=>void} [onToolCall]
+ */
+
+/**
+ * A message coming back from the worker (or built locally). Open-ended:
+ * the discriminant is `type` and the rest depends on it. Routing fields are
+ * typed non-optional because the dispatch guards (`msg.id != null`, etc.)
+ * already ensure they're present before use.
+ * @typedef {Object} WorkerMsg
+ * @property {string} type
+ * @property {string} id
+ * @property {string|number} sessionKey
+ * @property {string} text
+ * @property {string} delta
+ * @property {string} message
+ * @property {any[]} args
+ * @property {any} candidates
+ * @property {number} step
+ * @property {any} scores
+ * @property {any} spans
+ * @property {any} decoded
+ * @property {number} total
+ * @property {any} strings
+ * @property {any[]|null} toolCalls
+ * @property {string} [toolMode]
+ */
+
+/** @typedef {{post: (m:any)=>void}} Connection */
+
+/** CallConfig plus the extra mutable fields the client tacks on per-call.
+ * @typedef {import("./config.js").CallConfig & {tools?:any, toolSystem?:string, continuation?:boolean}} CallConfigExt */
+
+/** @typedef {{type:string, id:string, sessionKey:string, provider:import("./config.js").ProviderId, config:import("./config.js").CallConfig, text?:string, messages?:any}} GeneratePayload */
+
+// Providers with real function-calling APIs (the worker passes tool schemas and
+// parses structured tool_calls). Everything else (local transformers, Chrome
+// built-in) uses the XML prompt convention, parsed from the text.
+const NATIVE_TOOL_PROVIDERS = new Set(["openrouter", "ollama", "webllm"])
+const TEMPLATE_TOOL_PROVIDERS = new Set(["local"])
+
+/** @type {Connection|null} */
+let connection = null
+let idSeq = 0
+/** @type {Mapvoid>} */
+const handlers = new Map() // generation id -> (msg) => void
+/** @type {Mapvoid, onDone?:(t?:string)=>void, onError?:(m?:string)=>void, onNone?:()=>void}>} */
+const resumeHandlers = new Map() // sessionKey -> { onToken, onDone, onError, onNone }
+/** @type {Set<(message:string)=>void>} */
+const statusListeners = new Set() // (message) => void
+
+function nextId() {
+ return "llm-" + ++idSeq + "-" + (performance.now() | 0)
+}
+
+// Main-thread diagnostics. The worker forwards its own logs via {type:"log"}
+// (see dispatch); this is for client-side events — aborts and worker errors —
+// so a caller (e.g. loom) that only surfaces a generic AbortError still leaves a
+// trail in the console explaining what actually happened.
+/** @param {...any} args */
+function clog(...args) {
+ try {
+ console.log("[llm]", ...args)
+ } catch {}
+}
+
+/** @param {WorkerMsg} msg */
+function dispatch(msg) {
+ if (!msg) return
+ // Worker diagnostics: the Worker's own console is separate, so re-print its
+ // logs here on the main thread where the tool's devtools can see them.
+ if (msg.type === "log") {
+ try {
+ // Stringify object args inline so the console shows the actual values
+ // (a bare object logs as a collapsed "Object" and hides what we need).
+ console.log(
+ "[llm worker]",
+ ...(msg.args || []).map((a) =>
+ a && typeof a === "object" ? JSON.stringify(a) : a
+ )
+ )
+ } catch {}
+ return
+ }
+ if (msg.type === "status") {
+ for (const f of statusListeners) f(msg.message)
+ return
+ }
+ // Resume replies are keyed by sessionKey (the reconnecting tab never knew the
+ // original generation id) and tell us the live id to adopt for what follows.
+ if (
+ msg.type === "resumed" ||
+ msg.type === "resume-result" ||
+ msg.type === "no-active-generation"
+ ) {
+ const rh = msg.sessionKey != null && resumeHandlers.get(msg.sessionKey)
+ if (!rh) return
+ if (msg.type === "no-active-generation") {
+ resumeHandlers.delete(msg.sessionKey)
+ rh.onNone?.()
+ } else if (msg.type === "resume-result") {
+ resumeHandlers.delete(msg.sessionKey)
+ rh.onToken?.(msg.text)
+ rh.onDone?.(msg.text)
+ } else {
+ rh.onToken?.(msg.text)
+ handlers.set(msg.id, (/** @type {WorkerMsg} */ m) => {
+ if (m.type === "token") rh.onToken?.(m.text)
+ else if (m.type === "result") {
+ handlers.delete(msg.id)
+ resumeHandlers.delete(msg.sessionKey)
+ rh.onDone?.(m.text)
+ } else if (m.type === "error") {
+ handlers.delete(msg.id)
+ resumeHandlers.delete(msg.sessionKey)
+ rh.onError?.(m.message)
+ }
+ })
+ }
+ return
+ }
+ const h = msg.id != null && handlers.get(msg.id)
+ if (h) h(msg)
+}
+
+function getConnection() {
+ if (connection) return connection
+ // A dedicated Worker (one per page), NOT a SharedWorker. A SharedWorker is a
+ // single instance shared across every tab/tool on the origin — which serialises
+ // all generation through one model and keeps running stale code until every
+ // page closes. A per-page Worker reloads with the page, is isolated, and dies
+ // with it; a page that wants many models running at once gets its own worker
+ // rather than fighting over one. (Trade-off: loses resume-after-refresh, which
+ // relied on the worker outliving the page — `resume()` now just reports
+ // no-active-generation, handled gracefully.)
+ // NOTE: `new URL("./worker.js", import.meta.url)` MUST stay inline inside the
+ // constructor — that's the exact pattern bundlers (vite) statically detect to
+ // emit the worker chunk; hoisting it to a variable silently breaks bundling.
+ const w = new Worker(new URL("./worker.js", import.meta.url), {type: "module"})
+ w.onmessage = (/** @type {MessageEvent} */ ev) => dispatch(ev.data)
+ connection = {post: (m) => w.postMessage(m)}
+ return connection
+}
+
+/**
+ * Generate. Resolves to `{ text, stats }`.
+ *
+ * Two intents:
+ * - CHAT (default): pass chat messages, or a string (wrapped as a user turn).
+ * The instruct/chat model responds; the system prompt applies.
+ * - CONTINUATION: pass a string with `{ continuation: true }` to *continue* the
+ * text rather than answer it. local/webllm/ollama feed it raw; chat-only
+ * OpenRouter is framed with a "continue, output only the continuation"
+ * instruction. (This is what Loom uses; everyone else gets plain chat.)
+ *
+ * @param {Array|string} messages chat messages, or a string
+ * @param {GenOpts} [opts]
+ * @returns {Promise<{text:string, toolCalls:object[]|null, toolMode?:string, stats:object|null}>}
+ */
+export async function generate(messages, opts = {}) {
+ const cfg0 = opts.config ?? (await ensureConfig(opts.scope))
+ // Resolve the selected system/pre prompt docs → their text. repo.find is
+ // cached, so this is cheap after first load.
+ const cfg = await resolveCfgPrompts(cfg0)
+ /** @type {CallConfigExt} */
+ const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ (opts))
+
+ // Tools: native providers get JSON schemas on `config.tools`; the rest get the
+ // XML convention prepended to the system prompt (parsed from text).
+ const hasTools = Array.isArray(opts.tools) && opts.tools.length > 0
+ const native = hasTools && NATIVE_TOOL_PROVIDERS.has(config.provider)
+ const templated = hasTools && TEMPLATE_TOOL_PROVIDERS.has(config.provider)
+ if (native || templated) config.tools = toToolSchemas(opts.tools)
+ if (templated) config.toolSystem = buildToolsSystem(opts.tools)
+ const extraSystem =
+ hasTools && !native && !templated
+ ? [buildToolsSystem(opts.tools), opts.system].filter(Boolean).join("\n\n")
+ : opts.system
+
+ // Built-in (Chrome Prompt API) runs on the main thread, not the worker.
+ if (config.provider === "builtin") {
+ const pre = cfg.resolved?.pre || ""
+ const text =
+ typeof messages === "string"
+ ? pre
+ ? pre + "\n\n" + messages
+ : messages
+ : messages
+ return builtinGenerate(text, {
+ temperature: config.temperature,
+ topK: config.topK,
+ system: effectiveSystem(/** @type {any} */ (cfg), extraSystem),
+ onToken: opts.onToken,
+ onStatus: opts.onStatus,
+ signal: opts.signal,
+ }).then((t) => ({text: t, toolCalls: null, stats: null}))
+ }
+
+ // A string input is CHAT by default — wrapped as a user turn, so instruct/chat
+ // models respond normally and the system prompt applies. It's a raw
+ // CONTINUATION only when opts.continuation is set: raw-fed for
+ // local/webllm/ollama, and CONTINUE_SYS-framed for chat-only OpenRouter (see
+ // the worker). Loom passes continuation:true; other callers get plain chat.
+ const asContinuation = !!opts.continuation && typeof messages === "string"
+ const prepared = asContinuation
+ ? messages
+ : typeof messages === "string"
+ ? [{role: "user", content: messages}]
+ : messages
+ // Prepend the configured system + pre-prompt (and any tool-supplied system).
+ const input = applyPrompts(prepared, /** @type {any} */ (cfg), extraSystem)
+ const conn = getConnection()
+ const id = nextId()
+ const sessionKey = opts.sessionKey || id
+ /** @type {any} */
+ let stats = null
+
+ return new Promise((resolve, reject) => {
+ const onStatus = opts.onStatus
+ if (onStatus) statusListeners.add(onStatus)
+ const cleanup = () => {
+ handlers.delete(id)
+ if (onStatus) statusListeners.delete(onStatus)
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbort)
+ }
+ function onAbort() {
+ clog("generate: aborted by caller", {provider: config.provider, model: config.model})
+ conn.post({type: "abort", sessionKey})
+ cleanup()
+ reject(new DOMException("Aborted", "AbortError"))
+ }
+
+ handlers.set(id, (/** @type {WorkerMsg} */ msg) => {
+ switch (msg.type) {
+ case "token":
+ opts.onToken?.(msg.delta, msg.text)
+ break
+ case "prediction":
+ opts.onPrediction?.(msg.candidates, msg.step)
+ break
+ case "stats":
+ stats = msg
+ opts.onStats?.(msg)
+ break
+ case "result":
+ cleanup()
+ resolve({text: msg.text, toolCalls: msg.toolCalls || null, toolMode: msg.toolMode, stats})
+ break
+ case "error":
+ clog("generate: worker error", msg.message)
+ cleanup()
+ reject(new Error(msg.message))
+ break
+ }
+ })
+
+ if (opts.signal) {
+ if (opts.signal.aborted) return onAbort()
+ opts.signal.addEventListener("abort", onAbort)
+ }
+ // A string is a raw continuation prompt; an array is chat messages.
+ /** @type {GeneratePayload} */
+ const payload = {type: "generate", id, sessionKey, provider: config.provider, config}
+ if (typeof input === "string") payload.text = input
+ else payload.messages = input
+ conn.post(payload)
+ })
+}
+
+/**
+ * Stream a completion as an async iterable of telemetry events:
+ * { type:"token", delta, text }
+ * { type:"prediction", candidates:[{token,p}], step }
+ * { type:"stats", ... }
+ * { type:"status", message }
+ * { type:"done", text, stats }
+ *
+ * @returns {AsyncGenerator}
+ */
+/**
+ * @param {Array|string} messages
+ * @param {GenOpts} [opts]
+ */
+export async function* stream(messages, opts = {}) {
+ /** @type {any[]} */
+ const queue = []
+ /** @type {((v?:any) => void)|null} */
+ let wake = null
+ let finished = false
+ /** @type {any} */
+ let error = null
+ const push = (/** @type {any} */ ev) => {
+ queue.push(ev)
+ wake?.()
+ }
+
+ generate(messages, {
+ ...opts,
+ onToken: (delta, text) => push({type: "token", delta, text}),
+ onPrediction: (candidates, step) =>
+ push({type: "prediction", candidates, step}),
+ onStats: (s) => push({type: "stats", ...s}),
+ onStatus: (message) => push({type: "status", message}),
+ })
+ .then((r) => push({type: "done", text: r.text, stats: r.stats}))
+ .catch((e) => (error = e))
+ .finally(() => {
+ finished = true
+ wake?.()
+ })
+
+ while (true) {
+ if (queue.length) {
+ yield queue.shift()
+ continue
+ }
+ if (finished) break
+ await new Promise((r) => (wake = r))
+ }
+ if (error) throw error
+}
+
+/**
+ * Predict the next-token distribution after `text` — one forward pass, no
+ * generation. Resolves to `[{token, p}]` (top-k, highest p first). Powers
+ * "predict as you type". Local reads real logits; OpenRouter is best-effort
+ * (chat-only models return `[]`); Ollama returns `[]`.
+ *
+ * @param {string} text
+ * @param {GenOpts} [opts]
+ * @returns {Promise<{token:string,p:number}[]>}
+ */
+export async function predict(text, opts = {}) {
+ const base = opts.config ?? (await ensureConfig(opts.scope))
+ // A config provider can carry an in-memory handler that intercepts the request
+ // on the main thread (e.g. choochoo runs a base model and adds a live LoRA
+ // delta). Opt-in — no handler means normal worker dispatch below.
+ if (typeof base?.handler?.predict === "function") return base.handler.predict(text, opts)
+ const cfg = await resolveCfgPrompts(base)
+ /** @type {CallConfigExt} */
+ const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ ({...opts, topk: opts.topk || 10}))
+ // continuation → frame chat-only providers (OpenRouter) to predict the
+ // *continuation's* next token, not a chat reply's. Opt-in (Loom sets it).
+ config.continuation = !!opts.continuation
+ // Built-in exposes no next-token logprobs.
+ if (config.provider === "builtin") return Promise.resolve([])
+ const promptedText = applyPrompts(text, /** @type {any} */ (cfg), opts.system) // prepends pre-prompt (no system in raw)
+ const conn = getConnection()
+ const id = nextId()
+ const sessionKey = id // so an abort can reach the in-flight request in the worker
+ return new Promise((resolve, reject) => {
+ const cleanup = () => {
+ handlers.delete(id)
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbort)
+ }
+ function onAbort() {
+ conn.post({type: "abort", sessionKey}) // cancel the worker-side fetch (OpenRouter/WebLLM)
+ cleanup()
+ reject(new DOMException("Aborted", "AbortError"))
+ }
+ handlers.set(id, (/** @type {WorkerMsg} */ msg) => {
+ if (msg.type === "predictions") {
+ cleanup()
+ resolve(msg.candidates)
+ } else if (msg.type === "error") {
+ cleanup()
+ reject(new Error(msg.message))
+ }
+ })
+ if (opts.signal) {
+ if (opts.signal.aborted) return onAbort()
+ opts.signal.addEventListener("abort", onAbort)
+ }
+ conn.post({type: "predict", id, sessionKey, provider: config.provider, text: promptedText, config})
+ })
+}
+
+/**
+ * Score every token in `text` — runs a forward pass at each position and
+ * returns the model's probability, rank, entropy, and top-k alternatives for
+ * the actual next token. Powers the surprisal and info-gain overlays ("how
+ * surprised was the model by what you actually wrote?" and "how much did each
+ * token reduce its uncertainty?"). Only works for local models.
+ *
+ * Yields progress events and a final result:
+ * { type:"progress", step, total }
+ * { type:"done", scores:[{token, p, rank, entropy, topk:[{token,p}]}] }
+ *
+ * @param {string} text
+ * @param {GenOpts} [opts]
+ * @returns {AsyncGenerator}
+ */
+export async function* scoreTokens(text, opts = {}) {
+ const cfg = await resolveCfgPrompts(opts.config ?? (await ensureConfig(opts.scope)))
+ const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ (opts))
+ if (config.provider !== "local") return // only local models expose raw logits
+ const conn = getConnection()
+ const id = nextId()
+ const sessionKey = id
+
+ /** @type {any[]} */
+ const queue = []
+ /** @type {((v?:any) => void)|null} */
+ let wake = null
+ let finished = false
+ /** @type {any} */
+ let error = null
+ const push = (/** @type {any} */ ev) => { queue.push(ev); wake?.() }
+
+ handlers.set(id, (/** @type {WorkerMsg} */ msg) => {
+ if (msg.type === "score-progress") push({type: "progress", step: msg.step, total: msg.total})
+ else if (msg.type === "token-scores") {
+ push({type: "done", scores: msg.scores, spans: msg.spans, decoded: msg.decoded})
+ finished = true
+ handlers.delete(id)
+ wake?.()
+ } else if (msg.type === "error") {
+ error = new Error(msg.message)
+ finished = true
+ handlers.delete(id)
+ wake?.()
+ }
+ })
+
+ function onAbort() {
+ conn.post({type: "abort", sessionKey})
+ handlers.delete(id)
+ error = new DOMException("Aborted", "AbortError")
+ finished = true
+ wake?.()
+ }
+ if (opts.signal) {
+ if (opts.signal.aborted) { onAbort(); return }
+ opts.signal.addEventListener("abort", onAbort)
+ }
+
+ conn.post({type: "score-tokens", id, sessionKey, provider: config.provider, text, config})
+
+ while (true) {
+ if (queue.length) { yield queue.shift(); continue }
+ if (finished) break
+ await new Promise((r) => (wake = r))
+ }
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbort)
+ if (error) throw error
+}
+
+/**
+ * Compute per-token importance for `text` by erasure-based attribution: a
+ * baseline forward pass, then one pass per token with that token masked out,
+ * scoring each token by how much its removal shifts the model's next-token
+ * distribution (Jensen–Shannon divergence). N+1 forward passes on the local
+ * model — genuine importance, not an attention proxy. See Li, Chen, Zhu &
+ * Rudin 2016, "Understanding Neural Networks through Representation Erasure".
+ *
+ * This is erasure-based importance, NOT attention weights — for real attention
+ * see computeAttentionWeights. Returns `{decoded, spans}` where decoded is the
+ * tokenizer's round-tripped text and spans is `[{from, to, importance}]`
+ * (importance normalized to [0,1]) with positions relative to `decoded`. Only
+ * works for local models (others resolve to null).
+ *
+ * @param {string} text
+ * @param {GenOpts} [opts]
+ * @returns {Promise<{decoded:string, spans:Array}|null>}
+ */
+export async function computeImportance(text, opts = {}) {
+ const cfg = await resolveCfgPrompts(opts.config ?? (await ensureConfig(opts.scope)))
+ const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ (opts))
+ if (config.provider !== "local") return null
+ const conn = getConnection()
+ const id = nextId()
+ return new Promise((resolve, reject) => {
+ handlers.set(id, (/** @type {WorkerMsg} */ msg) => {
+ if (msg.type === "importance-scores") {
+ handlers.delete(id)
+ resolve({decoded: msg.decoded, spans: msg.spans || []})
+ } else if (msg.type === "error") {
+ handlers.delete(id)
+ reject(new Error(msg.message))
+ }
+ })
+ conn.post({type: "compute-importance", id, sessionKey: id, provider: config.provider, text, config})
+ })
+}
+
+/**
+ * Compute REAL attention weights for `text` (not the erasure proxy that
+ * computeImportance returns). Only works for local models exported with an
+ * `attentions` output (see glomper-tuning/onnx_attn.py). One forward pass.
+ *
+ * Resolves to:
+ * { supported:true, dims:{layers,heads,seq}, received, fromLast, spans, tokens, decoded }
+ * where `received` and `fromLast` are Float32Arrays of length layers*heads*seq
+ * laid out as [(layer*heads + head)*seq + key]:
+ * received[…] = mean attention key j gets across all queries i≥j (causal)
+ * fromLast[…] = attention the final token places on key j
+ * `spans` is [{from,to,index}] (char positions in `decoded`, keyed by token
+ * index). Resolves to { supported:false } when the model has no attention
+ * output, or null for non-local providers.
+ *
+ * @param {string} text
+ * @param {GenOpts} [opts]
+ * @returns {Promise