md-semantic-search 0.5.0 → 1.0.2

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 (52) hide show
  1. package/README.md +44 -1
  2. package/bin/cli.mjs +144 -30
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/core.d.ts +3 -2
  6. package/dist/core.js +26 -31
  7. package/dist/federation.d.ts +15 -0
  8. package/dist/federation.js +94 -0
  9. package/dist/filter.d.ts +29 -0
  10. package/dist/filter.js +317 -0
  11. package/dist/index-format.js +1 -1
  12. package/dist/index.d.ts +9 -1
  13. package/dist/index.js +8 -0
  14. package/dist/indexer.d.ts +13 -0
  15. package/dist/indexer.js +21 -7
  16. package/dist/markdown-parser.d.ts +1 -0
  17. package/dist/markdown-parser.js +71 -12
  18. package/dist/mcp.d.ts +54 -6
  19. package/dist/mcp.js +59 -11
  20. package/dist/quantization.d.ts +24 -0
  21. package/dist/quantization.js +85 -0
  22. package/dist/rag.d.ts +49 -0
  23. package/dist/rag.js +128 -0
  24. package/dist/search.d.ts +20 -6
  25. package/dist/search.js +175 -23
  26. package/dist/serve.js +44 -3
  27. package/dist/summarize.d.ts +16 -0
  28. package/dist/summarize.js +82 -0
  29. package/dist/tui.d.ts +1 -0
  30. package/dist/tui.js +36 -13
  31. package/dist/watcher.d.ts +11 -0
  32. package/dist/watcher.js +138 -0
  33. package/dist/wikilinks.d.ts +22 -0
  34. package/dist/wikilinks.js +100 -0
  35. package/package.json +3 -1
  36. package/src/binary-format.ts +216 -0
  37. package/src/core.ts +45 -37
  38. package/src/federation.ts +118 -0
  39. package/src/filter.ts +368 -0
  40. package/src/index-format.ts +1 -1
  41. package/src/index.ts +39 -1
  42. package/src/indexer.ts +42 -10
  43. package/src/markdown-parser.ts +80 -10
  44. package/src/mcp.ts +57 -11
  45. package/src/quantization.ts +93 -0
  46. package/src/rag.ts +189 -0
  47. package/src/search.ts +202 -25
  48. package/src/serve.ts +46 -4
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +157 -0
  52. package/src/wikilinks.ts +132 -0
@@ -183,7 +183,7 @@ export function chunkMarkdownStructural(input, opts) {
183
183
  if (blockText.length > maxChunk) {
184
184
  flushGroup();
185
185
  const headingPath = headingStack.map(h => h.heading);
186
- const subPieces = splitOversizedBlock(blockText, maxChunk);
186
+ const subPieces = splitOversizedBlock(blockText, maxChunk, block.type);
187
187
  let lineCursor = block.startLine;
188
188
  for (let idx = 0; idx < subPieces.length; idx++) {
189
189
  const piece = subPieces[idx];
@@ -213,8 +213,73 @@ export function chunkMarkdownStructural(input, opts) {
213
213
  flushGroup();
214
214
  return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
215
215
  }
216
- function splitOversizedBlock(text, maxChunk) {
216
+ function splitLongLine(line, maxChunk) {
217
+ const pieces = [];
218
+ let rest = line;
219
+ while (rest.length > maxChunk) {
220
+ let cut = rest.lastIndexOf(' ', maxChunk);
221
+ if (cut <= 0)
222
+ cut = maxChunk;
223
+ pieces.push(rest.slice(0, cut).trim());
224
+ rest = rest.slice(cut).trim();
225
+ }
226
+ if (rest)
227
+ pieces.push(rest);
228
+ return pieces;
229
+ }
230
+ export function splitOversizedBlock(text, maxChunk, blockType) {
217
231
  const lines = text.split('\n');
232
+ if (lines.length <= 1) {
233
+ return splitLongLine(text, maxChunk);
234
+ }
235
+ // If code block with opening fence
236
+ if (blockType === 'code' && lines.length > 2 && /^(\s*)(`{3,}|~{3,})/.test(lines[0])) {
237
+ const openFence = lines[0];
238
+ const hasCloseFence = /^(\s*)(`{3,}|~{3,})/.test(lines[lines.length - 1]);
239
+ const closeFence = hasCloseFence ? lines[lines.length - 1] : '```';
240
+ const innerLines = lines.slice(1, hasCloseFence ? lines.length - 1 : lines.length);
241
+ const pieces = [];
242
+ let currentCode = [];
243
+ let currentLen = openFence.length + closeFence.length + 2;
244
+ for (const line of innerLines) {
245
+ if (currentLen + line.length + 1 > maxChunk && currentCode.length > 0) {
246
+ pieces.push([openFence, ...currentCode, closeFence].join('\n'));
247
+ currentCode = [];
248
+ currentLen = openFence.length + closeFence.length + 2;
249
+ }
250
+ currentCode.push(line);
251
+ currentLen += line.length + 1;
252
+ }
253
+ if (currentCode.length > 0) {
254
+ pieces.push([openFence, ...currentCode, closeFence].join('\n'));
255
+ }
256
+ if (pieces.length > 0)
257
+ return pieces;
258
+ }
259
+ // If table with header + separator rows
260
+ if (blockType === 'table' && lines.length > 2 && /^\s*\|?\s*[-:]+[-|\s:]*$/.test(lines[1])) {
261
+ const headerRows = [lines[0], lines[1]];
262
+ const dataRows = lines.slice(2);
263
+ const headerLen = headerRows[0].length + headerRows[1].length + 2;
264
+ const pieces = [];
265
+ let currentRows = [];
266
+ let currentLen = headerLen;
267
+ for (const row of dataRows) {
268
+ if (currentLen + row.length + 1 > maxChunk && currentRows.length > 0) {
269
+ pieces.push([...headerRows, ...currentRows].join('\n'));
270
+ currentRows = [];
271
+ currentLen = headerLen;
272
+ }
273
+ currentRows.push(row);
274
+ currentLen += row.length + 1;
275
+ }
276
+ if (currentRows.length > 0) {
277
+ pieces.push([...headerRows, ...currentRows].join('\n'));
278
+ }
279
+ if (pieces.length > 0)
280
+ return pieces;
281
+ }
282
+ // Standard paragraph/list splitting
218
283
  const pieces = [];
219
284
  let currentLines = [];
220
285
  let currentLen = 0;
@@ -229,17 +294,11 @@ function splitOversizedBlock(text, maxChunk) {
229
294
  pieces.push(currentLines.join('\n').trim());
230
295
  currentLines = [];
231
296
  }
232
- let rest = line;
233
- while (rest.length > maxChunk) {
234
- let cut = rest.lastIndexOf(' ', maxChunk);
235
- if (cut <= 0)
236
- cut = maxChunk;
237
- pieces.push(rest.slice(0, cut).trim());
238
- rest = rest.slice(cut).trim();
297
+ const splitLines = splitLongLine(line, maxChunk);
298
+ for (const piece of splitLines) {
299
+ pieces.push(piece);
239
300
  }
240
- if (rest)
241
- currentLines.push(rest);
242
- currentLen = rest.length;
301
+ currentLen = 0;
243
302
  continue;
244
303
  }
245
304
  currentLines.push(line);
package/dist/mcp.d.ts CHANGED
@@ -22,6 +22,13 @@ export declare const MCP_TOOLS: ({
22
22
  type: string;
23
23
  description: string;
24
24
  };
25
+ vaults: {
26
+ type: string;
27
+ items: {
28
+ type: string;
29
+ };
30
+ description: string;
31
+ };
25
32
  tag: {
26
33
  type: string;
27
34
  items: {
@@ -29,6 +36,14 @@ export declare const MCP_TOOLS: ({
29
36
  };
30
37
  description: string;
31
38
  };
39
+ filter: {
40
+ type: string;
41
+ description: string;
42
+ };
43
+ graphBoost: {
44
+ type: string;
45
+ description: string;
46
+ };
32
47
  file?: undefined;
33
48
  heading?: undefined;
34
49
  };
@@ -40,9 +55,10 @@ export declare const MCP_TOOLS: ({
40
55
  inputSchema: {
41
56
  type: string;
42
57
  properties: {
43
- query?: undefined;
44
- k?: undefined;
58
+ vaults?: undefined;
45
59
  tag?: undefined;
60
+ filter?: undefined;
61
+ graphBoost?: undefined;
46
62
  file: {
47
63
  type: string;
48
64
  description: string;
@@ -52,6 +68,8 @@ export declare const MCP_TOOLS: ({
52
68
  description: string;
53
69
  };
54
70
  path?: undefined;
71
+ query?: undefined;
72
+ k?: undefined;
55
73
  };
56
74
  required: string[];
57
75
  };
@@ -59,34 +77,64 @@ export declare const MCP_TOOLS: ({
59
77
  name: string;
60
78
  description: string;
61
79
  inputSchema: {
62
- required?: undefined;
63
80
  type: string;
64
81
  properties: {
65
- query?: undefined;
66
- k?: undefined;
82
+ vaults?: undefined;
67
83
  tag?: undefined;
84
+ filter?: undefined;
85
+ graphBoost?: undefined;
68
86
  file?: undefined;
69
87
  heading?: undefined;
70
88
  path: {
71
89
  type: string;
72
90
  description: string;
73
91
  };
92
+ query?: undefined;
93
+ k?: undefined;
74
94
  };
95
+ required?: undefined;
75
96
  };
76
97
  } | {
77
98
  name: string;
78
99
  description: string;
79
100
  inputSchema: {
80
- required?: undefined;
81
101
  type: string;
82
102
  properties: {
103
+ vaults?: undefined;
104
+ tag?: undefined;
105
+ filter?: undefined;
106
+ graphBoost?: undefined;
107
+ file?: undefined;
108
+ heading?: undefined;
109
+ path?: undefined;
83
110
  query?: undefined;
84
111
  k?: undefined;
112
+ };
113
+ required?: undefined;
114
+ };
115
+ } | {
116
+ name: string;
117
+ description: string;
118
+ inputSchema: {
119
+ type: string;
120
+ properties: {
121
+ vaults?: undefined;
85
122
  tag?: undefined;
123
+ filter?: undefined;
124
+ graphBoost?: undefined;
86
125
  file?: undefined;
87
126
  heading?: undefined;
88
127
  path?: undefined;
128
+ query: {
129
+ type: string;
130
+ description: string;
131
+ };
132
+ k: {
133
+ type: string;
134
+ description: string;
135
+ };
89
136
  };
137
+ required: string[];
90
138
  };
91
139
  })[];
92
140
  export declare function handleMcpRequest(req: any, state: {
package/dist/mcp.js CHANGED
@@ -8,6 +8,8 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { buildIndex } from './indexer.js';
10
10
  import { loadIndex, searchIndex } from './search.js';
11
+ import { searchFederated } from './federation.js';
12
+ import { askQuestion } from './rag.js';
11
13
  import { globToRegExp } from './core.js';
12
14
  export const MCP_TOOLS = [
13
15
  {
@@ -19,7 +21,10 @@ export const MCP_TOOLS = [
19
21
  query: { type: 'string', description: 'Natural language search query' },
20
22
  k: { type: 'number', description: 'Number of top results to return (default: 6)' },
21
23
  path: { type: 'string', description: 'Optional path glob filter (e.g. "docs/**")' },
24
+ vaults: { type: 'array', items: { type: 'string' }, description: 'Optional list of vault directories for federated search' },
22
25
  tag: { type: 'array', items: { type: 'string' }, description: 'Optional frontmatter tag filters' },
26
+ filter: { type: 'string', description: 'Optional rich boolean filter expression (e.g. "tag:engineering AND status != archived")' },
27
+ graphBoost: { type: 'number', description: 'Optional boost weight for graph PageRank / wikilinks (0.0 to 1.0)' },
23
28
  },
24
29
  required: ['query'],
25
30
  },
@@ -54,6 +59,18 @@ export const MCP_TOOLS = [
54
59
  properties: {},
55
60
  },
56
61
  },
62
+ {
63
+ name: 'ask_knowledge_base',
64
+ description: 'Synthesize a grounded direct answer with citations from the local Markdown knowledge base',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ query: { type: 'string', description: 'Question to ask the knowledge base' },
69
+ k: { type: 'number', description: 'Number of candidate passages to retrieve (default: 5)' },
70
+ },
71
+ required: ['query'],
72
+ },
73
+ },
57
74
  ];
58
75
  export async function handleMcpRequest(req, state) {
59
76
  if (!req || typeof req !== 'object')
@@ -73,7 +90,7 @@ export async function handleMcpRequest(req, state) {
73
90
  },
74
91
  serverInfo: {
75
92
  name: 'md-semantic-search',
76
- version: '0.4.0',
93
+ version: '1.0.0',
77
94
  },
78
95
  },
79
96
  };
@@ -95,16 +112,35 @@ export async function handleMcpRequest(req, state) {
95
112
  if (name === 'search_markdown') {
96
113
  const query = typeof args.query === 'string' ? args.query : '';
97
114
  const k = Number.isInteger(args.k) && args.k > 0 ? args.k : 6;
98
- const results = await searchIndex({
99
- loaded: state.loaded,
100
- cacheDir: state.cacheDir,
101
- query,
102
- k,
103
- path: args.path,
104
- tag: args.tag,
105
- offline: state.offline,
106
- embedFn: state.embedFn,
107
- });
115
+ let results;
116
+ if (Array.isArray(args.vaults) && args.vaults.length > 0) {
117
+ results = await searchFederated({
118
+ vaults: args.vaults,
119
+ cacheDir: state.cacheDir,
120
+ query,
121
+ k,
122
+ path: args.path,
123
+ tag: args.tag,
124
+ filter: typeof args.filter === 'string' ? args.filter : undefined,
125
+ graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
126
+ offline: state.offline,
127
+ embedFn: state.embedFn,
128
+ });
129
+ }
130
+ else {
131
+ results = await searchIndex({
132
+ loaded: state.loaded,
133
+ cacheDir: state.cacheDir,
134
+ query,
135
+ k,
136
+ path: args.path,
137
+ tag: args.tag,
138
+ filter: typeof args.filter === 'string' ? args.filter : undefined,
139
+ graphBoost: typeof args.graphBoost === 'number' ? args.graphBoost : undefined,
140
+ offline: state.offline,
141
+ embedFn: state.embedFn,
142
+ });
143
+ }
108
144
  content = [{ type: 'text', text: JSON.stringify(results, null, 2) }];
109
145
  }
110
146
  else if (name === 'get_chunk') {
@@ -141,6 +177,18 @@ export async function handleMcpRequest(req, state) {
141
177
  };
142
178
  content = [{ type: 'text', text: JSON.stringify(info, null, 2) }];
143
179
  }
180
+ else if (name === 'ask_knowledge_base') {
181
+ const query = args.query;
182
+ const k = typeof args.k === 'number' ? args.k : 5;
183
+ const answerResult = await askQuestion({
184
+ loaded: state.loaded,
185
+ cacheDir: state.cacheDir,
186
+ query,
187
+ k,
188
+ embedFn: state.embedFn,
189
+ });
190
+ content = [{ type: 'text', text: JSON.stringify(answerResult, null, 2) }];
191
+ }
144
192
  else {
145
193
  return {
146
194
  jsonrpc: '2.0',
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Int8 Scalar Quantization Tier (issue #96).
3
+ *
4
+ * Maps normalized Float32 unit vectors in [-1.0, 1.0] to signed 8-bit integers in [-128, 127],
5
+ * providing 4x memory savings with <1% loss in ranking precision.
6
+ */
7
+ export declare const INT8_SCALE = 127;
8
+ export declare const INT8_INV_SCALE: number;
9
+ /**
10
+ * Quantizes an L2-normalized float vector into a signed Int8Array.
11
+ */
12
+ export declare function quantizeToInt8(vec: ArrayLike<number>): Int8Array;
13
+ /**
14
+ * Dequantizes an Int8Array back into an approximate Float32Array.
15
+ */
16
+ export declare function dequantizeFromInt8(vec: Int8Array): Float32Array;
17
+ /**
18
+ * Computes asymmetric cosine similarity between a normalized Float32 query vector
19
+ * and a quantized Int8 chunk vector.
20
+ *
21
+ * Since query is unit length (||q|| = 1.0),
22
+ * cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
23
+ */
24
+ export declare function asymmetricCosineInt8(queryFp32: Float32Array, chunkInt8: Int8Array): number;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Int8 Scalar Quantization Tier (issue #96).
3
+ *
4
+ * Maps normalized Float32 unit vectors in [-1.0, 1.0] to signed 8-bit integers in [-128, 127],
5
+ * providing 4x memory savings with <1% loss in ranking precision.
6
+ */
7
+ export const INT8_SCALE = 127.0;
8
+ export const INT8_INV_SCALE = 1.0 / 127.0;
9
+ /**
10
+ * Quantizes an L2-normalized float vector into a signed Int8Array.
11
+ */
12
+ export function quantizeToInt8(vec) {
13
+ const len = vec.length;
14
+ const out = new Int8Array(len);
15
+ for (let i = 0; i < len; i++) {
16
+ const val = vec[i];
17
+ // Clamp to [-1.0, 1.0] and scale to [-128, 127]
18
+ const clamped = val < -1.0 ? -1.0 : val > 1.0 ? 1.0 : val;
19
+ const scaled = Math.round(clamped * INT8_SCALE);
20
+ out[i] = scaled < -128 ? -128 : scaled > 127 ? 127 : scaled;
21
+ }
22
+ return out;
23
+ }
24
+ /**
25
+ * Dequantizes an Int8Array back into an approximate Float32Array.
26
+ */
27
+ export function dequantizeFromInt8(vec) {
28
+ const len = vec.length;
29
+ const out = new Float32Array(len);
30
+ for (let i = 0; i < len; i++) {
31
+ out[i] = vec[i] * INT8_INV_SCALE;
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Computes asymmetric cosine similarity between a normalized Float32 query vector
37
+ * and a quantized Int8 chunk vector.
38
+ *
39
+ * Since query is unit length (||q|| = 1.0),
40
+ * cosine(q, c) = (q · c_fp32) / ||c_fp32|| = (sum(q_i * c_i) / 127) / (||c|| / 127) = sum(q_i * c_i) / ||c||.
41
+ */
42
+ export function asymmetricCosineInt8(queryFp32, chunkInt8) {
43
+ const dim = queryFp32.length;
44
+ let dot = 0.0;
45
+ let normSq = 0.0;
46
+ // Unroll loop in steps of 8 for performance
47
+ let i = 0;
48
+ const limit = dim - (dim % 8);
49
+ for (; i < limit; i += 8) {
50
+ const c0 = chunkInt8[i];
51
+ const c1 = chunkInt8[i + 1];
52
+ const c2 = chunkInt8[i + 2];
53
+ const c3 = chunkInt8[i + 3];
54
+ const c4 = chunkInt8[i + 4];
55
+ const c5 = chunkInt8[i + 5];
56
+ const c6 = chunkInt8[i + 6];
57
+ const c7 = chunkInt8[i + 7];
58
+ dot +=
59
+ queryFp32[i] * c0 +
60
+ queryFp32[i + 1] * c1 +
61
+ queryFp32[i + 2] * c2 +
62
+ queryFp32[i + 3] * c3 +
63
+ queryFp32[i + 4] * c4 +
64
+ queryFp32[i + 5] * c5 +
65
+ queryFp32[i + 6] * c6 +
66
+ queryFp32[i + 7] * c7;
67
+ normSq +=
68
+ c0 * c0 +
69
+ c1 * c1 +
70
+ c2 * c2 +
71
+ c3 * c3 +
72
+ c4 * c4 +
73
+ c5 * c5 +
74
+ c6 * c6 +
75
+ c7 * c7;
76
+ }
77
+ for (; i < dim; i++) {
78
+ const c = chunkInt8[i];
79
+ dot += queryFp32[i] * c;
80
+ normSq += c * c;
81
+ }
82
+ if (normSq <= 0)
83
+ return 0.0;
84
+ return dot / Math.sqrt(normSq);
85
+ }
package/dist/rag.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Offline QA & Local RAG Answer Synthesis (issue #101).
3
+ *
4
+ * Implements grounded question answering with strict provenance citations ([file#heading]),
5
+ * supporting local LLM bridges (Ollama / LocalAI / OpenAI-compatible endpoint) and a
6
+ * fast built-in zero-dependency extractive synthesis fallback.
7
+ */
8
+ import { LoadedIndex, SearchResultHit } from './search.js';
9
+ export interface Citation {
10
+ file: string;
11
+ heading?: string;
12
+ score: number;
13
+ }
14
+ export interface SynthesisResult {
15
+ answer: string;
16
+ citations: Citation[];
17
+ passages: SearchResultHit[];
18
+ }
19
+ export interface AskOptions {
20
+ query: string;
21
+ loaded?: LoadedIndex;
22
+ indexDir?: string;
23
+ cacheDir?: string;
24
+ db?: string;
25
+ k?: number;
26
+ llmEndpoint?: string;
27
+ llmModel?: string;
28
+ llmFn?: (prompt: string) => Promise<string>;
29
+ systemPrompt?: string;
30
+ embedFn?: any;
31
+ }
32
+ export declare const DEFAULT_RAG_SYSTEM_PROMPT: string;
33
+ /**
34
+ * Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
35
+ */
36
+ export declare function extractAnswerFallback(query: string, passages: SearchResultHit[]): string;
37
+ /**
38
+ * Synthesizes an answer from retrieved passages using an LLM or fallback.
39
+ */
40
+ export declare function synthesizeAnswer(query: string, passages: SearchResultHit[], options?: {
41
+ llmFn?: (prompt: string) => Promise<string>;
42
+ llmEndpoint?: string;
43
+ llmModel?: string;
44
+ systemPrompt?: string;
45
+ }): Promise<SynthesisResult>;
46
+ /**
47
+ * End-to-end question answering against a Markdown index.
48
+ */
49
+ export declare function askQuestion(opts: AskOptions): Promise<SynthesisResult>;
package/dist/rag.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Offline QA & Local RAG Answer Synthesis (issue #101).
3
+ *
4
+ * Implements grounded question answering with strict provenance citations ([file#heading]),
5
+ * supporting local LLM bridges (Ollama / LocalAI / OpenAI-compatible endpoint) and a
6
+ * fast built-in zero-dependency extractive synthesis fallback.
7
+ */
8
+ import { searchIndex, loadIndex } from './search.js';
9
+ import { tokenize } from './lexical.js';
10
+ export const DEFAULT_RAG_SYSTEM_PROMPT = 'You are a helpful assistant answering questions using only the provided Markdown notes. ' +
11
+ 'Always cite your sources using [file#heading] format. ' +
12
+ 'If the context does not contain the answer, say that the notes do not have enough information.';
13
+ /**
14
+ * Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
15
+ */
16
+ export function extractAnswerFallback(query, passages) {
17
+ if (passages.length === 0) {
18
+ return 'No relevant passages found in the notes to answer the question.';
19
+ }
20
+ const queryTokens = new Set(tokenize(query));
21
+ const candidateSentences = [];
22
+ for (const hit of passages) {
23
+ const text = hit.snippet || '';
24
+ const cite = hit.heading ? `[${hit.file} › ${hit.heading}]` : `[${hit.file}]`;
25
+ // Split into sentences
26
+ const sentences = text
27
+ .split(/(?<=[.?!])\s+|\n+/)
28
+ .map((s) => s.trim())
29
+ .filter((s) => s.length > 15);
30
+ for (const sent of sentences) {
31
+ const sentTokens = tokenize(sent);
32
+ let matchCount = 0;
33
+ for (const tok of sentTokens) {
34
+ if (queryTokens.has(tok))
35
+ matchCount++;
36
+ }
37
+ if (matchCount > 0) {
38
+ const score = (matchCount / (sentTokens.length + 1)) * (hit.score || 1);
39
+ candidateSentences.push({ sentence: sent, score, citation: cite });
40
+ }
41
+ }
42
+ }
43
+ if (candidateSentences.length === 0) {
44
+ // Return top snippets directly
45
+ const top = passages.slice(0, 2).map((p) => {
46
+ const cite = p.heading ? `[${p.file} › ${p.heading}]` : `[${p.file}]`;
47
+ return `${cite}: ${p.snippet.slice(0, 200)}…`;
48
+ });
49
+ return `Relevant excerpts from notes:\n\n${top.join('\n\n')}`;
50
+ }
51
+ candidateSentences.sort((a, b) => b.score - a.score);
52
+ const selected = candidateSentences.slice(0, 3);
53
+ const body = selected.map((s) => `${s.sentence} ${s.citation}`).join('\n\n');
54
+ return `Based on your notes:\n\n${body}`;
55
+ }
56
+ /**
57
+ * Synthesizes an answer from retrieved passages using an LLM or fallback.
58
+ */
59
+ export async function synthesizeAnswer(query, passages, options = {}) {
60
+ const citations = passages.map((p) => ({
61
+ file: p.file,
62
+ heading: p.heading,
63
+ score: p.score,
64
+ }));
65
+ if (options.llmFn) {
66
+ const contextBlocks = passages.map((p, i) => {
67
+ const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
68
+ return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
69
+ }).join('\n\n---\n\n');
70
+ const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
71
+ const answer = await options.llmFn(prompt);
72
+ return { answer, citations, passages };
73
+ }
74
+ if (options.llmEndpoint) {
75
+ const contextBlocks = passages.map((p, i) => {
76
+ const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
77
+ return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
78
+ }).join('\n\n---\n\n');
79
+ const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
80
+ try {
81
+ const resp = await fetch(`${options.llmEndpoint}/api/generate`, {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({
85
+ model: options.llmModel || 'llama3',
86
+ prompt,
87
+ stream: false,
88
+ }),
89
+ });
90
+ if (resp.ok) {
91
+ const data = await resp.json();
92
+ const answer = data.response || data.text || '';
93
+ if (answer)
94
+ return { answer, citations, passages };
95
+ }
96
+ }
97
+ catch {
98
+ // Fallback to extractive
99
+ }
100
+ }
101
+ const answer = extractAnswerFallback(query, passages);
102
+ return { answer, citations, passages };
103
+ }
104
+ /**
105
+ * End-to-end question answering against a Markdown index.
106
+ */
107
+ export async function askQuestion(opts) {
108
+ const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmEndpoint, llmModel, systemPrompt, embedFn, } = opts;
109
+ let loaded = inputLoaded;
110
+ if (!loaded) {
111
+ if (!indexDir)
112
+ throw new Error('askQuestion requires either loaded index or indexDir');
113
+ loaded = loadIndex(indexDir);
114
+ }
115
+ const passages = await searchIndex({
116
+ loaded,
117
+ cacheDir,
118
+ query,
119
+ k,
120
+ embedFn,
121
+ });
122
+ return synthesizeAnswer(query, passages, {
123
+ llmFn,
124
+ llmEndpoint,
125
+ llmModel,
126
+ systemPrompt,
127
+ });
128
+ }