md-semantic-search 1.0.3 → 2.0.0

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.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/src/models.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import crypto from 'node:crypto';
5
5
 
6
- export type Pooling = 'mean' | 'last_token' | 'none';
6
+ export type Pooling = 'mean' | 'last_token' | 'none' | 'cls';
7
7
  export type DType = 'q8' | 'q4' | 'fp16' | 'fp32' | 'int8' | 'uint8';
8
8
 
9
9
  export interface ModelAdapter {
@@ -19,6 +19,12 @@ export interface ModelAdapter {
19
19
  dtype?: DType | string;
20
20
  maxTokens?: number;
21
21
  sessionOptions?: Record<string, unknown>;
22
+ /**
23
+ * issue #200: the ONNX graph already emits a pooled `sentence_embedding`
24
+ * (e.g. EmbeddingGemma) — embedding bypasses the feature-extraction
25
+ * pipeline and reads that output directly; `pooling: 'none'` is implied.
26
+ */
27
+ pooledOutput?: boolean;
22
28
  family?: string;
23
29
  note?: string;
24
30
  unknownAdapter?: boolean;
@@ -68,6 +74,7 @@ export const MODELS: Record<string, ModelAdapter> = {
68
74
  id: 'Xenova/bge-m3',
69
75
  nativeDim: 1024,
70
76
  dim: 1024,
77
+ dimensions: [64, 128, 256, 512, 1024],
71
78
  queryPrefix: '',
72
79
  passagePrefix: '',
73
80
  pooling: 'mean',
@@ -82,6 +89,7 @@ export const MODELS: Record<string, ModelAdapter> = {
82
89
  revision: 'c25a394dd583836952667c12f008335071b3f43d',
83
90
  nativeDim: 1024,
84
91
  dim: 1024,
92
+ dimensions: [32, 64, 128, 256, 512, 768, 1024],
85
93
  queryPrefix: 'Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:',
86
94
  passagePrefix: '',
87
95
  pooling: 'last_token',
@@ -89,12 +97,42 @@ export const MODELS: Record<string, ModelAdapter> = {
89
97
  dtype: 'q8',
90
98
  maxTokens: 32768,
91
99
  family: 'qwen3',
92
- note: '~613 MB q8. Opt-in Qwen3 embedding model.',
100
+ note: '~613 MB q8. Opt-in Qwen3 embedding model with Matryoshka/MRL support.',
101
+ },
102
+ 'granite-multilingual-311m': {
103
+ id: 'onnx-community/granite-embedding-311m-multilingual-r2-ONNX',
104
+ revision: '8f039f21d4181327268271bea4b11ddcc7eef88d',
105
+ nativeDim: 768,
106
+ dim: 768,
107
+ queryPrefix: '',
108
+ passagePrefix: '',
109
+ pooling: 'cls',
110
+ normalize: true,
111
+ dtype: 'q8',
112
+ maxTokens: 8192,
113
+ family: 'granite',
114
+ note: '~298 MB q8. IBM Granite r2, ModernBERT (8k context), CLS pooling, Apache-2.0. Issue #200.',
115
+ },
116
+ 'embeddinggemma-300m': {
117
+ id: 'onnx-community/embeddinggemma-300m-ONNX',
118
+ revision: '5090578d9565bb06545b4552f76e6bc2c93e4a66',
119
+ nativeDim: 768,
120
+ dim: 768,
121
+ dimensions: [64, 128, 256, 512, 768],
122
+ queryPrefix: 'task: search result | query: ',
123
+ passagePrefix: 'title: none | text: ',
124
+ pooling: 'none',
125
+ normalize: true,
126
+ dtype: 'q8',
127
+ maxTokens: 2048,
128
+ pooledOutput: true,
129
+ family: 'gemma',
130
+ note: '~300 MB q8. Google EmbeddingGemma, pre-pooled sentence_embedding output, MRL. Gemma license (not MIT/Apache). Issue #200.',
93
131
  },
94
132
  };
95
133
 
96
134
  export const DEFAULT_MODEL = 'e5-base';
97
- export const POOLING: Pooling[] = ['mean', 'last_token', 'none'];
135
+ export const POOLING: Pooling[] = ['mean', 'last_token', 'none', 'cls'];
98
136
  const POOLING_SET = new Set<string>(POOLING);
99
137
 
100
138
  const aliasByRepoId = new Map<string, string>();
@@ -139,6 +177,7 @@ export function normalizeAdapter(raw: Partial<ModelAdapter> | ModelAdapter): Mod
139
177
  normalize: raw.normalize !== false,
140
178
  dtype: typeof raw.dtype === 'string' && raw.dtype.length > 0 ? raw.dtype : 'q8',
141
179
  ...(Number.isSafeInteger(raw.maxTokens) && raw.maxTokens && raw.maxTokens > 0 ? { maxTokens: raw.maxTokens } : {}),
180
+ ...(raw.pooledOutput === true ? { pooledOutput: true } : {}),
142
181
  ...(typeof raw.family === 'string' && raw.family.length > 0 ? { family: raw.family } : {}),
143
182
  ...(typeof raw.note === 'string' ? { note: raw.note } : {}),
144
183
  });
@@ -148,42 +187,65 @@ export function normalizeAdapter(raw: Partial<ModelAdapter> | ModelAdapter): Mod
148
187
  return adapter;
149
188
  }
150
189
 
151
- export function resolveModel(name?: string | Partial<ModelAdapter> | ModelAdapter | null): ModelAdapter {
190
+ export function resolveModel(
191
+ name?: string | Partial<ModelAdapter> | ModelAdapter | null,
192
+ chosenDim?: number
193
+ ): ModelAdapter {
194
+ let base: ModelAdapter;
152
195
  if (name === undefined || name === null || name === '') {
153
- return withDim(MODELS[DEFAULT_MODEL]);
154
- }
155
- if (typeof name === 'object' && !Array.isArray(name)) {
156
- return normalizeAdapter(name);
157
- }
158
- if (typeof name !== 'string') {
196
+ base = withDim(MODELS[DEFAULT_MODEL]);
197
+ } else if (typeof name === 'object' && !Array.isArray(name)) {
198
+ base = normalizeAdapter(name);
199
+ } else if (typeof name !== 'string') {
159
200
  throw new Error(`model must be an alias, id, or adapter object — got ${typeof name}`);
201
+ } else if (MODELS[name]) {
202
+ base = withDim(MODELS[name]);
203
+ } else {
204
+ let id = name;
205
+ let revision = 'main';
206
+ const at = name.indexOf('@');
207
+ const hasExplicitRevision = at > 0;
208
+ if (hasExplicitRevision) {
209
+ id = name.slice(0, at);
210
+ const requested = name.slice(at + 1);
211
+ revision = requested === '' ? 'main' : requested;
212
+ }
213
+ const alias = aliasByRepoId.get(id);
214
+ if (alias !== undefined) {
215
+ const registered = MODELS[alias];
216
+ const resolved = hasExplicitRevision ? { ...registered, revision } : registered;
217
+ base = withDim(resolved);
218
+ } else {
219
+ base = normalizeAdapter({
220
+ id,
221
+ ...(revision !== 'main' ? { revision } : {}),
222
+ pooling: 'none',
223
+ normalize: false,
224
+ nativeDim: 0,
225
+ dim: 0,
226
+ unknownAdapter: true,
227
+ });
228
+ }
160
229
  }
161
- if (MODELS[name]) return withDim(MODELS[name]);
162
230
 
163
- let id = name;
164
- let revision = 'main';
165
- const at = name.indexOf('@');
166
- const hasExplicitRevision = at > 0;
167
- if (hasExplicitRevision) {
168
- id = name.slice(0, at);
169
- const requested = name.slice(at + 1);
170
- revision = requested === '' ? 'main' : requested;
231
+ if (chosenDim !== undefined) {
232
+ if (typeof chosenDim !== 'number' || !Number.isSafeInteger(chosenDim) || chosenDim <= 0) {
233
+ throw new Error(`--dimensions must be a positive integer, got "${chosenDim}"`);
234
+ }
235
+ if (!base.dimensions || base.dimensions.length === 0) {
236
+ throw new Error(
237
+ `model "${base.id}" does not declare Matryoshka/MRL dimensions support (supported MRL models: qwen3-embedding-0.6b, bge-m3)`
238
+ );
239
+ }
240
+ if (!base.dimensions.includes(chosenDim)) {
241
+ throw new Error(
242
+ `invalid dimension ${chosenDim} for model "${base.id}" — supported MRL dimensions: ${base.dimensions.join(', ')}`
243
+ );
244
+ }
245
+ return { ...base, dim: chosenDim, nativeDim: base.nativeDim || base.dim };
171
246
  }
172
- const alias = aliasByRepoId.get(id);
173
- if (alias !== undefined) {
174
- const registered = MODELS[alias];
175
- const resolved = hasExplicitRevision ? { ...registered, revision } : registered;
176
- return withDim(resolved);
177
- }
178
- return normalizeAdapter({
179
- id,
180
- ...(revision !== 'main' ? { revision } : {}),
181
- pooling: 'none',
182
- normalize: false,
183
- nativeDim: 0,
184
- dim: 0,
185
- unknownAdapter: true,
186
- });
247
+
248
+ return base;
187
249
  }
188
250
 
189
251
  export interface FingerprintInput {
@@ -202,7 +264,7 @@ export function embeddingAdapterFingerprint(model: FingerprintInput): string {
202
264
  `passage-prefix:${model.passagePrefix ?? ''}`,
203
265
  `pooling:${model.pooling ?? 'mean'}`,
204
266
  `normalize:${model.normalize !== false}`,
205
- `dimension:${model.nativeDim ?? model.dim ?? 0}`,
267
+ `dimension:${model.dim ?? model.nativeDim ?? 0}`,
206
268
  ].join('\u0000');
207
269
  return `adapter-v1:${crypto.createHash('sha256').update(semantics).digest('hex')}`;
208
270
  }
package/src/providers.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { loadApiKeyFile } from './serve.js';
15
15
  import { ModelAdapter } from './models.js';
16
+ import { fetchTimeoutMs } from './core.js';
16
17
 
17
18
  export type EmbedderRole = 'query' | 'passage';
18
19
  export type ExternalEmbedFn = (
@@ -59,6 +60,9 @@ async function postJson(fetchImpl: typeof fetch, url: string, headers: Record<st
59
60
  method: 'POST',
60
61
  headers: { 'content-type': 'application/json', ...headers },
61
62
  body: JSON.stringify(body),
63
+ // issue #184: a hung embedder endpoint must fail into the retry path,
64
+ // not hold an index run / serve slot for undici's ~300s default
65
+ signal: AbortSignal.timeout(fetchTimeoutMs()),
62
66
  });
63
67
  if (!res.ok) {
64
68
  const text = await res.text().catch(() => '');
@@ -124,6 +128,10 @@ export function resolveExternalEmbedder(cfg: ProviderConfig): ResolvedEmbedder {
124
128
  family: cfg.embedder,
125
129
  };
126
130
  })();
131
+ // The probe starts eagerly; callers that only ever use embedFn never await
132
+ // it, so its rejection (e.g. the issue #184 fetch timeout) must not become
133
+ // an unhandled one. descriptor() re-surfaces the same rejection to awaiters.
134
+ descriptorPromise.catch(() => {});
127
135
 
128
136
  return {
129
137
  id: cfg.embedder,
@@ -132,3 +140,340 @@ export function resolveExternalEmbedder(cfg: ProviderConfig): ResolvedEmbedder {
132
140
  descriptor: () => descriptorPromise,
133
141
  };
134
142
  }
143
+
144
+ /**
145
+ * Chat provider (issue #199): one OpenAI-compatible `/chat/completions` path
146
+ * for answer synthesis — the same lingua franca HyDE already speaks — with an
147
+ * Ollama-native `/api/generate` legacy path (`--llm-provider ollama`) that
148
+ * preserves the pre-#199 wire format for existing setups.
149
+ */
150
+ export type ChatProviderId = 'openai' | 'ollama';
151
+
152
+ export interface ChatProviderConfig {
153
+ provider: ChatProviderId;
154
+ /** Provider-side model name; omitted bodies let the server pick its default. */
155
+ model?: string;
156
+ baseUrl?: string;
157
+ keyFile?: string;
158
+ /** Injected fetch for tests; defaults to globalThis.fetch. */
159
+ fetchImpl?: typeof fetch;
160
+ }
161
+
162
+ export interface ChatRequest {
163
+ system: string;
164
+ user: string;
165
+ /** Streaming render hook; fires once per token chunk when the wire streams. */
166
+ onDelta?: (delta: string) => void;
167
+ /** Cooperative cancel (serve: client disconnected mid-stream). */
168
+ signal?: AbortSignal;
169
+ }
170
+
171
+ export interface ResolvedChat {
172
+ id: ChatProviderId;
173
+ model: string;
174
+ /** Streams one completion; resolves to the assembled full text. */
175
+ complete(req: ChatRequest): Promise<string>;
176
+ }
177
+
178
+ /**
179
+ * Incremental SSE frame parser (issue #199): feed it raw network chunks, it
180
+ * emits one `data:` payload per complete event. Handles CRLF, LF-only streams,
181
+ * multi-line `data:` fields (joined with \n per the WHATWG spec), comment
182
+ * keep-alive lines (`: ping`), and chunks that split a line or a CRLF pair
183
+ * across the boundary. Non-`data` fields (event/id/retry) are irrelevant to
184
+ * the chat delta stream and dropped.
185
+ */
186
+ export function createSseParser(onData: (data: string) => void): { push(chunk: string): void; end(): void } {
187
+ let buf = '';
188
+ let dataLines: string[] = [];
189
+ const dispatch = () => {
190
+ if (dataLines.length > 0) {
191
+ const payload = dataLines.join('\n');
192
+ dataLines = [];
193
+ onData(payload);
194
+ }
195
+ };
196
+ const consumeLine = (rawLine: string) => {
197
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
198
+ if (line === '') {
199
+ dispatch();
200
+ return;
201
+ }
202
+ if (line.startsWith(':')) return; // comment / keep-alive
203
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
204
+ };
205
+ return {
206
+ push(chunk: string) {
207
+ buf += chunk;
208
+ let nl: number;
209
+ while ((nl = buf.indexOf('\n')) >= 0) {
210
+ const line = buf.slice(0, nl);
211
+ buf = buf.slice(nl + 1);
212
+ consumeLine(line);
213
+ }
214
+ },
215
+ end() {
216
+ if (buf !== '') {
217
+ consumeLine(buf);
218
+ buf = '';
219
+ }
220
+ dispatch();
221
+ },
222
+ };
223
+ }
224
+
225
+ /** `data:` payload of an OpenAI streaming chunk → delta text ('' when none). */
226
+ export function openAiDeltaText(payload: string): string {
227
+ if (payload === '[DONE]') return '';
228
+ try {
229
+ const json: any = JSON.parse(payload);
230
+ const text = json?.choices?.[0]?.delta?.content;
231
+ return typeof text === 'string' ? text : '';
232
+ } catch {
233
+ return '';
234
+ }
235
+ }
236
+
237
+ /** One Ollama NDJSON streaming line → delta text ('' when none). */
238
+ export function ollamaDeltaText(line: string): string {
239
+ const trimmed = line.trim();
240
+ if (!trimmed) return '';
241
+ try {
242
+ const json: any = JSON.parse(trimmed);
243
+ const text = json?.response;
244
+ return typeof text === 'string' ? text : '';
245
+ } catch {
246
+ return '';
247
+ }
248
+ }
249
+
250
+ function chatBaseOf(cfg: ChatProviderConfig): string {
251
+ const raw =
252
+ cfg.baseUrl ||
253
+ (cfg.provider === 'ollama' ? process.env.OLLAMA_HOST || 'http://127.0.0.1:11434' : 'https://api.openai.com/v1');
254
+ return raw.replace(/\/+$/, '');
255
+ }
256
+
257
+ function chatBearerKey(cfg: ChatProviderConfig): string | undefined {
258
+ if (cfg.provider !== 'openai') return undefined;
259
+ if (cfg.keyFile) return loadApiKeyFile(cfg.keyFile);
260
+ return process.env.OPENAI_API_KEY || undefined;
261
+ }
262
+
263
+ const IDLE_ABORT_REASON = () =>
264
+ new DOMException('The operation was aborted due to timeout', 'TimeoutError');
265
+
266
+ /**
267
+ * Streams one completion with an IDLE timeout (issue #199 × #184): the
268
+ * abort timer arms before connect and resets on every received chunk, so a
269
+ * hung endpoint fails fast while a long streaming generation is never killed
270
+ * mid-answer the way a whole-request `AbortSignal.timeout` would. An external
271
+ * `signal` (serve: client disconnected mid-stream) aborts immediately.
272
+ */
273
+ function makeIdleGuard(external?: AbortSignal) {
274
+ const controller = new AbortController();
275
+ const idleMs = fetchTimeoutMs();
276
+ let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => controller.abort(IDLE_ABORT_REASON()), idleMs);
277
+ (timer as any)?.unref?.();
278
+ const onExternalAbort = () => controller.abort(external!.reason);
279
+ if (external) {
280
+ if (external.aborted) onExternalAbort();
281
+ else external.addEventListener('abort', onExternalAbort, { once: true });
282
+ }
283
+ return {
284
+ signal: controller.signal,
285
+ /** Reset the idle window (after connect, after every chunk). */
286
+ arm() {
287
+ if (timer) clearTimeout(timer);
288
+ timer = setTimeout(() => controller.abort(IDLE_ABORT_REASON()), idleMs);
289
+ (timer as any)?.unref?.();
290
+ },
291
+ done() {
292
+ if (timer) {
293
+ clearTimeout(timer);
294
+ timer = null;
295
+ }
296
+ external?.removeEventListener('abort', onExternalAbort);
297
+ },
298
+ };
299
+ }
300
+
301
+ /** Line-splitting over arbitrary network chunks (Ollama NDJSON). */
302
+ function createLineParser(onLine: (line: string) => void): { push(chunk: string): void; end(): void } {
303
+ let buf = '';
304
+ return {
305
+ push(chunk: string) {
306
+ buf += chunk;
307
+ let nl: number;
308
+ while ((nl = buf.indexOf('\n')) >= 0) {
309
+ const line = buf.slice(0, nl).replace(/\r$/, '');
310
+ buf = buf.slice(nl + 1);
311
+ onLine(line);
312
+ }
313
+ },
314
+ end() {
315
+ if (buf !== '') {
316
+ onLine(buf.replace(/\r$/, ''));
317
+ buf = '';
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ function isLegacyFallbackEligible(err: unknown): boolean {
324
+ // Only a clearly OpenAI-incompatible shape (no such route) justifies trying
325
+ // the Ollama-native endpoint; auth/4xx/5xx errors must surface as-is.
326
+ return /HTTP 40[45]\b/.test(String((err as any)?.message || ''));
327
+ }
328
+
329
+ /**
330
+ * Build a chat completion provider over HTTP streaming (issue #199).
331
+ * `openai` → POST {base}/chat/completions with `stream:true`, SSE deltas.
332
+ * `ollama` → POST {base}/api/generate with `stream:true`, NDJSON deltas —
333
+ * the pre-#199 endpoint, upgraded from `stream:false` to streaming. The
334
+ * openai path additionally falls back to /api/generate once when the endpoint
335
+ * answers 404/405 (not OpenAI-shaped), so an Ollama root URL keeps working
336
+ * with default flags.
337
+ */
338
+ export function resolveChatProvider(cfg: ChatProviderConfig): ResolvedChat {
339
+ const fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
340
+ if (typeof fetchImpl !== 'function') throw new Error('chat: no fetch implementation available');
341
+ const base = chatBaseOf(cfg);
342
+
343
+ const streamOllamaGenerate = async (
344
+ system: string,
345
+ user: string,
346
+ onDelta: ((d: string) => void) | undefined,
347
+ signal: AbortSignal | undefined,
348
+ ): Promise<string> => {
349
+ const guard = makeIdleGuard(signal);
350
+ let res: Response;
351
+ try {
352
+ res = await fetchImpl(`${base}/api/generate`, {
353
+ method: 'POST',
354
+ headers: { 'content-type': 'application/json' },
355
+ signal: guard.signal,
356
+ body: JSON.stringify({
357
+ model: cfg.model || 'llama3',
358
+ // legacy wire format: one prompt string, byte-identical to the
359
+ // pre-#199 single-string synthesis prompt
360
+ prompt: system + user,
361
+ stream: true,
362
+ }),
363
+ });
364
+ } catch (e) {
365
+ guard.done();
366
+ throw e;
367
+ }
368
+ if (!res.ok) {
369
+ guard.done();
370
+ const text = await res.text().catch(() => '');
371
+ throw new Error(`chat ${base}/api/generate -> HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
372
+ }
373
+ let full = '';
374
+ const parser = createLineParser((line) => {
375
+ const delta = ollamaDeltaText(line);
376
+ if (delta) {
377
+ full += delta;
378
+ onDelta?.(delta);
379
+ }
380
+ });
381
+ const decoder = new TextDecoder();
382
+ try {
383
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array | string>) {
384
+ parser.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
385
+ guard.arm();
386
+ }
387
+ parser.push(decoder.decode());
388
+ parser.end();
389
+ } finally {
390
+ guard.done();
391
+ }
392
+ return full;
393
+ };
394
+
395
+ const streamOpenAiChat = async (
396
+ system: string,
397
+ user: string,
398
+ onDelta: ((d: string) => void) | undefined,
399
+ signal: AbortSignal | undefined,
400
+ ): Promise<string> => {
401
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
402
+ const key = chatBearerKey(cfg);
403
+ if (key) headers.authorization = `Bearer ${key}`;
404
+ const guard = makeIdleGuard(signal);
405
+ let res: Response;
406
+ try {
407
+ res = await fetchImpl(`${base}/chat/completions`, {
408
+ method: 'POST',
409
+ headers,
410
+ signal: guard.signal,
411
+ body: JSON.stringify({
412
+ ...(cfg.model ? { model: cfg.model } : {}),
413
+ messages: [
414
+ { role: 'system', content: system },
415
+ { role: 'user', content: user },
416
+ ],
417
+ stream: true,
418
+ }),
419
+ });
420
+ } catch (e) {
421
+ guard.done();
422
+ throw e;
423
+ }
424
+ if (!res.ok) {
425
+ guard.done();
426
+ const text = await res.text().catch(() => '');
427
+ throw new Error(`chat ${base}/chat/completions -> HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
428
+ }
429
+ let full = '';
430
+ let wireDone = false;
431
+ const parser = createSseParser((payload) => {
432
+ if (payload === '[DONE]') {
433
+ wireDone = true;
434
+ return;
435
+ }
436
+ const delta = openAiDeltaText(payload);
437
+ if (delta) {
438
+ full += delta;
439
+ onDelta?.(delta);
440
+ }
441
+ });
442
+ const decoder = new TextDecoder();
443
+ try {
444
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array | string>) {
445
+ parser.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
446
+ guard.arm();
447
+ // leaving the for-await on [DONE] cancels the stream through the
448
+ // iterator's return() — a manual cancel() here would just reject
449
+ // with "ReadableStream is locked"
450
+ if (wireDone) break;
451
+ }
452
+ if (!wireDone) {
453
+ parser.push(decoder.decode());
454
+ parser.end();
455
+ }
456
+ } finally {
457
+ guard.done();
458
+ }
459
+ return full;
460
+ };
461
+
462
+ return {
463
+ id: cfg.provider,
464
+ model: cfg.model || '',
465
+ async complete(req) {
466
+ if (cfg.provider === 'ollama') {
467
+ return streamOllamaGenerate(req.system, req.user, req.onDelta, req.signal);
468
+ }
469
+ try {
470
+ return await streamOpenAiChat(req.system, req.user, req.onDelta, req.signal);
471
+ } catch (e) {
472
+ if (isLegacyFallbackEligible(e)) {
473
+ return streamOllamaGenerate(req.system, req.user, req.onDelta, req.signal);
474
+ }
475
+ throw e;
476
+ }
477
+ },
478
+ };
479
+ }
@@ -91,3 +91,64 @@ export function asymmetricCosineInt8(queryFp32: Float32Array, chunkInt8: Int8Arr
91
91
  if (normSq <= 0) return 0.0;
92
92
  return dot / Math.sqrt(normSq);
93
93
  }
94
+
95
+ /**
96
+ * Precomputed int8 chunk norm = sqrt(sum(c_i^2)) (issue #190): constant per
97
+ * chunk, so caching it at load time removes half of the asymmetric sweep.
98
+ * Accumulates in the same order as `asymmetricCosineInt8` so the cached-norm
99
+ * fast path returns bitwise-equal scores.
100
+ */
101
+ export function int8Norm(chunkInt8: Int8Array): number {
102
+ const dim = chunkInt8.length;
103
+ let normSq = 0.0;
104
+
105
+ let i = 0;
106
+ const limit = dim - (dim % 8);
107
+ for (; i < limit; i += 8) {
108
+ normSq +=
109
+ chunkInt8[i] * chunkInt8[i] +
110
+ chunkInt8[i + 1] * chunkInt8[i + 1] +
111
+ chunkInt8[i + 2] * chunkInt8[i + 2] +
112
+ chunkInt8[i + 3] * chunkInt8[i + 3] +
113
+ chunkInt8[i + 4] * chunkInt8[i + 4] +
114
+ chunkInt8[i + 5] * chunkInt8[i + 5] +
115
+ chunkInt8[i + 6] * chunkInt8[i + 6] +
116
+ chunkInt8[i + 7] * chunkInt8[i + 7];
117
+ }
118
+ for (; i < dim; i++) {
119
+ normSq += chunkInt8[i] * chunkInt8[i];
120
+ }
121
+ return Math.sqrt(normSq);
122
+ }
123
+
124
+ /**
125
+ * Dot-only sweep fast path (issue #190): identical to `asymmetricCosineInt8`
126
+ * when `chunkNorm` came from `int8Norm` on the same vector.
127
+ */
128
+ export function asymmetricCosineInt8CachedNorm(
129
+ queryFp32: Float32Array,
130
+ chunkInt8: Int8Array,
131
+ chunkNorm: number,
132
+ ): number {
133
+ if (!(chunkNorm > 0)) return 0.0;
134
+ const dim = queryFp32.length;
135
+ let dot = 0.0;
136
+
137
+ let i = 0;
138
+ const limit = dim - (dim % 8);
139
+ for (; i < limit; i += 8) {
140
+ dot +=
141
+ queryFp32[i] * chunkInt8[i] +
142
+ queryFp32[i + 1] * chunkInt8[i + 1] +
143
+ queryFp32[i + 2] * chunkInt8[i + 2] +
144
+ queryFp32[i + 3] * chunkInt8[i + 3] +
145
+ queryFp32[i + 4] * chunkInt8[i + 4] +
146
+ queryFp32[i + 5] * chunkInt8[i + 5] +
147
+ queryFp32[i + 6] * chunkInt8[i + 6] +
148
+ queryFp32[i + 7] * chunkInt8[i + 7];
149
+ }
150
+ for (; i < dim; i++) {
151
+ dot += queryFp32[i] * chunkInt8[i];
152
+ }
153
+ return dot / chunkNorm;
154
+ }