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