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/binary-format.js
CHANGED
|
@@ -116,7 +116,16 @@ export function readBinaryHeader(buffer) {
|
|
|
116
116
|
throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
|
|
117
117
|
}
|
|
118
118
|
const version = buffer.readUInt32LE(8);
|
|
119
|
+
// issue #189: version was parsed but never checked — a future-format file
|
|
120
|
+
// would silently decode as garbage vectors
|
|
121
|
+
if (version !== BINARY_VERSION) {
|
|
122
|
+
throw new Error(`Invalid binary index: unsupported version ${version} (expected ${BINARY_VERSION}) — run \`mdss index\` to rebuild`);
|
|
123
|
+
}
|
|
119
124
|
const flags = buffer.readUInt32LE(12);
|
|
125
|
+
// issue #189: unknown flag bits silently decoded as fp32 garbage
|
|
126
|
+
if (flags !== BINARY_FLAG_FP32 && flags !== BINARY_FLAG_INT8) {
|
|
127
|
+
throw new Error(`Invalid binary index: unknown flags 0x${flags.toString(16)} (expected 0 or 1) — run \`mdss index\` to rebuild`);
|
|
128
|
+
}
|
|
120
129
|
const dim = buffer.readUInt32LE(16);
|
|
121
130
|
const chunkCount = buffer.readUInt32LE(20);
|
|
122
131
|
const vectorsOffset = Number(buffer.readBigUInt64LE(24));
|
package/dist/completions.js
CHANGED
|
@@ -32,9 +32,12 @@ export const FLAGS = [
|
|
|
32
32
|
{ name: '--embedder-key-file', kind: 'file' },
|
|
33
33
|
{ name: '--expand', kind: 'value' },
|
|
34
34
|
{ name: '--expand-passages', kind: 'number' },
|
|
35
|
+
{ name: '--multi-query', kind: 'number' },
|
|
36
|
+
{ name: '--rerank-model', kind: 'value' },
|
|
35
37
|
{ name: '--health-public', kind: 'flag' },
|
|
36
38
|
{ name: '--allow-unsecured', kind: 'flag' },
|
|
37
39
|
{ name: '--ann', kind: 'flag' },
|
|
40
|
+
{ name: '--ann', kind: 'value' },
|
|
38
41
|
{ name: '--canonical', kind: 'flag' },
|
|
39
42
|
{ name: '--rag', kind: 'flag' },
|
|
40
43
|
{ name: '--auto-tag', kind: 'flag' },
|
|
@@ -46,6 +49,7 @@ export const FLAGS = [
|
|
|
46
49
|
{ name: '--dry-run', kind: 'flag' },
|
|
47
50
|
// values
|
|
48
51
|
{ name: '--workers', kind: 'number' },
|
|
52
|
+
{ name: '--max-chunk-tokens', kind: 'number' },
|
|
49
53
|
{ name: '--config', kind: 'file' },
|
|
50
54
|
{ name: '--format', kind: 'value' },
|
|
51
55
|
{ name: '--output', alias: '-o', kind: 'file' },
|
|
@@ -54,6 +58,7 @@ export const FLAGS = [
|
|
|
54
58
|
{ name: '--index-dir', kind: 'dir' },
|
|
55
59
|
{ name: '--cache-dir', kind: 'dir' },
|
|
56
60
|
{ name: '--model', kind: 'value' },
|
|
61
|
+
{ name: '--dimensions', alias: '--dim', kind: 'number' },
|
|
57
62
|
{ name: '--k', kind: 'number' },
|
|
58
63
|
{ name: '--max-per-file', kind: 'number' },
|
|
59
64
|
{ name: '--max-per-doc', kind: 'number' },
|
|
@@ -79,13 +84,18 @@ export const FLAGS = [
|
|
|
79
84
|
{ name: '--graph-boost', kind: 'number' },
|
|
80
85
|
{ name: '--filter', kind: 'value' },
|
|
81
86
|
{ name: '--quantize', kind: 'value' },
|
|
87
|
+
{ name: '--direction', kind: 'value' },
|
|
88
|
+
{ name: '--no-semantic', kind: 'flag' },
|
|
89
|
+
{ name: '--session', kind: 'value' },
|
|
90
|
+
{ name: '--resume', kind: 'flag' },
|
|
82
91
|
{ name: '--llm-endpoint', kind: 'value' },
|
|
83
92
|
{ name: '--llm-model', kind: 'value' },
|
|
93
|
+
{ name: '--llm-provider', kind: 'value' },
|
|
84
94
|
{ name: '--system-prompt', kind: 'value' },
|
|
85
95
|
];
|
|
86
96
|
export const COMMANDS = [
|
|
87
97
|
'index', 'stats', 'check', 'doctor', 'export', 'search',
|
|
88
|
-
'ask', 'tui', 'serve', 'mcp', 'models', 'completions',
|
|
98
|
+
'ask', 'chat', 'tui', 'serve', 'mcp', 'models', 'completions', 'related',
|
|
89
99
|
];
|
|
90
100
|
export const COMPLETION_SHELLS = ['bash', 'zsh', 'fish', 'powershell'];
|
|
91
101
|
const DIR_FLAGS = [...new Set(FLAGS.filter((f) => f.kind === 'dir').flatMap((f) => [f.name, ...(f.alias && !f.alias.startsWith('--') ? [f.alias] : [])]))];
|
package/dist/core.d.ts
CHANGED
|
@@ -23,7 +23,9 @@ export type LockAcquireResult = {
|
|
|
23
23
|
pid: number | null;
|
|
24
24
|
heldSince: string | null;
|
|
25
25
|
};
|
|
26
|
-
export declare function acquireIndexLock(indexDir: string
|
|
26
|
+
export declare function acquireIndexLock(indexDir: string, _testHooks?: {
|
|
27
|
+
onClassified?: () => void;
|
|
28
|
+
}): LockAcquireResult;
|
|
27
29
|
export declare function releaseIndexLock(indexDir: string): void;
|
|
28
30
|
export declare function withIndexLock<T>(indexDir: string, fn: () => Promise<T>): Promise<T>;
|
|
29
31
|
export type ModelDescriptor = ModelAdapter;
|
|
@@ -31,6 +33,14 @@ export declare function getPipelineModelSource(model: ModelDescriptor, cacheDir:
|
|
|
31
33
|
export declare function resolveSessionOptions(model?: ModelDescriptor): Record<string, unknown> | undefined;
|
|
32
34
|
export declare function getExtractorCacheKey(model: ModelDescriptor, cacheDir: string, offline?: boolean): string;
|
|
33
35
|
export declare function isNetworkError(err: any): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
|
|
38
|
+
* embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
|
|
39
|
+
* for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
|
|
40
|
+
* override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
|
|
43
|
+
export declare function fetchTimeoutMs(envValue?: string): number;
|
|
34
44
|
export declare function retryWithBackoff<T>(fn: () => Promise<T>, opts?: {
|
|
35
45
|
maxRetries?: number;
|
|
36
46
|
delays?: number[];
|
|
@@ -48,6 +58,7 @@ export declare function prepareEmbeddingRequest(model: ModelDescriptor, texts: s
|
|
|
48
58
|
normalize: boolean;
|
|
49
59
|
};
|
|
50
60
|
};
|
|
61
|
+
export declare function truncateAndNormalizeVector(vec: number[] | Float32Array, targetDim: number, normalize?: boolean): number[];
|
|
51
62
|
export declare function embed(texts: string[], kind: 'query' | 'passage', model: ModelDescriptor, cacheDir: string, offline?: boolean, retryOpts?: {
|
|
52
63
|
maxRetries?: number;
|
|
53
64
|
delays?: number[];
|
|
@@ -67,7 +78,7 @@ export declare function migrateToSchemaV2(index: {
|
|
|
67
78
|
}): void;
|
|
68
79
|
export declare function migrateToSchemaV3(): void;
|
|
69
80
|
export declare const SCHEMA_MIGRATIONS: Record<number, (index: any) => void>;
|
|
70
|
-
export declare function walkMarkdown(dir: string, ignore?: string[]): string[];
|
|
81
|
+
export declare function walkMarkdown(dir: string, ignore?: string[], errors?: string[]): string[];
|
|
71
82
|
export declare function assertSafePath(targetPath: string, allowedRoots?: string[]): string;
|
|
72
83
|
export declare function validateGlob(glob: string): string;
|
|
73
84
|
export declare function globToRegExp(glob: string): RegExp;
|
|
@@ -76,7 +87,7 @@ export declare function splitFrontmatter(raw: string): {
|
|
|
76
87
|
body: string;
|
|
77
88
|
};
|
|
78
89
|
export declare function extractTitle(frontmatter: string, body: string, relPath: string): string;
|
|
79
|
-
export declare function chunkMarkdown(body: string, maxChunk?: number): StructuralChunk[];
|
|
90
|
+
export declare function chunkMarkdown(body: string, maxChunk?: number, maxChunkTokens?: number): StructuralChunk[];
|
|
80
91
|
export interface ParsedChunkFile {
|
|
81
92
|
file: string;
|
|
82
93
|
title: string;
|
|
@@ -87,4 +98,31 @@ export interface ParsedChunkFile {
|
|
|
87
98
|
endLine: number;
|
|
88
99
|
meta: DocumentMetadata;
|
|
89
100
|
}
|
|
90
|
-
export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string
|
|
101
|
+
export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string,
|
|
102
|
+
/** issue #202: token budget overrides the char budget when set. */
|
|
103
|
+
maxChunkTokens?: number): ParsedChunkFile[];
|
|
104
|
+
export interface GetDocLinesOptions {
|
|
105
|
+
file: string;
|
|
106
|
+
fromLine?: number;
|
|
107
|
+
maxLines?: number;
|
|
108
|
+
dbDir?: string;
|
|
109
|
+
chunks?: Array<{
|
|
110
|
+
file: string;
|
|
111
|
+
text: string;
|
|
112
|
+
startLine?: number;
|
|
113
|
+
endLine?: number;
|
|
114
|
+
}>;
|
|
115
|
+
}
|
|
116
|
+
export interface GetDocLinesResult {
|
|
117
|
+
file: string;
|
|
118
|
+
fromLine: number;
|
|
119
|
+
toLine: number;
|
|
120
|
+
lineCount: number;
|
|
121
|
+
totalLines: number;
|
|
122
|
+
text: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Line-range retrieval helper (issue #140).
|
|
126
|
+
* Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
|
|
127
|
+
*/
|
|
128
|
+
export declare function getDocLines(opts: GetDocLinesOptions): GetDocLinesResult;
|
package/dist/core.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
8
9
|
import { resolveModel } from './models.js';
|
|
9
10
|
import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
|
|
10
11
|
import { parseFrontmatter } from './frontmatter.js';
|
|
@@ -58,7 +59,7 @@ function readLock(lockPath) {
|
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
export function acquireIndexLock(indexDir) {
|
|
62
|
+
export function acquireIndexLock(indexDir, _testHooks) {
|
|
62
63
|
const lockPath = path.join(indexDir, LOCK_FILENAME);
|
|
63
64
|
const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
|
|
64
65
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -86,6 +87,25 @@ export function acquireIndexLock(indexDir) {
|
|
|
86
87
|
const dead = pid !== null && !pidAlive(pid);
|
|
87
88
|
const staleMs = stat ? Date.now() - stat.mtimeMs : 0;
|
|
88
89
|
if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
|
|
90
|
+
// issue #188: between the stat/read above and the unlink below another
|
|
91
|
+
// writer may reclaim the lock and write a fresh, live one. Re-read and
|
|
92
|
+
// unlink only when the owner identity (pid + since) still matches what
|
|
93
|
+
// was classified — never unlink a lock we did not classify.
|
|
94
|
+
_testHooks?.onClassified?.();
|
|
95
|
+
if (!fs.existsSync(lockPath))
|
|
96
|
+
continue;
|
|
97
|
+
const current = readLock(lockPath);
|
|
98
|
+
const sameOwner = garbage
|
|
99
|
+
? current === null
|
|
100
|
+
: current !== null && current.pid === pid && current.since === heldSince;
|
|
101
|
+
if (!sameOwner) {
|
|
102
|
+
return {
|
|
103
|
+
acquired: false,
|
|
104
|
+
reason: 'lock changed while reclaiming — another writer got here first',
|
|
105
|
+
pid: current?.pid ?? pid,
|
|
106
|
+
heldSince: current?.since ?? heldSince,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
89
109
|
try {
|
|
90
110
|
fs.unlinkSync(lockPath);
|
|
91
111
|
}
|
|
@@ -148,14 +168,17 @@ export function resolveSessionOptions(model) {
|
|
|
148
168
|
const intra = envIntra ? parseInt(envIntra, 10) : undefined;
|
|
149
169
|
const inter = envInter ? parseInt(envInter, 10) : undefined;
|
|
150
170
|
const opts = model?.sessionOptions || {};
|
|
151
|
-
|
|
171
|
+
// issue #194: ONNX wasm sessions default to a single intra-op thread — the
|
|
172
|
+
// build/query sweep leaves most of the CPU idle. Default to the physical
|
|
173
|
+
// core count (measured 1.2x on a 4c/8t laptop; hyperthreads add contention,
|
|
174
|
+
// not throughput), still overridable via MDSS_INTRA_OP_THREADS.
|
|
175
|
+
const defaultIntra = Math.max(1, Math.min(4, os.cpus().length >> 1));
|
|
176
|
+
const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0
|
|
177
|
+
? intra
|
|
178
|
+
: opts.intraOpNumThreads ?? defaultIntra;
|
|
152
179
|
const interOpNumThreads = Number.isSafeInteger(inter) && inter && inter > 0 ? inter : opts.interOpNumThreads;
|
|
153
|
-
if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
180
|
const result = {};
|
|
157
|
-
|
|
158
|
-
result.intraOpNumThreads = intraOpNumThreads;
|
|
181
|
+
result.intraOpNumThreads = intraOpNumThreads;
|
|
159
182
|
if (interOpNumThreads !== undefined)
|
|
160
183
|
result.interOpNumThreads = interOpNumThreads;
|
|
161
184
|
return result;
|
|
@@ -170,6 +193,10 @@ export function getExtractorCacheKey(model, cacheDir, offline) {
|
|
|
170
193
|
export function isNetworkError(err) {
|
|
171
194
|
if (!err)
|
|
172
195
|
return false;
|
|
196
|
+
// issue #184: outbound fetch timeouts abort with TimeoutError/AbortError —
|
|
197
|
+
// they must classify as network errors so retries and fallbacks engage
|
|
198
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError')
|
|
199
|
+
return true;
|
|
173
200
|
const code = err.code || err.cause?.code;
|
|
174
201
|
if (code && ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'FETCH_ERROR'].includes(code)) {
|
|
175
202
|
return true;
|
|
@@ -184,10 +211,23 @@ export function isNetworkError(err) {
|
|
|
184
211
|
msg.includes('etimedout') ||
|
|
185
212
|
msg.includes('enotfound') ||
|
|
186
213
|
msg.includes('network error') ||
|
|
214
|
+
msg.includes('aborted due to timeout') ||
|
|
187
215
|
msg.includes('503 service unavailable') ||
|
|
188
216
|
msg.includes('502 bad gateway') ||
|
|
189
217
|
msg.includes('504 gateway timeout'));
|
|
190
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
|
|
221
|
+
* embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
|
|
222
|
+
* for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
|
|
223
|
+
* override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
|
|
224
|
+
*/
|
|
225
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
226
|
+
export function fetchTimeoutMs(envValue) {
|
|
227
|
+
const raw = envValue ?? process.env.MDSS_FETCH_TIMEOUT_MS;
|
|
228
|
+
const n = Number(raw);
|
|
229
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_FETCH_TIMEOUT_MS;
|
|
230
|
+
}
|
|
191
231
|
export async function retryWithBackoff(fn, opts = {}) {
|
|
192
232
|
const maxRetries = Number.isInteger(opts.maxRetries) && opts.maxRetries >= 0 ? opts.maxRetries : 3;
|
|
193
233
|
const delays = opts.delays || [1000, 4000, 16000];
|
|
@@ -224,6 +264,46 @@ export async function getExtractor(model, cacheDir, offline = false, retryOpts =
|
|
|
224
264
|
const key = getExtractorCacheKey(model, cacheDir, offline);
|
|
225
265
|
if (_extractors.has(key))
|
|
226
266
|
return _extractors.get(key);
|
|
267
|
+
// issue #200: adapters whose ONNX graph emits a pre-pooled
|
|
268
|
+
// `sentence_embedding` (EmbeddingGemma family) bypass the feature-extraction
|
|
269
|
+
// pipeline — it can only pool last_hidden_state/logits/token_embeddings and
|
|
270
|
+
// would crash on such graphs. The shim keeps the pipeline call signature so
|
|
271
|
+
// embed() is unchanged.
|
|
272
|
+
if (model.pooledOutput === true) {
|
|
273
|
+
const loadPooledModel = async () => {
|
|
274
|
+
const { AutoModel, AutoTokenizer, env } = await import('@huggingface/transformers');
|
|
275
|
+
if (cacheDir)
|
|
276
|
+
env.cacheDir = cacheDir;
|
|
277
|
+
env.allowRemoteModels = !offline;
|
|
278
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
279
|
+
const modelOpts = {
|
|
280
|
+
revision,
|
|
281
|
+
dtype: model.dtype || 'q8',
|
|
282
|
+
progress_callback: emitDownloadEvent,
|
|
283
|
+
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
284
|
+
};
|
|
285
|
+
const tokenizer = await AutoTokenizer.from_pretrained(source, { revision });
|
|
286
|
+
const onnxModel = await AutoModel.from_pretrained(source, modelOpts);
|
|
287
|
+
const normalize = model.normalize !== false;
|
|
288
|
+
const ext = async (input) => {
|
|
289
|
+
const texts = Array.isArray(input) ? input : [input];
|
|
290
|
+
const modelInputs = await tokenizer(texts, { padding: true, truncation: true });
|
|
291
|
+
const outputs = await onnxModel(modelInputs);
|
|
292
|
+
let result = outputs.sentence_embedding;
|
|
293
|
+
if (!result) {
|
|
294
|
+
throw new Error(`model "${model.id}" declared pooledOutput but produced no sentence_embedding output`);
|
|
295
|
+
}
|
|
296
|
+
if (normalize)
|
|
297
|
+
result = result.normalize(2, -1);
|
|
298
|
+
return { tolist: () => result.tolist() };
|
|
299
|
+
};
|
|
300
|
+
_extractors.set(key, ext);
|
|
301
|
+
return ext;
|
|
302
|
+
};
|
|
303
|
+
if (offline)
|
|
304
|
+
return await loadPooledModel();
|
|
305
|
+
return await retryWithBackoff(loadPooledModel, retryOpts);
|
|
306
|
+
}
|
|
227
307
|
const loadPipeline = async () => {
|
|
228
308
|
const { pipeline, env } = await import('@huggingface/transformers');
|
|
229
309
|
if (cacheDir)
|
|
@@ -258,11 +338,36 @@ export function prepareEmbeddingRequest(model, texts, kind) {
|
|
|
258
338
|
options: { pooling: model.pooling ?? 'mean', normalize: model.normalize !== false },
|
|
259
339
|
};
|
|
260
340
|
}
|
|
341
|
+
export function truncateAndNormalizeVector(vec, targetDim, normalize = true) {
|
|
342
|
+
const len = Math.min(vec.length, targetDim);
|
|
343
|
+
const out = new Array(len);
|
|
344
|
+
let norm = 0;
|
|
345
|
+
for (let i = 0; i < len; i++) {
|
|
346
|
+
const val = vec[i];
|
|
347
|
+
out[i] = val;
|
|
348
|
+
norm += val * val;
|
|
349
|
+
}
|
|
350
|
+
if (normalize) {
|
|
351
|
+
norm = Math.sqrt(norm);
|
|
352
|
+
if (norm > 0) {
|
|
353
|
+
for (let i = 0; i < len; i++) {
|
|
354
|
+
out[i] /= norm;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
261
360
|
export async function embed(texts, kind, model, cacheDir, offline = false, retryOpts = {}) {
|
|
262
361
|
const ext = await getExtractor(model, cacheDir, offline, retryOpts);
|
|
263
362
|
const { input, options } = prepareEmbeddingRequest(model, texts, kind);
|
|
264
363
|
const out = await ext(input, options);
|
|
265
|
-
|
|
364
|
+
const list = out.tolist();
|
|
365
|
+
const targetDim = model.dim;
|
|
366
|
+
if (targetDim && list.length > 0 && list[0].length > targetDim) {
|
|
367
|
+
const doNorm = model.normalize !== false;
|
|
368
|
+
return list.map((vec) => truncateAndNormalizeVector(vec, targetDim, doNorm));
|
|
369
|
+
}
|
|
370
|
+
return list;
|
|
266
371
|
}
|
|
267
372
|
export function cosine(a, b, bOffset = 0) {
|
|
268
373
|
if (b instanceof Int8Array) {
|
|
@@ -355,7 +460,7 @@ export const SCHEMA_MIGRATIONS = {
|
|
|
355
460
|
2: migrateToSchemaV2,
|
|
356
461
|
3: migrateToSchemaV3,
|
|
357
462
|
};
|
|
358
|
-
export function walkMarkdown(dir, ignore = []) {
|
|
463
|
+
export function walkMarkdown(dir, ignore = [], errors) {
|
|
359
464
|
const out = [];
|
|
360
465
|
const ignoreRe = ignore.map(globToRegExp);
|
|
361
466
|
const walk = (cur) => {
|
|
@@ -363,7 +468,14 @@ export function walkMarkdown(dir, ignore = []) {
|
|
|
363
468
|
try {
|
|
364
469
|
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
365
470
|
}
|
|
366
|
-
catch {
|
|
471
|
+
catch (e) {
|
|
472
|
+
// issue #189: an unreadable directory (permissions, OneDrive eviction)
|
|
473
|
+
// used to silently shrink the corpus; surface it when a collector is
|
|
474
|
+
// given, always at least as a stderr warning
|
|
475
|
+
const msg = `walkMarkdown: cannot read directory ${cur}: ${e?.message || e}`;
|
|
476
|
+
if (errors)
|
|
477
|
+
errors.push(msg);
|
|
478
|
+
process.stderr.write(`warning: ${msg}\n`);
|
|
367
479
|
return;
|
|
368
480
|
}
|
|
369
481
|
for (const e of entries) {
|
|
@@ -465,17 +577,21 @@ export function extractTitle(frontmatter, body, relPath) {
|
|
|
465
577
|
return path.basename(relPath).replace(/\.(md|markdown)$/i, '');
|
|
466
578
|
}
|
|
467
579
|
const DEFAULT_MAX_CHUNK = 1400;
|
|
468
|
-
export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
|
|
469
|
-
|
|
580
|
+
export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK, maxChunkTokens) {
|
|
581
|
+
// issue #202: a token budget (maxChunkTokens) takes precedence over the
|
|
582
|
+
// legacy char budget; without it the pre-#202 char behavior is unchanged
|
|
583
|
+
return chunkMarkdownStructural(body, maxChunkTokens && maxChunkTokens > 0 ? { maxChunkTokens } : maxChunk);
|
|
470
584
|
}
|
|
471
|
-
export function parseFile(absPath, dbDir, maxChunk, raw
|
|
585
|
+
export function parseFile(absPath, dbDir, maxChunk, raw,
|
|
586
|
+
/** issue #202: token budget overrides the char budget when set. */
|
|
587
|
+
maxChunkTokens) {
|
|
472
588
|
const content = raw ?? fs.readFileSync(absPath, 'utf8');
|
|
473
589
|
const { frontmatter, body } = splitFrontmatter(content);
|
|
474
590
|
const meta = parseFrontmatter(frontmatter);
|
|
475
591
|
const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
|
|
476
592
|
const title = meta.title || extractTitle(frontmatter, body, rel);
|
|
477
593
|
const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
|
|
478
|
-
return chunkMarkdown(body, maxChunk).map((c) => {
|
|
594
|
+
return chunkMarkdown(body, maxChunk, maxChunkTokens).map((c) => {
|
|
479
595
|
const heading = c.heading || title;
|
|
480
596
|
return {
|
|
481
597
|
file: rel,
|
|
@@ -489,3 +605,81 @@ export function parseFile(absPath, dbDir, maxChunk, raw) {
|
|
|
489
605
|
};
|
|
490
606
|
});
|
|
491
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Line-range retrieval helper (issue #140).
|
|
610
|
+
* Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
|
|
611
|
+
*/
|
|
612
|
+
export function getDocLines(opts) {
|
|
613
|
+
const { file, fromLine = 1, maxLines, dbDir, chunks } = opts;
|
|
614
|
+
if (!file || typeof file !== 'string') {
|
|
615
|
+
throw new Error('getDocLines: "file" parameter is required');
|
|
616
|
+
}
|
|
617
|
+
const normalizedFile = file.split(/[/\\]/).join('/');
|
|
618
|
+
let rawContent = null;
|
|
619
|
+
if (dbDir) {
|
|
620
|
+
const candidatePath = path.resolve(dbDir, file);
|
|
621
|
+
try {
|
|
622
|
+
assertSafePath(candidatePath, [dbDir]);
|
|
623
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) {
|
|
624
|
+
rawContent = fs.readFileSync(candidatePath, 'utf8');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
if (e.message?.includes('path traversal guard'))
|
|
629
|
+
throw e;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (rawContent === null && Array.isArray(chunks)) {
|
|
633
|
+
const matchingChunks = chunks.filter((c) => c.file === file || c.file === normalizedFile || c.file.split(/[/\\]/).join('/') === normalizedFile);
|
|
634
|
+
if (matchingChunks.length > 0) {
|
|
635
|
+
matchingChunks.sort((a, b) => (a.startLine || 0) - (b.startLine || 0));
|
|
636
|
+
const maxEnd = Math.max(...matchingChunks.map((c) => c.endLine || c.startLine || 1));
|
|
637
|
+
const lineMap = new Array(maxEnd).fill('');
|
|
638
|
+
for (const ch of matchingChunks) {
|
|
639
|
+
const start = (ch.startLine || 1) - 1;
|
|
640
|
+
const chunkLines = (ch.text || '').split(/\r?\n/);
|
|
641
|
+
for (let i = 0; i < chunkLines.length; i++) {
|
|
642
|
+
if (start + i < lineMap.length) {
|
|
643
|
+
lineMap[start + i] = chunkLines[i];
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
rawContent = lineMap.join('\n');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (rawContent === null) {
|
|
651
|
+
throw new Error(`File not found: ${file}`);
|
|
652
|
+
}
|
|
653
|
+
const lines = rawContent.split(/\r?\n/);
|
|
654
|
+
const totalLines = lines.length;
|
|
655
|
+
let from = typeof fromLine === 'number' && !isNaN(fromLine) ? Math.floor(fromLine) : 1;
|
|
656
|
+
if (from < 1)
|
|
657
|
+
from = 1;
|
|
658
|
+
let selected;
|
|
659
|
+
let to;
|
|
660
|
+
if (totalLines === 0) {
|
|
661
|
+
from = 1;
|
|
662
|
+
to = 0;
|
|
663
|
+
selected = [];
|
|
664
|
+
}
|
|
665
|
+
else if (from > totalLines) {
|
|
666
|
+
from = totalLines;
|
|
667
|
+
to = totalLines;
|
|
668
|
+
selected = [lines[totalLines - 1]];
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
const count = typeof maxLines === 'number' && !isNaN(maxLines) && maxLines > 0
|
|
672
|
+
? Math.min(Math.floor(maxLines), totalLines - from + 1)
|
|
673
|
+
: totalLines - from + 1;
|
|
674
|
+
to = from + count - 1;
|
|
675
|
+
selected = lines.slice(from - 1, to);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
file: normalizedFile,
|
|
679
|
+
fromLine: from,
|
|
680
|
+
toLine: to,
|
|
681
|
+
lineCount: selected.length,
|
|
682
|
+
totalLines,
|
|
683
|
+
text: selected.join('\n'),
|
|
684
|
+
};
|
|
685
|
+
}
|
package/dist/federation.d.ts
CHANGED
|
@@ -7,9 +7,24 @@ export interface VaultConfig {
|
|
|
7
7
|
export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
|
|
8
8
|
vaults: Array<string | VaultConfig>;
|
|
9
9
|
cacheDir?: string;
|
|
10
|
+
/**
|
|
11
|
+
* issue #179: when set, every vault path (and explicit indexDir) must resolve
|
|
12
|
+
* inside one of these roots or a traversal-guard error is thrown. Callers
|
|
13
|
+
* exposing federation to remote clients (MCP) must pin this to their trust
|
|
14
|
+
* domain; direct CLI use stays guarded only by MDSS_ROOT_GUARD.
|
|
15
|
+
*/
|
|
16
|
+
allowedRoots?: string[];
|
|
10
17
|
}
|
|
11
18
|
export interface FederatedSearchResultHit extends SearchResultHit {
|
|
12
19
|
vault: string;
|
|
13
20
|
vaultPath: string;
|
|
14
21
|
}
|
|
15
|
-
export
|
|
22
|
+
export interface FederatedSearchResult {
|
|
23
|
+
hits: FederatedSearchResultHit[];
|
|
24
|
+
/**
|
|
25
|
+
* issue #189: per-vault load/query failures surfaced to the caller — a
|
|
26
|
+
* broken vault must not silently look like "0 results".
|
|
27
|
+
*/
|
|
28
|
+
vaultErrors: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResult>;
|
package/dist/federation.js
CHANGED
|
@@ -8,20 +8,23 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import { loadIndex, searchIndex } from './search.js';
|
|
11
|
+
import { assertSafePath } from './core.js';
|
|
11
12
|
export async function searchFederated(opts) {
|
|
12
|
-
const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
|
|
13
|
+
const { vaults, k = 10, cacheDir = '', allowedRoots, ...searchOpts } = opts;
|
|
13
14
|
if (!vaults || vaults.length === 0) {
|
|
14
15
|
throw new Error('Federated search requires at least one vault specified');
|
|
15
16
|
}
|
|
16
17
|
const normalizedVaults = vaults.map((v) => {
|
|
17
18
|
if (typeof v === 'string') {
|
|
18
|
-
const absPath = path.resolve(v);
|
|
19
|
+
const absPath = assertSafePath(path.resolve(v), allowedRoots);
|
|
19
20
|
const name = path.basename(absPath);
|
|
20
21
|
return { name, path: absPath };
|
|
21
22
|
}
|
|
22
|
-
const absPath = path.resolve(v.path);
|
|
23
|
+
const absPath = assertSafePath(path.resolve(v.path), allowedRoots);
|
|
23
24
|
const name = v.name || path.basename(absPath);
|
|
24
|
-
|
|
25
|
+
// indexDir is loaded from disk too — guard it with the same roots (#179)
|
|
26
|
+
const indexDir = v.indexDir ? assertSafePath(path.resolve(v.indexDir), allowedRoots) : undefined;
|
|
27
|
+
return { name, path: absPath, indexDir };
|
|
25
28
|
});
|
|
26
29
|
// Load and query each vault concurrently
|
|
27
30
|
const perVaultResults = await Promise.all(normalizedVaults.map(async (vault) => {
|
|
@@ -39,7 +42,10 @@ export async function searchFederated(opts) {
|
|
|
39
42
|
try {
|
|
40
43
|
// Query vault index with a slightly larger pool to ensure healthy global fusion
|
|
41
44
|
const poolK = Math.max(k * 2, 20);
|
|
42
|
-
|
|
45
|
+
// issue #179: never invent a per-vault cache location — a falsy cacheDir
|
|
46
|
+
// disables the disk layer (searchIndex semantics), so query embeddings
|
|
47
|
+
// are only ever written to a cache dir the caller explicitly owns.
|
|
48
|
+
const resolvedCache = cacheDir;
|
|
43
49
|
const hits = await searchIndex({
|
|
44
50
|
...searchOpts,
|
|
45
51
|
cacheDir: resolvedCache,
|
|
@@ -52,14 +58,22 @@ export async function searchFederated(opts) {
|
|
|
52
58
|
return { vault, hits: [], error: err.message };
|
|
53
59
|
}
|
|
54
60
|
}));
|
|
61
|
+
// issue #189: surface per-vault failures — a broken vault must not look
|
|
62
|
+
// like a vault with simply no matching notes
|
|
63
|
+
const vaultErrors = perVaultResults
|
|
64
|
+
.filter((r) => r.error !== null)
|
|
65
|
+
.map((r) => `vault "${r.vault.name || path.basename(r.vault.path)}" (${r.vault.path}): ${r.error}`);
|
|
55
66
|
// If single vault, return directly with attribution
|
|
56
67
|
if (normalizedVaults.length === 1) {
|
|
57
68
|
const { vault, hits } = perVaultResults[0];
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
return {
|
|
70
|
+
hits: hits.slice(0, k).map((h) => ({
|
|
71
|
+
...h,
|
|
72
|
+
vault: vault.name || path.basename(vault.path),
|
|
73
|
+
vaultPath: vault.path,
|
|
74
|
+
})),
|
|
75
|
+
vaultErrors,
|
|
76
|
+
};
|
|
63
77
|
}
|
|
64
78
|
// Multi-vault reciprocal rank fusion (RRF)
|
|
65
79
|
// Collect all hits across vaults
|
|
@@ -85,10 +99,13 @@ export async function searchFederated(opts) {
|
|
|
85
99
|
return b.hit.score - a.hit.score;
|
|
86
100
|
})
|
|
87
101
|
.slice(0, k);
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
return {
|
|
103
|
+
hits: fused.map(({ hit, vault, rrfSum }) => ({
|
|
104
|
+
...hit,
|
|
105
|
+
score: rrfSum,
|
|
106
|
+
vault: vault.name || path.basename(vault.path),
|
|
107
|
+
vaultPath: vault.path,
|
|
108
|
+
})),
|
|
109
|
+
vaultErrors,
|
|
110
|
+
};
|
|
94
111
|
}
|
package/dist/filter.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ export type FilterNode = {
|
|
|
22
22
|
value: string | number | boolean;
|
|
23
23
|
};
|
|
24
24
|
export declare function tokenizeFilter(expr: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
|
|
27
|
+
* (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
|
|
28
|
+
* request body would exhaust the call stack. 64 levels is far beyond any
|
|
29
|
+
* legitimate filter and keeps the parser fail-closed with a clear error.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_FILTER_DEPTH = 64;
|
|
25
32
|
export declare function parseFilter(expr: string): FilterNode;
|
|
26
33
|
export declare function evaluateFilter(node: FilterNode | string, meta?: DocumentMetadata, doc?: {
|
|
27
34
|
file?: string;
|
package/dist/filter.js
CHANGED
|
@@ -72,12 +72,20 @@ export function tokenizeFilter(expr) {
|
|
|
72
72
|
}
|
|
73
73
|
return tokens;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
|
|
77
|
+
* (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
|
|
78
|
+
* request body would exhaust the call stack. 64 levels is far beyond any
|
|
79
|
+
* legitimate filter and keeps the parser fail-closed with a clear error.
|
|
80
|
+
*/
|
|
81
|
+
export const MAX_FILTER_DEPTH = 64;
|
|
75
82
|
export function parseFilter(expr) {
|
|
76
83
|
const tokens = tokenizeFilter(expr);
|
|
77
84
|
if (tokens.length === 0) {
|
|
78
85
|
throw new Error('Empty filter expression');
|
|
79
86
|
}
|
|
80
87
|
let pos = 0;
|
|
88
|
+
let depth = 0;
|
|
81
89
|
function peek() {
|
|
82
90
|
return tokens[pos];
|
|
83
91
|
}
|
|
@@ -131,12 +139,17 @@ export function parseFilter(expr) {
|
|
|
131
139
|
throw new Error(`Unexpected end of filter expression at position ${pos}`);
|
|
132
140
|
}
|
|
133
141
|
if (token === '(') {
|
|
142
|
+
depth += 1;
|
|
143
|
+
if (depth > MAX_FILTER_DEPTH) {
|
|
144
|
+
throw new Error(`Filter expression too deeply nested (max ${MAX_FILTER_DEPTH} levels of parentheses)`);
|
|
145
|
+
}
|
|
134
146
|
consume(); // (
|
|
135
147
|
const node = parseExpression();
|
|
136
148
|
if (peek() !== ')') {
|
|
137
149
|
throw new Error('Expected closing parenthesis ")" in filter expression');
|
|
138
150
|
}
|
|
139
151
|
consume(); // )
|
|
152
|
+
depth -= 1;
|
|
140
153
|
return node;
|
|
141
154
|
}
|
|
142
155
|
// Comparison term: <field> <op> <value> or <field>:<value>
|