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.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- 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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
91
|
-
|
|
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
|
-
//
|
|
263
|
+
// Fall through to extractive.
|
|
99
264
|
}
|
|
100
265
|
}
|
|
101
266
|
const answer = extractAnswerFallback(query, passages);
|
|
102
|
-
return
|
|
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
|
|
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
|
|
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));
|