@chatpanel/gateway 0.6.17 → 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -96,6 +96,16 @@ const DEFAULTS = {
96
96
  enableFullTier: true,
97
97
  },
98
98
 
99
+ // Local speech-to-text (dictation) — whisper via the same in-process ONNX engine
100
+ // and model dir as NER (stt-engine.js). No autostart on purpose: the model
101
+ // downloads on FIRST dictation, never on gateway boot (first-run load time).
102
+ // Multilingual default so the spoken language is auto-detected per segment.
103
+ stt: {
104
+ enabled: true,
105
+ model: 'onnx-community/whisper-base',
106
+ allowDownload: true,
107
+ },
108
+
99
109
  // Log one line per request (method, tokens redacted) without any raw values.
100
110
  logRequests: true,
101
111
 
@@ -22,7 +22,7 @@ export function persistConfig(cfg, path = configPath()) {
22
22
  // restart drops them and every model falls back to the default OpenAI upstream.
23
23
  destinations: cfg.destinations,
24
24
  bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
25
- ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
25
+ ner: cfg.ner, stt: cfg.stt, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
26
26
  pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
27
27
  };
28
28
  writeFileSync(path, JSON.stringify(out, null, 2));
@@ -45,6 +45,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
45
45
  dictionary: Array.isArray(cfg.redaction?.dictionary) ? cfg.redaction.dictionary : [],
46
46
  },
47
47
  ner: cfg.ner,
48
+ stt: cfg.stt,
48
49
  allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
49
50
  // free = lifetime trial usage ({ used, cap, remaining }) — read-only; the cap
50
51
  // is fixed and the count is server-authoritative (never settable from the UI).
@@ -109,6 +110,13 @@ export function applyConfigPatch(cfg, patch = {}) {
109
110
  // its `used` count is server-authoritative — neither is editable here, so a
110
111
  // client can't raise the cap or reset its own trial.
111
112
  }
113
+ // Local dictation toggle. The MODEL is switched via POST /stt/models (like the
114
+ // NER manager), not patched here.
115
+ if (patch.stt && typeof patch.stt === 'object') {
116
+ cfg.stt = cfg.stt || { enabled: true, model: 'onnx-community/whisper-base', allowDownload: true };
117
+ if ('enabled' in patch.stt) cfg.stt.enabled = !!patch.stt.enabled;
118
+ if ('allowDownload' in patch.stt) cfg.stt.allowDownload = !!patch.stt.allowDownload;
119
+ }
112
120
  if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
113
121
  if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
114
122
  if (patch.tools && typeof patch.tools === 'object') {
package/src/mcp.js CHANGED
@@ -28,7 +28,7 @@ function baseUrl() {
28
28
  const TOOLS = [
29
29
  {
30
30
  name: 'search_history',
31
- description: 'Full-text search the user\'s ChatPanel history (past chats and meeting transcripts) by keyword relevance. Use this to recall what was discussed when the current context does not already contain it.',
31
+ description: 'Full-text search the user\'s ChatPanel history past chats, meeting transcripts, and notes — by keyword relevance. Use this to recall what was discussed or written when the current context does not already contain it.',
32
32
  inputSchema: {
33
33
  type: 'object',
34
34
  properties: {
@@ -40,10 +40,10 @@ const TOOLS = [
40
40
  },
41
41
  {
42
42
  name: 'get_record',
43
- description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id> or meeting:<id> returned by search_history.',
43
+ description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id>, meeting:<id>, or note:<id> returned by search_history.',
44
44
  inputSchema: {
45
45
  type: 'object',
46
- properties: { id: { type: 'string', description: 'Record id such as chat:abc or meeting:imp_123.' } },
46
+ properties: { id: { type: 'string', description: 'Record id such as chat:abc, meeting:imp_123, or note:xyz.' } },
47
47
  required: ['id'],
48
48
  },
49
49
  },
package/src/ner-engine.js CHANGED
@@ -143,7 +143,9 @@ export async function fetchAdapter(_url, opts = {}) {
143
143
  export function progress() { return _progress; }
144
144
 
145
145
  // Import transformers ONCE and configure its env (model dir + WASM in a binary).
146
- async function ensureLib() {
146
+ // Exported so sibling engines (stt-engine.js) share the same configured lib —
147
+ // one env, one cache dir, one WASM setup; never a second copy of this logic.
148
+ export async function ensureLib() {
147
149
  if (_lib) return _lib;
148
150
  const root = modelRoot();
149
151
  try { mkdirSync(root, { recursive: true }); } catch { /* best effort */ }
@@ -159,7 +161,7 @@ async function ensureLib() {
159
161
  try { Object.defineProperty(process, 'release', { value: { ...process.release, name: 'bun' }, configurable: true }); } catch { /* ignore */ }
160
162
  }
161
163
 
162
- const { env, pipeline } = await import('@huggingface/transformers');
164
+ const { env, pipeline, Tensor } = await import('@huggingface/transformers');
163
165
  env.cacheDir = root; // where remote downloads are cached
164
166
  env.localModelPath = root; // where local loads resolve — same dir, we control it
165
167
  try { env.remoteHost = MODEL_HOST; } catch { /* optional */ }
@@ -169,7 +171,7 @@ async function ensureLib() {
169
171
  // failure when ORT-web runs outside a browser.
170
172
  try { env.backends.onnx.wasm.proxy = false; env.backends.onnx.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
171
173
  }
172
- _lib = { env, pipeline };
174
+ _lib = { env, pipeline, Tensor };
173
175
  return _lib;
174
176
  }
175
177
 
package/src/server.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
22
  import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
23
- import { redactSegments } from './redact.js';
23
+ import { redactSegments, segment } from './redact.js';
24
24
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
25
25
  import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
26
26
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
@@ -32,7 +32,9 @@ import { saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-s
32
32
  import { createHistoryStore } from './sqlite-store.js';
33
33
  import { ingestBackup } from './backup-ingest.js';
34
34
  import * as nerEngine from './ner-engine.js';
35
+ import * as sttEngine from './stt-engine.js';
35
36
  import { MODEL_CATALOG, isKnownModel } from './models.js';
37
+ import { STT_MODEL_CATALOG, isKnownSttModel, DEFAULT_STT_MODEL } from './stt-models.js';
36
38
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
37
39
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
38
40
  import { resolveDestination, aggregateModelsAsync } from './router.js';
@@ -40,7 +42,7 @@ import * as openai from './openai.js';
40
42
  import * as responses from './responses.js';
41
43
  import * as anthropic from './anthropic.js';
42
44
 
43
- export const VERSION = '0.6.17';
45
+ export const VERSION = '0.6.18';
44
46
 
45
47
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
46
48
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -490,7 +492,14 @@ export function createGateway(cfg = loadConfig()) {
490
492
  if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
491
493
 
492
494
  if (req.method === 'GET' && pathname === '/health') {
493
- return sendJson(res, 200, { ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier });
495
+ // `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
496
+ // to auto-detect local dictation. `enabled` reflects config; the model only
497
+ // downloads on first use, so state may be 'off' while still available.
498
+ const stt = sttEngine.health();
499
+ return sendJson(res, 200, {
500
+ ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
501
+ stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL },
502
+ });
494
503
  }
495
504
 
496
505
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
@@ -646,6 +655,112 @@ export function createGateway(cfg = loadConfig()) {
646
655
  return sendJson(res, 202, { accepted: true, active: id, state: nerEngine.state(), progress: nerEngine.progress() });
647
656
  }
648
657
  }
658
+ // --- Local speech-to-text (dictation). Whisper runs IN-PROCESS (stt-engine.js,
659
+ // same ONNX engine + model dir as NER); audio arrives as 16 kHz mono Float32 PCM
660
+ // chunks over loopback and ONLY TEXT ever leaves this process. Wire contract
661
+ // (additive; see docs in the hub repo):
662
+ // POST /stt/sessions {lang?} → 201 { id } (ensures the model)
663
+ // POST /stt/sessions/:id/audio binary Float32 PCM chunk → { ok }
664
+ // GET /stt/sessions/:id/events SSE: progress | interim | final | error | end
665
+ // DELETE /stt/sessions/:id flush tail → { ok }
666
+ // GET/POST /stt/models catalog + progress / switch (mirrors /ner/models)
667
+ if (pathname === '/stt/models') {
668
+ if (req.method === 'GET') {
669
+ const available = STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) }));
670
+ return sendJson(res, 200, {
671
+ active: sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL,
672
+ state: sttEngine.state(),
673
+ progress: sttEngine.progress(),
674
+ available,
675
+ });
676
+ }
677
+ if (req.method === 'POST') {
678
+ let body = null;
679
+ try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
680
+ const id = body && typeof body.id === 'string' ? body.id : null;
681
+ if (!id || !isKnownSttModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
682
+ if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
683
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
684
+ sttEngine.setModel(id, { onLog: (m) => console.log(m) });
685
+ return sendJson(res, 202, { accepted: true, active: id, state: sttEngine.state(), progress: sttEngine.progress() });
686
+ }
687
+ }
688
+ if (pathname === '/stt/sessions' && req.method === 'POST') {
689
+ if (cfg.stt?.enabled === false) return sendJson(res, 403, { error: { message: 'STT disabled in gateway config', type: 'stt_disabled' } });
690
+ let body = null;
691
+ try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8') || '{}'); } catch { body = null; }
692
+ // Kick the model load on first use (single-flight; downloads once). The
693
+ // client follows progress on the session's SSE stream.
694
+ if (!sttEngine.isReady()) {
695
+ sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
696
+ }
697
+ try {
698
+ // `redact: true` chains the OPTIONAL redaction hop onto finals (STT → NER,
699
+ // same composable model as everything else: any stage, with or without).
700
+ const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true });
701
+ return sendJson(res, 201, { id, state: sttEngine.state() });
702
+ } catch (e) {
703
+ return sendJson(res, e.code === 'too_many_sessions' ? 429 : 500, { error: { message: e.message, type: e.code || 'stt_error' } });
704
+ }
705
+ }
706
+ {
707
+ const m = pathname.match(/^\/stt\/sessions\/([0-9a-f-]{36})(\/audio|\/events)?$/);
708
+ if (m) {
709
+ const sid = m[1];
710
+ if (m[2] === '/audio' && req.method === 'POST') {
711
+ try {
712
+ const raw = await readBody(req, cfg.maxBodyBytes);
713
+ sttEngine.pushAudio(sid, sttEngine.toFloat32(raw));
714
+ return sendJson(res, 200, { ok: true, state: sttEngine.state() });
715
+ } catch (e) {
716
+ return sendJson(res, e.code === 'no_session' ? 404 : 400, { error: { message: e.message, type: e.code || 'stt_error' } });
717
+ }
718
+ }
719
+ if (m[2] === '/events' && req.method === 'GET') {
720
+ const sess = sttEngine.getSession(sid);
721
+ if (!sess) return sendJson(res, 404, { error: { message: 'no such session', type: 'no_session' } });
722
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
723
+ const send = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
724
+ // Optional STT → NER hop: redact FINALS through the same shared guard as
725
+ // chat traffic (one implementation, composed — never a second redactor).
726
+ // Interims stay raw (transient, loopback-only). NOTE: the vault is
727
+ // discarded, so these placeholders are permanent — that's the point.
728
+ const sttIsPro = sess.redact ? await resolvePro(cfg.pro?.entitlementToken) : true;
729
+ const maybeRedact = async (ev) => {
730
+ if (ev.type !== 'final' || !sess.redact) return ev;
731
+ try {
732
+ let t = ev.text;
733
+ await redactSegments([segment(() => t, (v) => { t = v; })], cfg.redaction, { isPro: sttIsPro });
734
+ return { ...ev, text: t };
735
+ } catch { return ev; } // fail-open: raw text is still local-only
736
+ };
737
+ // While the model is loading/downloading, stream progress so the UI can
738
+ // show "downloading 43%" instead of dead air on first-ever dictation.
739
+ send({ type: 'state', state: sttEngine.state() });
740
+ const progressTimer = setInterval(() => {
741
+ const st = sttEngine.state();
742
+ if (st === 'downloading' || st === 'loading') send({ type: 'progress', state: st, ...(sttEngine.progress() || {}) });
743
+ else if (st === 'error') { send({ type: 'error', code: 'model_failed', message: sttEngine.health().error || 'model failed to load', fatal: true }); clearInterval(progressTimer); }
744
+ else { send({ type: 'state', state: st }); clearInterval(progressTimer); }
745
+ }, 500);
746
+ progressTimer.unref?.();
747
+ // Redaction is async — chain events so finals can't overtake interims.
748
+ let evChain = Promise.resolve();
749
+ const unsub = sttEngine.subscribe(sid, (ev) => {
750
+ evChain = evChain.then(async () => {
751
+ send(await maybeRedact(ev));
752
+ if (ev.type === 'end') { clearInterval(progressTimer); res.end(); }
753
+ }).catch(() => {});
754
+ });
755
+ req.on('close', () => { clearInterval(progressTimer); unsub?.(); });
756
+ return;
757
+ }
758
+ if (!m[2] && req.method === 'DELETE') {
759
+ await sttEngine.endSession(sid);
760
+ return sendJson(res, 200, { ok: true });
761
+ }
762
+ }
763
+ }
649
764
  if (pathname === '/logs' && req.method === 'GET') {
650
765
  return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
651
766
  }
@@ -0,0 +1,332 @@
1
+ // In-process speech-to-text — whisper via transformers.js, zero native deps.
2
+ //
3
+ // Same design as ner-engine.js (and deliberately the same file shape): an ONNX
4
+ // model run IN-PROCESS, downloaded once on first use into ~/.chatpanel/models,
5
+ // fully offline afterwards — audio NEVER leaves the machine. This is what makes
6
+ // dictation private: the extension streams mic PCM over loopback, we transcribe
7
+ // locally, and only text goes back.
8
+ //
9
+ // Two layers:
10
+ // engine — load/switch the whisper pipeline (mirrors ner-engine verbatim)
11
+ // sessions — rolling-buffer streaming decode: interim results every ~1.2s,
12
+ // finals on trailing silence or when a segment grows too long.
13
+ // Whisper isn't natively streaming, so we re-decode the open
14
+ // segment and commit it when the speaker pauses.
15
+ //
16
+ // Fail-open by design: if the model can't load, sessions emit one error event
17
+ // and dictation falls back to the browser engine client-side.
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync, readdirSync } from 'node:fs';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { ensureLib, modelRoot } from './ner-engine.js';
23
+ import { DEFAULT_STT_MODEL, isEnglishOnly } from './stt-models.js';
24
+
25
+ export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
26
+
27
+ let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
28
+ let _model = null; // active model id
29
+ let _pipe = null; // the loaded automatic-speech-recognition pipeline
30
+ let _err = null; // last error message (for /health)
31
+ let _initPromise = null; // single-flight init
32
+ let _progress = null; // { model, file, pct } while downloading, else null
33
+
34
+ // Whisper exports ship encoder+decoder ONNX files (not the single model.onnx the
35
+ // NER check looks for), so presence = "any encoder_model*.onnx on disk".
36
+ export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL) {
37
+ const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
38
+ if (!existsSync(dir)) return false;
39
+ try { return readdirSync(dir).some((f) => /^encoder_model.*\.onnx$/.test(f)); }
40
+ catch { return false; }
41
+ }
42
+
43
+ export function state() { return _state; }
44
+ export function isReady() { return _state === 'ready' && !!_pipe; }
45
+ export function progress() { return _progress; }
46
+
47
+ export function health() {
48
+ return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err };
49
+ }
50
+
51
+ // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
52
+ // and a failed SWITCH keeps the previous working pipeline.
53
+ /** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean }} [opts] */
54
+ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
55
+ const prevPipe = _pipe;
56
+ const prevModel = _model;
57
+ let lib;
58
+ try {
59
+ lib = await ensureLib();
60
+ } catch (e) {
61
+ _state = 'error'; _err = `engine load failed: ${e.message}`;
62
+ log(`[stt] transformers.js not available (${e.message}) — dictation falls back to the browser engine`);
63
+ return false;
64
+ }
65
+
66
+ const haveLocal = modelOnDisk(modelId);
67
+ lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
68
+ if (!haveLocal && !allowDownload) {
69
+ _state = 'error'; _err = 'model not on disk and downloads disabled';
70
+ log(`[stt] model ${modelId} not installed and downloads disabled`);
71
+ return false;
72
+ }
73
+
74
+ _state = haveLocal ? 'loading' : 'downloading';
75
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time)…`); }
76
+
77
+ try {
78
+ const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
79
+ dtype: 'q8',
80
+ progress_callback: (p) => {
81
+ if (!p) return;
82
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
83
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
84
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
85
+ } else if (p.status === 'done' && p.file) {
86
+ log(`[stt] fetched ${p.file}`);
87
+ }
88
+ },
89
+ });
90
+ _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
91
+ if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
92
+ log(`[stt] ready — model ${modelId} (in-process, offline) — local dictation active`);
93
+ return true;
94
+ } catch (e) {
95
+ _err = e.message; _progress = null;
96
+ if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
97
+ else { _state = 'error'; }
98
+ log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // Load the configured model once, on FIRST USE (never at gateway startup — the
104
+ // download is deferred until someone actually dictates). Single-flight.
105
+ export function init(cfg = {}) {
106
+ if (_initPromise) return _initPromise;
107
+ const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
108
+ _model = cfg.model || DEFAULT_STT_MODEL;
109
+ _state = 'loading';
110
+ _initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
111
+ return _initPromise;
112
+ }
113
+
114
+ export async function setModel(modelId, opts = {}) {
115
+ const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
116
+ if (!modelId) return false;
117
+ if (modelId === _model && isReady()) return true;
118
+ return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false });
119
+ }
120
+
121
+ // ── Streaming sessions ──────────────────────────────────────────────────────────
122
+
123
+ const MAX_SESSIONS = 4;
124
+ const IDLE_MS = 60_000; // no audio for a minute → session expires
125
+ const DECODE_GAP_MS = 1200; // min spacing between decodes of one session
126
+ const MAX_SEGMENT_S = 12; // force-finalize an open segment past this
127
+ const SILENCE_FINAL_MS = 700; // trailing quiet that commits a segment
128
+ const SILENCE_RMS = 0.008; // "quiet" threshold on Float32 PCM
129
+ const MAX_BUFFER_S = 30; // hard cap on the open segment (whisper ctx)
130
+
131
+ const _sessions = new Map();
132
+ let _decodeChain = Promise.resolve(); // whisper is effectively single-threaded —
133
+ // serialize decodes across ALL sessions
134
+
135
+ export function sessionCount() { return _sessions.size; }
136
+
137
+ /** @param {{ lang?: string, redact?: boolean }} [opts] */
138
+ export function createSession({ lang, redact = false } = {}) {
139
+ if (_sessions.size >= MAX_SESSIONS) {
140
+ const e = /** @type {Error & { code?: string }} */ (new Error('too many concurrent dictation sessions'));
141
+ e.code = 'too_many_sessions'; throw e;
142
+ }
143
+ const s = {
144
+ id: randomUUID(),
145
+ lang: typeof lang === 'string' && lang ? lang.slice(0, 12) : null,
146
+ langTried: false, // language auto-detect runs once per session (multilingual models)
147
+ // Opaque to this engine: the server applies the redaction hop to finals when
148
+ // set. Pipeline stages stay independent — STT never imports NER.
149
+ redact: !!redact,
150
+ chunks: [], // Float32Array pieces of the OPEN (unfinalized) segment
151
+ samples: 0,
152
+ listeners: new Set(), // (event) => void — the SSE writers
153
+ lastDecodeAt: 0,
154
+ decodeTimer: null,
155
+ lastInterim: '',
156
+ closed: false,
157
+ idleTimer: null,
158
+ };
159
+ _sessions.set(s.id, s);
160
+ touch(s);
161
+ return { id: s.id };
162
+ }
163
+
164
+ export function getSession(id) { return _sessions.get(String(id || '')) || null; }
165
+
166
+ export function subscribe(id, fn) {
167
+ const s = getSession(id);
168
+ if (!s) return null;
169
+ s.listeners.add(fn);
170
+ return () => s.listeners.delete(fn);
171
+ }
172
+
173
+ function emit(s, ev) {
174
+ for (const fn of s.listeners) { try { fn(ev); } catch { /* listener's problem */ } }
175
+ }
176
+
177
+ function touch(s) {
178
+ if (s.idleTimer) clearTimeout(s.idleTimer);
179
+ s.idleTimer = setTimeout(() => endSession(s.id).catch(() => {}), IDLE_MS);
180
+ s.idleTimer.unref?.();
181
+ }
182
+
183
+ // Accept a chunk of 16 kHz mono Float32 PCM and schedule a decode.
184
+ export function pushAudio(id, float32) {
185
+ const s = getSession(id);
186
+ if (!s || s.closed) {
187
+ const e = /** @type {Error & { code?: string }} */ (new Error('no such session'));
188
+ e.code = 'no_session'; throw e;
189
+ }
190
+ if (!(float32 instanceof Float32Array) || !float32.length) return;
191
+ s.chunks.push(float32);
192
+ s.samples += float32.length;
193
+ touch(s);
194
+ scheduleDecode(s);
195
+ }
196
+
197
+ // HTTP bodies arrive as Buffers whose byteOffset may not be 4-aligned — copy.
198
+ export function toFloat32(buf) {
199
+ const bytes = buf.length - (buf.length % 4);
200
+ const out = new Float32Array(bytes / 4);
201
+ for (let i = 0; i < out.length; i++) out[i] = buf.readFloatLE(i * 4);
202
+ return out;
203
+ }
204
+
205
+ function scheduleDecode(s) {
206
+ if (s.closed || s.decodeTimer) return;
207
+ const wait = Math.max(0, s.lastDecodeAt + DECODE_GAP_MS - Date.now());
208
+ s.decodeTimer = setTimeout(() => {
209
+ s.decodeTimer = null;
210
+ _decodeChain = _decodeChain.then(() => decodeSession(s).catch(() => {}));
211
+ }, wait);
212
+ s.decodeTimer.unref?.();
213
+ }
214
+
215
+ function concatBuffer(s) {
216
+ const out = new Float32Array(s.samples);
217
+ let o = 0;
218
+ for (const c of s.chunks) { out.set(c, o); o += c.length; }
219
+ return out;
220
+ }
221
+
222
+ function rms(audio, from = 0, to = audio.length) {
223
+ let sum = 0;
224
+ const n = Math.max(1, to - from);
225
+ for (let i = from; i < to; i++) sum += audio[i] * audio[i];
226
+ return Math.sqrt(sum / n);
227
+ }
228
+
229
+ // Whisper's native language-ID, which transformers.js doesn't implement (its
230
+ // pipeline just defaults to English): one decoder step from <|startoftranscript|>,
231
+ // argmax over the 99 language tokens. Run ONCE per session on the first voiced
232
+ // segment, then pinned — so "speak any language, it just works" without a setting.
233
+ async function detectLanguage(audio) {
234
+ try {
235
+ const lib = await ensureLib();
236
+ const gc = _pipe?.model?.generation_config;
237
+ if (!gc?.lang_to_id || !gc.decoder_start_token_id || !lib.Tensor) return null; // english-only model (or exotic export)
238
+ const clip = audio.length > SAMPLE_RATE * 8 ? audio.subarray(0, SAMPLE_RATE * 8) : audio;
239
+ const feats = await _pipe.processor(clip);
240
+ const decoder_input_ids = new lib.Tensor('int64', new BigInt64Array([BigInt(gc.decoder_start_token_id)]), [1, 1]);
241
+ const out = await _pipe.model({ ...feats, decoder_input_ids });
242
+ let best = null, bestVal = -Infinity;
243
+ for (const [tok, id] of Object.entries(gc.lang_to_id)) {
244
+ const v = Number(out.logits.data[id]);
245
+ if (v > bestVal) { bestVal = v; best = tok; }
246
+ }
247
+ return best ? best.slice(2, -2) : null; // '<|fr|>' → 'fr'
248
+ } catch { return null; } // fail-open: whisper's English default still transcribes
249
+ }
250
+
251
+ async function decodeSession(s, { flush = false } = {}) {
252
+ if (s.closed && !flush) return;
253
+ if (!isReady() || !s.samples) return;
254
+ s.lastDecodeAt = Date.now();
255
+
256
+ const audio = concatBuffer(s);
257
+ const tail = Math.round((SILENCE_FINAL_MS / 1000) * SAMPLE_RATE);
258
+ const trailingQuiet = audio.length > tail && rms(audio, audio.length - tail) < SILENCE_RMS;
259
+
260
+ // Nothing but room tone? Don't decode (whisper hallucinates on silence) and
261
+ // don't let the buffer grow unbounded — keep only the last second.
262
+ if (rms(audio) < SILENCE_RMS) {
263
+ if (audio.length > SAMPLE_RATE * 5) {
264
+ s.chunks = [audio.subarray(audio.length - SAMPLE_RATE)];
265
+ s.samples = SAMPLE_RATE;
266
+ }
267
+ return;
268
+ }
269
+
270
+ // Auto-detect the spoken language on the session's first voiced audio (≥1s),
271
+ // then pin it. An explicit client `lang` wins; `.en` models skip all of this.
272
+ if (!s.lang && !s.langTried && !isEnglishOnly(_model) && audio.length >= SAMPLE_RATE) {
273
+ s.langTried = true;
274
+ const detected = await detectLanguage(audio);
275
+ if (detected) { s.lang = detected; emit(s, { type: 'language', lang: detected }); }
276
+ }
277
+
278
+ let text = '';
279
+ try {
280
+ const opts = isEnglishOnly(_model) ? {} : { language: s.lang || undefined, task: 'transcribe' };
281
+ const out = await _pipe(audio, opts);
282
+ text = String(out?.text || '').trim();
283
+ } catch (e) {
284
+ emit(s, { type: 'error', code: 'decode_failed', message: e.message, fatal: false });
285
+ return;
286
+ }
287
+ if (!text) return;
288
+
289
+ const tooLong = audio.length >= MAX_SEGMENT_S * SAMPLE_RATE;
290
+ const overflow = audio.length >= MAX_BUFFER_S * SAMPLE_RATE;
291
+ if (flush || trailingQuiet || tooLong || overflow) {
292
+ // Commit: the open segment becomes a final; the buffer restarts empty.
293
+ s.chunks = []; s.samples = 0; s.lastInterim = '';
294
+ emit(s, { type: 'final', text });
295
+ } else if (text !== s.lastInterim) {
296
+ s.lastInterim = text;
297
+ emit(s, { type: 'interim', text });
298
+ }
299
+ // More audio may have arrived while decoding — the next push reschedules.
300
+ }
301
+
302
+ // Stop a session: flush any remaining speech as a last final, notify, clean up.
303
+ export async function endSession(id) {
304
+ const s = getSession(id);
305
+ if (!s || s.closed) return false;
306
+ s.closed = true;
307
+ if (s.idleTimer) clearTimeout(s.idleTimer);
308
+ if (s.decodeTimer) { clearTimeout(s.decodeTimer); s.decodeTimer = null; }
309
+ try {
310
+ await (_decodeChain = _decodeChain.then(() => decodeSession(s, { flush: true }).catch(() => {})));
311
+ } finally {
312
+ emit(s, { type: 'end' });
313
+ s.listeners.clear();
314
+ _sessions.delete(s.id);
315
+ }
316
+ return true;
317
+ }
318
+
319
+ // Test hook: reset module state.
320
+ export function _reset() {
321
+ for (const s of _sessions.values()) {
322
+ if (s.idleTimer) clearTimeout(s.idleTimer);
323
+ if (s.decodeTimer) clearTimeout(s.decodeTimer);
324
+ }
325
+ _sessions.clear();
326
+ _state = 'off'; _model = null; _pipe = null; _err = null; _initPromise = null; _progress = null;
327
+ }
328
+
329
+ // Test hook: inject a fake pipeline (so session logic is testable without a model).
330
+ export function _setPipeForTest(pipe, model = 'test/fake') {
331
+ _pipe = pipe; _model = model; _state = pipe ? 'ready' : 'off';
332
+ }
@@ -0,0 +1,46 @@
1
+ // Catalog of speech-to-text (whisper) models the gateway can run, surfaced in the
2
+ // extension's Gateway settings and dictation UI. All are ONNX (transformers.js)
3
+ // automatic-speech-recognition models — same engine, download plumbing, and model
4
+ // root as the NER catalog (models.js). Sizes are the on-disk q8 footprint, approx.
5
+ //
6
+ // Adding a model: any onnx-community/* (or Xenova/*) whisper export works. Verify
7
+ // it loads + transcribes before listing it here. `.en` models are English-only and
8
+ // reject language/task options — sttDecodeOptions() below handles that split.
9
+
10
+ // Default is MULTILINGUAL on purpose: whisper auto-detects the spoken language
11
+ // per segment when no `language` is pinned — dictation must "just work" in any
12
+ // language without a setting. `.en` models are the speed opt-in, not the default.
13
+ export const DEFAULT_STT_MODEL = 'onnx-community/whisper-base';
14
+
15
+ export const STT_MODEL_CATALOG = [
16
+ {
17
+ id: 'onnx-community/whisper-base',
18
+ label: 'Multilingual — balanced',
19
+ lang: '99 languages (auto-detected)',
20
+ approxMB: 105,
21
+ note: 'Default. Detects the spoken language automatically; good accuracy.',
22
+ },
23
+ {
24
+ id: 'onnx-community/whisper-tiny.en',
25
+ label: 'English — fastest',
26
+ lang: 'English',
27
+ approxMB: 60,
28
+ note: 'Real-time on modest hardware; English only.',
29
+ },
30
+ {
31
+ id: 'onnx-community/whisper-small',
32
+ label: 'Multilingual — accurate',
33
+ lang: '99 languages (auto-detected)',
34
+ approxMB: 330,
35
+ note: 'Best accuracy; needs a faster machine for real-time use.',
36
+ },
37
+ ];
38
+
39
+ export function isKnownSttModel(id) {
40
+ return STT_MODEL_CATALOG.some((m) => m.id === id);
41
+ }
42
+
43
+ // English-only exports reject `language`/`task` generation options.
44
+ export function isEnglishOnly(id) {
45
+ return /\.en$/.test(String(id || ''));
46
+ }