md-semantic-search 1.1.0 → 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 (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +182 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -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/indexer.d.ts +17 -1
  15. package/dist/indexer.js +91 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +414 -0
  50. package/src/indexer.ts +107 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. package/src/webui.ts +201 -2
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
+ }
package/dist/rag.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LoadedIndex, SearchResultHit } from './search.js';
2
+ import { ChatProviderId, ResolvedChat } from './providers.js';
2
3
  export interface Citation {
3
4
  file: string;
4
5
  heading?: string;
@@ -7,7 +8,25 @@ export interface Citation {
7
8
  export interface SynthesisResult {
8
9
  answer: string;
9
10
  citations: Citation[];
11
+ /**
12
+ * issue #199 follow-up: citations the answer actually mentions (bracket
13
+ * formats `[file#heading]` / `[file › heading]` / `[file]`), in order of
14
+ * first mention. Deterministic post-processing of the LLM text — a subset
15
+ * of `citations`, which stays the full retrieval manifest for compatibility.
16
+ */
17
+ citationsUsed?: Citation[];
10
18
  passages: SearchResultHit[];
19
+ /** Which synthesis stage produced the answer (issue #197 observability). */
20
+ mode?: 'llm' | 'endpoint' | 'extractive';
21
+ }
22
+ /**
23
+ * System/user split for transports that keep the two apart on the wire —
24
+ * MCP `sampling/createMessage` (issue #197) carries a `systemPrompt` field
25
+ * and a message list, unlike the single-string local LLM bridge.
26
+ */
27
+ export interface LlmStructuredRequest {
28
+ system: string;
29
+ user: string;
11
30
  }
12
31
  export interface ChatSourceManifestItem {
13
32
  file: string;
@@ -25,6 +44,8 @@ export interface ChatTurn {
25
44
  answer: string;
26
45
  citations: Citation[];
27
46
  manifest: ChatSourceManifestItem[];
47
+ /** Which synthesis stage produced the answer (issue #199; optional in persisted sessions). */
48
+ mode?: SynthesisResult['mode'];
28
49
  }
29
50
  export interface ChatSession {
30
51
  id: string;
@@ -41,9 +62,17 @@ export interface AskOptions {
41
62
  cacheDir?: string;
42
63
  db?: string;
43
64
  k?: number;
65
+ offline?: boolean;
44
66
  llmEndpoint?: string;
45
67
  llmModel?: string;
68
+ /** issue #199: chat wire format for llmEndpoint — 'openai' (default) or 'ollama'. */
69
+ llmProvider?: ChatProviderId;
46
70
  llmFn?: (prompt: string) => Promise<string>;
71
+ llmRequestFn?: (req: LlmStructuredRequest) => Promise<string>;
72
+ /** issue #199: incremental render hook — fires per token chunk while the LLM streams. */
73
+ onDelta?: (delta: string) => void;
74
+ /** issue #199: injection seam for tests/serve; built from llmEndpoint when absent. */
75
+ llmChat?: ResolvedChat;
47
76
  systemPrompt?: string;
48
77
  embedFn?: any;
49
78
  }
@@ -55,9 +84,17 @@ export interface ChatTurnOptions {
55
84
  cacheDir?: string;
56
85
  db?: string;
57
86
  k?: number;
87
+ offline?: boolean;
58
88
  llmEndpoint?: string;
59
89
  llmModel?: string;
90
+ /** issue #199: chat wire format for llmEndpoint — 'openai' (default) or 'ollama'. */
91
+ llmProvider?: ChatProviderId;
60
92
  llmFn?: (prompt: string) => Promise<string>;
93
+ llmRequestFn?: (req: LlmStructuredRequest) => Promise<string>;
94
+ /** issue #199: incremental render hook — fires per token chunk while the LLM streams. */
95
+ onDelta?: (delta: string) => void;
96
+ /** issue #199: injection seam for tests/serve; built from llmEndpoint when absent. */
97
+ llmChat?: ResolvedChat;
61
98
  systemPrompt?: string;
62
99
  embedFn?: any;
63
100
  log?: (msg: string) => void;
@@ -67,6 +104,10 @@ export declare const DEFAULT_RAG_SYSTEM_PROMPT: string;
67
104
  * Returns the directory where chat sessions are persisted.
68
105
  */
69
106
  export declare function getSessionsDir(indexDir: string): string;
107
+ /**
108
+ * Validates a session id before it is used as a filesystem path component.
109
+ */
110
+ export declare function assertSafeSessionId(id: string): string;
70
111
  /**
71
112
  * Atomically saves a ChatSession to disk (<indexDir>/sessions/<id>.json).
72
113
  */
@@ -88,13 +129,28 @@ export declare function listChatSessions(indexDir: string): Array<{
88
129
  * Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
89
130
  */
90
131
  export declare function extractAnswerFallback(query: string, passages: SearchResultHit[]): string;
132
+ /**
133
+ * issue #199 follow-up: which of the retrieved citations the answer text
134
+ * actually cites. Bracket-only scan (the exact formats the system prompt and
135
+ * the extractive fallback emit), exact file/heading match against the
136
+ * retrieval manifest, order of first mention. No mention → [].
137
+ */
138
+ export declare function extractUsedCitations(answer: string, citations: Citation[]): Citation[];
91
139
  /**
92
140
  * Synthesizes an answer from retrieved passages using an LLM or fallback.
93
141
  */
94
142
  export declare function synthesizeAnswer(query: string, passages: SearchResultHit[], options?: {
95
143
  llmFn?: (prompt: string) => Promise<string>;
144
+ llmRequestFn?: (req: LlmStructuredRequest) => Promise<string>;
96
145
  llmEndpoint?: string;
97
146
  llmModel?: string;
147
+ /** issue #199: chat wire format for llmEndpoint — 'openai' (default) or 'ollama'. */
148
+ llmProvider?: ChatProviderId;
149
+ llmChat?: ResolvedChat;
150
+ /** issue #199: incremental render hook — fires per token chunk while the LLM streams. */
151
+ onDelta?: (delta: string) => void;
152
+ /** Cooperative cancel (serve /ask: client disconnected mid-stream). */
153
+ signal?: AbortSignal;
98
154
  systemPrompt?: string;
99
155
  conversationHistory?: string;
100
156
  }): Promise<SynthesisResult>;
@@ -111,4 +167,8 @@ export declare function chatTurn(opts: ChatTurnOptions): Promise<{
111
167
  answer: string;
112
168
  citations: Citation[];
113
169
  manifest: ChatSourceManifestItem[];
170
+ /** Which synthesis stage produced the answer ('llm'/'endpoint'/'extractive'). */
171
+ mode: SynthesisResult['mode'];
172
+ /** Citations the answer text actually mentions (issue #199 follow-up). */
173
+ citationsUsed: Citation[];
114
174
  }>;