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/rag.d.ts CHANGED
@@ -1,11 +1,5 @@
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
1
  import { LoadedIndex, SearchResultHit } from './search.js';
2
+ import { ChatProviderId, ResolvedChat } from './providers.js';
9
3
  export interface Citation {
10
4
  file: string;
11
5
  heading?: string;
@@ -14,7 +8,52 @@ export interface Citation {
14
8
  export interface SynthesisResult {
15
9
  answer: string;
16
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[];
17
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;
30
+ }
31
+ export interface ChatSourceManifestItem {
32
+ file: string;
33
+ heading?: string;
34
+ headingPath?: string[];
35
+ startLine?: number;
36
+ endLine?: number;
37
+ chunkHash?: string;
38
+ score: number;
39
+ }
40
+ export interface ChatTurn {
41
+ id: string;
42
+ timestamp: string;
43
+ query: string;
44
+ answer: string;
45
+ citations: Citation[];
46
+ manifest: ChatSourceManifestItem[];
47
+ /** Which synthesis stage produced the answer (issue #199; optional in persisted sessions). */
48
+ mode?: SynthesisResult['mode'];
49
+ }
50
+ export interface ChatSession {
51
+ id: string;
52
+ createdAt: string;
53
+ updatedAt: string;
54
+ db?: string;
55
+ model?: string;
56
+ turns: ChatTurn[];
18
57
  }
19
58
  export interface AskOptions {
20
59
  query: string;
@@ -23,27 +62,113 @@ export interface AskOptions {
23
62
  cacheDir?: string;
24
63
  db?: string;
25
64
  k?: number;
65
+ offline?: boolean;
26
66
  llmEndpoint?: string;
27
67
  llmModel?: string;
68
+ /** issue #199: chat wire format for llmEndpoint — 'openai' (default) or 'ollama'. */
69
+ llmProvider?: ChatProviderId;
28
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;
29
76
  systemPrompt?: string;
30
77
  embedFn?: any;
31
78
  }
79
+ export interface ChatTurnOptions {
80
+ session?: ChatSession;
81
+ query: string;
82
+ loaded?: LoadedIndex;
83
+ indexDir?: string;
84
+ cacheDir?: string;
85
+ db?: string;
86
+ k?: number;
87
+ offline?: boolean;
88
+ llmEndpoint?: string;
89
+ llmModel?: string;
90
+ /** issue #199: chat wire format for llmEndpoint — 'openai' (default) or 'ollama'. */
91
+ llmProvider?: ChatProviderId;
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;
98
+ systemPrompt?: string;
99
+ embedFn?: any;
100
+ log?: (msg: string) => void;
101
+ }
32
102
  export declare const DEFAULT_RAG_SYSTEM_PROMPT: string;
103
+ /**
104
+ * Returns the directory where chat sessions are persisted.
105
+ */
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;
111
+ /**
112
+ * Atomically saves a ChatSession to disk (<indexDir>/sessions/<id>.json).
113
+ */
114
+ export declare function saveChatSession(indexDir: string, session: ChatSession): string;
115
+ /**
116
+ * Loads a ChatSession by id or creates a new one. Degrades safely if corrupt.
117
+ */
118
+ export declare function loadChatSession(indexDir: string, id: string, log?: (msg: string) => void): ChatSession;
119
+ /**
120
+ * Lists all existing ChatSessions in the indexDir, sorted by updatedAt descending.
121
+ */
122
+ export declare function listChatSessions(indexDir: string): Array<{
123
+ id: string;
124
+ createdAt: string;
125
+ updatedAt: string;
126
+ turnsCount: number;
127
+ }>;
33
128
  /**
34
129
  * Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
35
130
  */
36
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[];
37
139
  /**
38
140
  * Synthesizes an answer from retrieved passages using an LLM or fallback.
39
141
  */
40
142
  export declare function synthesizeAnswer(query: string, passages: SearchResultHit[], options?: {
41
143
  llmFn?: (prompt: string) => Promise<string>;
144
+ llmRequestFn?: (req: LlmStructuredRequest) => Promise<string>;
42
145
  llmEndpoint?: string;
43
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;
44
154
  systemPrompt?: string;
155
+ conversationHistory?: string;
45
156
  }): Promise<SynthesisResult>;
46
157
  /**
47
158
  * End-to-end question answering against a Markdown index.
48
159
  */
49
160
  export declare function askQuestion(opts: AskOptions): Promise<SynthesisResult>;
161
+ /**
162
+ * Executes a single conversational turn in a ChatSession with context carry-over and source manifest.
163
+ */
164
+ export declare function chatTurn(opts: ChatTurnOptions): Promise<{
165
+ session: ChatSession;
166
+ turn: ChatTurn;
167
+ answer: string;
168
+ citations: Citation[];
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[];
174
+ }>;
package/dist/rag.js CHANGED
@@ -1,15 +1,103 @@
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
- */
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
8
4
  import { searchIndex, loadIndex } from './search.js';
9
5
  import { tokenize } from './lexical.js';
6
+ import { resolveChatProvider } from './providers.js';
10
7
  export const DEFAULT_RAG_SYSTEM_PROMPT = 'You are a helpful assistant answering questions using only the provided Markdown notes. ' +
11
8
  'Always cite your sources using [file#heading] format. ' +
12
9
  'If the context does not contain the answer, say that the notes do not have enough information.';
10
+ /**
11
+ * Returns the directory where chat sessions are persisted.
12
+ */
13
+ export function getSessionsDir(indexDir) {
14
+ return path.join(indexDir, 'sessions');
15
+ }
16
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
17
+ /**
18
+ * Validates a session id before it is used as a filesystem path component.
19
+ */
20
+ export function assertSafeSessionId(id) {
21
+ if (!SESSION_ID_PATTERN.test(id)) {
22
+ throw new Error('Invalid session ID: expected 1-64 letters, numbers, hyphens, or underscores');
23
+ }
24
+ return id;
25
+ }
26
+ /**
27
+ * Atomically saves a ChatSession to disk (<indexDir>/sessions/<id>.json).
28
+ */
29
+ export function saveChatSession(indexDir, session) {
30
+ assertSafeSessionId(session.id);
31
+ const dir = getSessionsDir(indexDir);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const targetPath = path.join(dir, `${session.id}.json`);
34
+ const tmpPath = path.join(dir, `${session.id}.${Date.now()}.tmp`);
35
+ fs.writeFileSync(tmpPath, JSON.stringify(session, null, 2), 'utf8');
36
+ fs.renameSync(tmpPath, targetPath);
37
+ return targetPath;
38
+ }
39
+ /**
40
+ * Loads a ChatSession by id or creates a new one. Degrades safely if corrupt.
41
+ */
42
+ export function loadChatSession(indexDir, id, log = () => { }) {
43
+ assertSafeSessionId(id);
44
+ const sessionPath = path.join(getSessionsDir(indexDir), `${id}.json`);
45
+ if (fs.existsSync(sessionPath)) {
46
+ try {
47
+ const raw = fs.readFileSync(sessionPath, 'utf8');
48
+ const parsed = JSON.parse(raw);
49
+ if (parsed && typeof parsed.id === 'string' && Array.isArray(parsed.turns)) {
50
+ return parsed;
51
+ }
52
+ log(`warning: session ${id} is malformed; starting a fresh session.`);
53
+ }
54
+ catch (e) {
55
+ log(`warning: failed to parse session ${id} (${e.message}); starting fresh.`);
56
+ }
57
+ }
58
+ const now = new Date().toISOString();
59
+ return {
60
+ id,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ turns: [],
64
+ };
65
+ }
66
+ /**
67
+ * Lists all existing ChatSessions in the indexDir, sorted by updatedAt descending.
68
+ */
69
+ export function listChatSessions(indexDir) {
70
+ const dir = getSessionsDir(indexDir);
71
+ if (!fs.existsSync(dir))
72
+ return [];
73
+ const out = [];
74
+ let entries;
75
+ try {
76
+ entries = fs.readdirSync(dir);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ for (const file of entries) {
82
+ if (!file.endsWith('.json'))
83
+ continue;
84
+ const p = path.join(dir, file);
85
+ try {
86
+ const raw = fs.readFileSync(p, 'utf8');
87
+ const s = JSON.parse(raw);
88
+ if (s && typeof s.id === 'string' && Array.isArray(s.turns)) {
89
+ out.push({
90
+ id: s.id,
91
+ createdAt: s.createdAt || s.updatedAt || '',
92
+ updatedAt: s.updatedAt || s.createdAt || '',
93
+ turnsCount: s.turns.length,
94
+ });
95
+ }
96
+ }
97
+ catch { }
98
+ }
99
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
100
+ }
13
101
  /**
14
102
  * Built-in extractive answer synthesizer for fully offline, zero-dependency QA.
15
103
  */
@@ -53,6 +141,56 @@ export function extractAnswerFallback(query, passages) {
53
141
  const body = selected.map((s) => `${s.sentence} ${s.citation}`).join('\n\n');
54
142
  return `Based on your notes:\n\n${body}`;
55
143
  }
144
+ const CITATION_MENTION_RE = /\[([^\]\n]{1,200})\]/g;
145
+ /** `[file]` | `[file#heading]` | `[file › heading]` → {file, heading?}; null for non-citations. */
146
+ function parseCitationMention(text) {
147
+ const t = text.trim();
148
+ if (!t)
149
+ return null;
150
+ let file;
151
+ let heading;
152
+ const hash = t.indexOf('#');
153
+ const sep = t.indexOf('›');
154
+ if (hash > 0) {
155
+ file = t.slice(0, hash).trim();
156
+ heading = t.slice(hash + 1).trim();
157
+ }
158
+ else if (sep > 0) {
159
+ file = t.slice(0, sep).trim();
160
+ heading = t.slice(sep + 1).trim();
161
+ }
162
+ else {
163
+ file = t;
164
+ }
165
+ // markdown links, footnote refs, and empty brackets are not citations
166
+ if (!file || /https?:\/\//i.test(file))
167
+ return null;
168
+ return { file, heading: heading || undefined };
169
+ }
170
+ /**
171
+ * issue #199 follow-up: which of the retrieved citations the answer text
172
+ * actually cites. Bracket-only scan (the exact formats the system prompt and
173
+ * the extractive fallback emit), exact file/heading match against the
174
+ * retrieval manifest, order of first mention. No mention → [].
175
+ */
176
+ export function extractUsedCitations(answer, citations) {
177
+ const used = [];
178
+ const seen = new Set();
179
+ for (const m of answer.matchAll(CITATION_MENTION_RE)) {
180
+ const mention = parseCitationMention(m[1]);
181
+ if (!mention)
182
+ continue;
183
+ const hit = citations.find((c) => c.file === mention.file && (mention.heading === undefined || c.heading === mention.heading));
184
+ if (!hit)
185
+ continue;
186
+ const key = `${hit.file}\u0000${hit.heading ?? ''}`;
187
+ if (!seen.has(key)) {
188
+ seen.add(key);
189
+ used.push(hit);
190
+ }
191
+ }
192
+ return used;
193
+ }
56
194
  /**
57
195
  * Synthesizes an answer from retrieved passages using an LLM or fallback.
58
196
  */
@@ -62,50 +200,77 @@ export async function synthesizeAnswer(query, passages, options = {}) {
62
200
  heading: p.heading,
63
201
  score: p.score,
64
202
  }));
203
+ // One prompt assembly for every stage: `system + user` reproduces the
204
+ // historical single-string prompt byte-for-byte; the split serves the
205
+ // structured transports (MCP sampling, issue #197).
206
+ const system = options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT;
207
+ const historyHeader = options.conversationHistory ? `\nConversation History:\n${options.conversationHistory}\n` : '';
208
+ const contextBlocks = passages
209
+ .map((p, i) => {
210
+ const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
211
+ return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
212
+ })
213
+ .join('\n\n---\n\n');
214
+ const user = `${historyHeader}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
215
+ // every stage returns through one shape so citationsUsed stays consistent
216
+ const finish = (answer, mode) => ({
217
+ answer,
218
+ citations,
219
+ citationsUsed: extractUsedCitations(answer, citations),
220
+ passages,
221
+ mode,
222
+ });
223
+ // issue #197: structured hook (MCP sampling). Best-effort like the endpoint
224
+ // path — a refusal or timeout falls through to the extractive answer.
225
+ if (options.llmRequestFn) {
226
+ try {
227
+ const answer = await options.llmRequestFn({ system, user });
228
+ if (answer)
229
+ return finish(answer, 'llm');
230
+ }
231
+ catch {
232
+ // Fall through to extractive.
233
+ }
234
+ }
65
235
  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:`;
236
+ const answer = await options.llmFn(system + user);
237
+ return finish(answer, 'llm');
238
+ }
239
+ // issue #199: unified chat provider path. `/chat/completions` by default
240
+ // (same wire format HyDE uses — llama.cpp, LM Studio, vLLM, Ollama /v1 all
241
+ // speak it), Ollama-native `/api/generate` under `--llm-provider ollama`.
242
+ // Streaming deltas flow to onDelta; the full text is assembled for the
243
+ // returned result. Best-effort like the old endpoint path — a refusal or
244
+ // idle timeout falls through to the extractive answer.
245
+ if (options.llmChat || options.llmEndpoint) {
246
+ const chat = options.llmChat ??
247
+ resolveChatProvider({
248
+ provider: options.llmProvider ?? 'openai',
249
+ model: options.llmModel,
250
+ baseUrl: options.llmEndpoint,
251
+ });
80
252
  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
- }),
253
+ const answer = await chat.complete({
254
+ system,
255
+ user,
256
+ ...(options.onDelta ? { onDelta: options.onDelta } : {}),
257
+ ...(options.signal ? { signal: options.signal } : {}),
89
258
  });
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
- }
259
+ if (answer)
260
+ return finish(answer, 'endpoint');
96
261
  }
97
262
  catch {
98
- // Fallback to extractive
263
+ // Fall through to extractive.
99
264
  }
100
265
  }
101
266
  const answer = extractAnswerFallback(query, passages);
102
- return { answer, citations, passages };
267
+ return finish(answer, 'extractive');
103
268
  }
104
269
  /**
105
270
  * End-to-end question answering against a Markdown index.
106
271
  */
107
272
  export async function askQuestion(opts) {
108
- const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmEndpoint, llmModel, systemPrompt, embedFn, } = opts;
273
+ const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmRequestFn, llmEndpoint, llmModel, llmProvider, llmChat, onDelta, systemPrompt, embedFn, } = opts;
109
274
  let loaded = inputLoaded;
110
275
  if (!loaded) {
111
276
  if (!indexDir)
@@ -118,11 +283,122 @@ export async function askQuestion(opts) {
118
283
  query,
119
284
  k,
120
285
  embedFn,
286
+ offline: opts.offline,
121
287
  });
122
288
  return synthesizeAnswer(query, passages, {
123
289
  llmFn,
290
+ llmRequestFn,
291
+ llmEndpoint,
292
+ llmModel,
293
+ llmProvider,
294
+ llmChat,
295
+ onDelta,
296
+ systemPrompt,
297
+ });
298
+ }
299
+ /**
300
+ * Executes a single conversational turn in a ChatSession with context carry-over and source manifest.
301
+ */
302
+ export async function chatTurn(opts) {
303
+ const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmRequestFn, llmEndpoint, llmModel, llmProvider, llmChat, onDelta, systemPrompt, embedFn, log = () => { }, } = opts;
304
+ let loaded = inputLoaded;
305
+ if (!loaded) {
306
+ if (!indexDir)
307
+ throw new Error('chatTurn requires either loaded index or indexDir');
308
+ loaded = loadIndex(indexDir);
309
+ }
310
+ let session = opts.session;
311
+ if (!session) {
312
+ const newId = crypto.randomUUID().slice(0, 8);
313
+ session = indexDir ? loadChatSession(indexDir, newId, log) : {
314
+ id: newId,
315
+ createdAt: new Date().toISOString(),
316
+ updatedAt: new Date().toISOString(),
317
+ turns: [],
318
+ };
319
+ }
320
+ // 1. Build contextual retrieval query for follow-up questions
321
+ let retrievalQuery = query;
322
+ if (session.turns.length > 0) {
323
+ const prevTurn = session.turns[session.turns.length - 1];
324
+ const prevTokens = tokenize(prevTurn.query).filter((t) => t.length > 3).slice(0, 4);
325
+ if (prevTokens.length > 0) {
326
+ retrievalQuery = `${query} ${prevTokens.join(' ')}`.trim();
327
+ }
328
+ }
329
+ // 2. Search index
330
+ const passages = await searchIndex({
331
+ loaded,
332
+ cacheDir,
333
+ query: retrievalQuery,
334
+ k,
335
+ embedFn,
336
+ offline: opts.offline,
337
+ });
338
+ // 3. Construct source manifest from retrieved chunks
339
+ const manifest = passages.map((p) => {
340
+ const chunk = loaded.index?.chunks?.find((c) => c.file === p.file && (c.heading === p.heading || (!c.heading && !p.heading)));
341
+ return {
342
+ file: p.file,
343
+ heading: p.heading,
344
+ headingPath: chunk?.headingPath,
345
+ startLine: chunk?.startLine,
346
+ endLine: chunk?.endLine,
347
+ chunkHash: chunk?.chunkHash,
348
+ score: p.score,
349
+ };
350
+ });
351
+ // 4. Conversation history string for LLM prompt
352
+ let conversationHistory = '';
353
+ if (session.turns.length > 0) {
354
+ conversationHistory = session.turns
355
+ .slice(-3)
356
+ .map((t) => `User: ${t.query}\nAssistant: ${t.answer}`)
357
+ .join('\n\n');
358
+ }
359
+ // 5. Synthesize answer
360
+ const synth = await synthesizeAnswer(query, passages, {
361
+ llmFn,
362
+ llmRequestFn,
124
363
  llmEndpoint,
125
364
  llmModel,
365
+ llmProvider,
366
+ llmChat,
367
+ onDelta,
126
368
  systemPrompt,
369
+ conversationHistory,
127
370
  });
371
+ // 6. Append turn to session
372
+ const turn = {
373
+ id: crypto.randomUUID(),
374
+ timestamp: new Date().toISOString(),
375
+ query,
376
+ answer: synth.answer,
377
+ citations: synth.citations,
378
+ manifest,
379
+ mode: synth.mode,
380
+ };
381
+ session.turns.push(turn);
382
+ session.updatedAt = turn.timestamp;
383
+ if (loaded.index?.db)
384
+ session.db = loaded.index.db;
385
+ if (loaded.model?.id)
386
+ session.model = loaded.model.id;
387
+ if (indexDir) {
388
+ try {
389
+ saveChatSession(indexDir, session);
390
+ }
391
+ catch (e) {
392
+ log(`warning: failed to save chat session ${session.id}: ${e.message}`);
393
+ }
394
+ }
395
+ return {
396
+ session,
397
+ turn,
398
+ answer: turn.answer,
399
+ citations: turn.citations,
400
+ manifest: turn.manifest,
401
+ mode: turn.mode ?? 'extractive',
402
+ citationsUsed: extractUsedCitations(turn.answer, turn.citations),
403
+ };
128
404
  }
package/dist/repair.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface RepairAction {
2
- action: 'remove-stale-lock' | 'remove-broken-vectors-bin' | 'remove-corrupt-ivf' | 'rebuild-index' | 'rewrite-hashes';
2
+ action: 'remove-stale-lock' | 'remove-broken-vectors-bin' | 'remove-corrupt-ivf' | 'remove-corrupt-hnsw' | 'rebuild-index' | 'rewrite-hashes';
3
3
  detail: string;
4
4
  }
5
5
  export interface RepairPlan {
package/dist/repair.js CHANGED
@@ -108,7 +108,7 @@ export function planRepairs(indexDir, opts = {}) {
108
108
  });
109
109
  }
110
110
  }
111
- // --- 3. corrupt ivf.json (optional ANN sidecar; loader silently ignores it) ---
111
+ // --- 3. corrupt ANN sidecars (optional; loader silently ignores them) ---
112
112
  const ivfPath = path.join(indexDir, 'ivf.json');
113
113
  const ivfRaw = readJsonIfParsable(ivfPath);
114
114
  if (ivfRaw.ok === false) {
@@ -117,9 +117,19 @@ export function planRepairs(indexDir, opts = {}) {
117
117
  detail: 'ivf.json is not valid JSON — removing it re-enables brute-force ANN fallback',
118
118
  });
119
119
  }
120
+ // issue #201: hnsw.json gets the same corrupt-sidecar treatment
121
+ const hnswPath = path.join(indexDir, 'hnsw.json');
122
+ const hnswRaw = readJsonIfParsable(hnswPath);
123
+ if (hnswRaw.ok === false) {
124
+ plan.actions.push({
125
+ action: 'remove-corrupt-hnsw',
126
+ detail: 'hnsw.json is not valid JSON — removing it re-enables brute-force ANN fallback',
127
+ });
128
+ }
120
129
  // --- 4. corrupt vectors.json ---
121
130
  const liveWriter = plan.skipped.some((s) => s.action === 'remove-stale-lock' && /live writer/.test(s.detail));
122
- const vectorsRaw = readJsonIfParsable(path.join(indexDir, 'vectors.json'));
131
+ const vectorsPath = path.join(indexDir, 'vectors.json');
132
+ const vectorsRaw = readJsonIfParsable(vectorsPath);
123
133
  if (vectorsRaw.ok === false) {
124
134
  if (db && !liveWriter) {
125
135
  plan.actions.push({ action: 'rebuild-index', detail: `vectors.json is not valid JSON — rebuild from ${db}` });
@@ -137,6 +147,44 @@ export function planRepairs(indexDir, opts = {}) {
137
147
  });
138
148
  }
139
149
  }
150
+ // --- 4b. vectors.json SHA-256 sidecar mismatch (issue #166) ---
151
+ // `mdss check` reports this as an integrity failure; without a planned
152
+ // repair `check --fix` printed "nothing to repair" and left the index
153
+ // broken. Mirror the check's hashing (raw bytes vs first sidecar token)
154
+ // and plan the same rebuild as the corrupt-JSON path — rewriting the
155
+ // sidecar would bless whatever tampered content is on disk.
156
+ if (vectorsRaw.ok === true) {
157
+ const vectorsShaPath = `${vectorsPath}.sha256`;
158
+ if (fs.existsSync(vectorsShaPath)) {
159
+ let mismatch = null;
160
+ try {
161
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(vectorsPath)).digest('hex');
162
+ const expected = fs.readFileSync(vectorsShaPath, 'utf8').trim().split(/\s+/)[0];
163
+ if (expected && expected !== actual)
164
+ mismatch = 'SHA-256 mismatch';
165
+ }
166
+ catch (e) {
167
+ mismatch = e.message;
168
+ }
169
+ if (mismatch) {
170
+ if (db && !liveWriter) {
171
+ plan.actions.push({ action: 'rebuild-index', detail: `vectors.json integrity check failed (${mismatch}) — rebuild from ${db}` });
172
+ }
173
+ else if (liveWriter) {
174
+ plan.skipped.push({
175
+ action: 'rebuild-index',
176
+ detail: `vectors.json integrity check failed (${mismatch}), but a live writer holds the lock — retry after it finishes`,
177
+ });
178
+ }
179
+ else {
180
+ plan.skipped.push({
181
+ action: 'rebuild-index',
182
+ detail: `vectors.json integrity check failed (${mismatch}) — pass --db so --fix can rebuild it`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
140
188
  // --- 5. damaged .hashes.json ---
141
189
  const hashesRaw = readJsonIfParsable(path.join(indexDir, '.hashes.json'));
142
190
  if (hashesRaw.ok === false) {
@@ -175,6 +223,9 @@ export function applyRepairs(indexDir, plan, opts = {}) {
175
223
  case 'remove-corrupt-ivf':
176
224
  fs.rmSync(path.join(indexDir, 'ivf.json'), { force: true });
177
225
  break;
226
+ case 'remove-corrupt-hnsw':
227
+ fs.rmSync(path.join(indexDir, 'hnsw.json'), { force: true });
228
+ break;
178
229
  case 'rewrite-hashes': {
179
230
  const hashes = recomputeHashes(opts.db);
180
231
  fs.writeFileSync(path.join(indexDir, '.hashes.json'), JSON.stringify(hashes, null, 2));