md-semantic-search 0.4.0 → 0.5.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 +81 -0
- package/bin/cli.mjs +368 -14
- package/dist/collapse.d.ts +6 -0
- package/dist/collapse.js +20 -0
- package/dist/core.d.ts +84 -0
- package/dist/core.js +479 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +150 -0
- package/dist/index-format.d.ts +23 -0
- package/dist/index-format.js +125 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +29 -0
- package/dist/indexer.d.ts +70 -0
- package/dist/indexer.js +452 -0
- package/dist/ivf.d.ts +33 -0
- package/dist/ivf.js +144 -0
- package/dist/lexical.d.ts +44 -0
- package/dist/lexical.js +226 -0
- package/dist/markdown-parser.d.ts +27 -0
- package/dist/markdown-parser.js +252 -0
- package/dist/mcp.d.ts +111 -0
- package/dist/mcp.js +216 -0
- package/dist/metrics.d.ts +43 -0
- package/dist/metrics.js +97 -0
- package/dist/models.d.ts +38 -0
- package/dist/models.js +181 -0
- package/dist/rerank.d.ts +10 -0
- package/dist/rerank.js +41 -0
- package/dist/search.d.ts +135 -0
- package/dist/search.js +492 -0
- package/dist/serve.d.ts +49 -0
- package/dist/serve.js +321 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +196 -0
- package/dist/wikilinks.d.ts +47 -0
- package/dist/wikilinks.js +171 -0
- package/package.json +19 -5
- package/src/{collapse.mjs → collapse.ts} +7 -12
- package/src/core.ts +546 -0
- package/src/{frontmatter.mjs → frontmatter.ts} +27 -50
- package/src/{index-format.mjs → index-format.ts} +57 -40
- package/src/{index.mjs → index.ts} +16 -8
- package/src/indexer.ts +585 -0
- package/src/ivf.ts +190 -0
- package/src/{lexical.mjs → lexical.ts} +66 -100
- package/src/{markdown-parser.mjs → markdown-parser.ts} +56 -100
- package/src/mcp.ts +243 -0
- package/src/metrics.ts +135 -0
- package/src/models.ts +219 -0
- package/src/rerank.ts +54 -0
- package/src/search.ts +628 -0
- package/src/serve.ts +372 -0
- package/src/tui.ts +227 -0
- package/src/{wikilinks.mjs → wikilinks.ts} +70 -67
- package/src/core.mjs +0 -535
- package/src/indexer.mjs +0 -540
- package/src/metrics.mjs +0 -186
- package/src/models.mjs +0 -299
- package/src/rerank.mjs +0 -75
- package/src/search.mjs +0 -636
- package/src/serve.mjs +0 -387
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result collapse and diversity control helpers (issue #45).
|
|
3
|
+
* Deduplicates / caps multiple chunks per document or canonical identity
|
|
4
|
+
* to increase result diversity in search output.
|
|
5
|
+
*/
|
|
6
|
+
export declare function collapseResults<T>(results: T[], getDocId: (hit: T) => string, maxPerDoc?: number): T[];
|
package/dist/collapse.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result collapse and diversity control helpers (issue #45).
|
|
3
|
+
* Deduplicates / caps multiple chunks per document or canonical identity
|
|
4
|
+
* to increase result diversity in search output.
|
|
5
|
+
*/
|
|
6
|
+
export function collapseResults(results, getDocId, maxPerDoc = 1) {
|
|
7
|
+
if (!maxPerDoc || maxPerDoc <= 0)
|
|
8
|
+
return results;
|
|
9
|
+
const counts = new Map();
|
|
10
|
+
const collapsed = [];
|
|
11
|
+
for (const hit of results) {
|
|
12
|
+
const docId = getDocId(hit);
|
|
13
|
+
const count = counts.get(docId) || 0;
|
|
14
|
+
if (count < maxPerDoc) {
|
|
15
|
+
counts.set(docId, count + 1);
|
|
16
|
+
collapsed.push(hit);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return collapsed;
|
|
20
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { resolveModel, ModelAdapter, Pooling, DType } from './models.js';
|
|
2
|
+
import { PARSER_VERSION, StructuralChunk } from './markdown-parser.js';
|
|
3
|
+
import { parseFrontmatter, DocumentMetadata } from './frontmatter.js';
|
|
4
|
+
export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType };
|
|
5
|
+
export declare const LOCK_FILENAME = ".mdss.lock";
|
|
6
|
+
export declare function pidAlive(pid: number | null): boolean;
|
|
7
|
+
export interface LockInfo {
|
|
8
|
+
pid: number;
|
|
9
|
+
since: string;
|
|
10
|
+
}
|
|
11
|
+
export type LockAcquireResult = {
|
|
12
|
+
acquired: true;
|
|
13
|
+
lockPath: string;
|
|
14
|
+
} | {
|
|
15
|
+
acquired: false;
|
|
16
|
+
reason: string;
|
|
17
|
+
pid: number | null;
|
|
18
|
+
heldSince: string | null;
|
|
19
|
+
};
|
|
20
|
+
export declare function acquireIndexLock(indexDir: string): LockAcquireResult;
|
|
21
|
+
export declare function releaseIndexLock(indexDir: string): void;
|
|
22
|
+
export declare function withIndexLock<T>(indexDir: string, fn: () => Promise<T>): Promise<T>;
|
|
23
|
+
export type ModelDescriptor = ModelAdapter;
|
|
24
|
+
export declare function getPipelineModelSource(model: ModelDescriptor, cacheDir: string, offline: boolean): string;
|
|
25
|
+
export declare function resolveSessionOptions(model?: ModelDescriptor): Record<string, unknown> | undefined;
|
|
26
|
+
export declare function getExtractorCacheKey(model: ModelDescriptor, cacheDir: string, offline?: boolean): string;
|
|
27
|
+
export declare function isNetworkError(err: any): boolean;
|
|
28
|
+
export declare function retryWithBackoff<T>(fn: () => Promise<T>, opts?: {
|
|
29
|
+
maxRetries?: number;
|
|
30
|
+
delays?: number[];
|
|
31
|
+
log?: (msg: string) => void;
|
|
32
|
+
}): Promise<T>;
|
|
33
|
+
export declare function getExtractor(model: ModelDescriptor, cacheDir: string, offline?: boolean, retryOpts?: {
|
|
34
|
+
maxRetries?: number;
|
|
35
|
+
delays?: number[];
|
|
36
|
+
log?: (msg: string) => void;
|
|
37
|
+
}): Promise<any>;
|
|
38
|
+
export declare function prepareEmbeddingRequest(model: ModelDescriptor, texts: string[], kind: 'query' | 'passage'): {
|
|
39
|
+
input: string[];
|
|
40
|
+
options: {
|
|
41
|
+
pooling: Pooling;
|
|
42
|
+
normalize: boolean;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export declare function embed(texts: string[], kind: 'query' | 'passage', model: ModelDescriptor, cacheDir: string, offline?: boolean, retryOpts?: {
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
delays?: number[];
|
|
48
|
+
log?: (msg: string) => void;
|
|
49
|
+
}): Promise<number[][]>;
|
|
50
|
+
export declare function cosine(a: Float32Array | number[], b: Float32Array | number[], bOffset?: number): number;
|
|
51
|
+
export declare function encodeVec(vec: number[] | Float32Array): string;
|
|
52
|
+
export declare function decodeVec(s: string, dim?: number): Float32Array;
|
|
53
|
+
export declare function isBinaryIndex(index: {
|
|
54
|
+
format?: string;
|
|
55
|
+
}): boolean;
|
|
56
|
+
export declare function parseSchemaVersion(value: unknown): number;
|
|
57
|
+
export declare function resolveIndexDimension(stored: unknown, known: number): number | undefined;
|
|
58
|
+
export declare const SCHEMA_VERSION = 3;
|
|
59
|
+
export declare function migrateToSchemaV2(index: {
|
|
60
|
+
schemaVersion?: number;
|
|
61
|
+
}): void;
|
|
62
|
+
export declare function migrateToSchemaV3(): void;
|
|
63
|
+
export declare const SCHEMA_MIGRATIONS: Record<number, (index: any) => void>;
|
|
64
|
+
export declare function walkMarkdown(dir: string, ignore?: string[]): string[];
|
|
65
|
+
export declare function assertSafePath(targetPath: string, allowedRoots?: string[]): string;
|
|
66
|
+
export declare function validateGlob(glob: string): string;
|
|
67
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
68
|
+
export declare function splitFrontmatter(raw: string): {
|
|
69
|
+
frontmatter: string;
|
|
70
|
+
body: string;
|
|
71
|
+
};
|
|
72
|
+
export declare function extractTitle(frontmatter: string, body: string, relPath: string): string;
|
|
73
|
+
export declare function chunkMarkdown(body: string, maxChunk?: number): StructuralChunk[];
|
|
74
|
+
export interface ParsedChunkFile {
|
|
75
|
+
file: string;
|
|
76
|
+
title: string;
|
|
77
|
+
heading: string;
|
|
78
|
+
headingPath: string[];
|
|
79
|
+
text: string;
|
|
80
|
+
startLine: number;
|
|
81
|
+
endLine: number;
|
|
82
|
+
meta: DocumentMetadata;
|
|
83
|
+
}
|
|
84
|
+
export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string): ParsedChunkFile[];
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core helpers: model loading, embeddings, markdown walking + chunking, cosine.
|
|
3
|
+
* Fully model- and path-agnostic — everything is driven by explicit arguments
|
|
4
|
+
* so the same code works on any folder of .md files, anywhere on disk.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { resolveModel } from './models.js';
|
|
10
|
+
import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
|
|
11
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
12
|
+
export { PARSER_VERSION, parseFrontmatter, resolveModel };
|
|
13
|
+
const _extractors = new Map();
|
|
14
|
+
export const LOCK_FILENAME = '.mdss.lock';
|
|
15
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
16
|
+
export function pidAlive(pid) {
|
|
17
|
+
if (!Number.isInteger(pid) || pid === null || pid <= 0)
|
|
18
|
+
return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
return e.code === 'EPERM';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function readLock(lockPath) {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(lockPath, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(raw);
|
|
37
|
+
if (!Number.isInteger(j.pid) || typeof j.since !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
return j;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function acquireIndexLock(indexDir) {
|
|
46
|
+
const lockPath = path.join(indexDir, LOCK_FILENAME);
|
|
47
|
+
const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
+
let fd;
|
|
50
|
+
try {
|
|
51
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
52
|
+
fs.writeFileSync(fd, payload);
|
|
53
|
+
return { acquired: true, lockPath };
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e.code !== 'EEXIST') {
|
|
57
|
+
throw new Error(`cannot create index lock ${lockPath}: ${e.message}`, { cause: e });
|
|
58
|
+
}
|
|
59
|
+
let stat = null;
|
|
60
|
+
try {
|
|
61
|
+
stat = fs.statSync(lockPath);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* raced */
|
|
65
|
+
}
|
|
66
|
+
const info = readLock(lockPath);
|
|
67
|
+
const pid = info?.pid ?? null;
|
|
68
|
+
const heldSince = info?.since ?? null;
|
|
69
|
+
const garbage = info === null;
|
|
70
|
+
const dead = pid !== null && !pidAlive(pid);
|
|
71
|
+
const staleMs = stat ? Date.now() - stat.mtimeMs : 0;
|
|
72
|
+
if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(lockPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* raced */
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
acquired: false,
|
|
83
|
+
reason: garbage
|
|
84
|
+
? 'lock file is unreadable/abandoned'
|
|
85
|
+
: dead
|
|
86
|
+
? `lock held by dead pid ${pid} (since ${heldSince})`
|
|
87
|
+
: `index is being written by pid ${pid ?? '?'} (since ${heldSince ?? 'unknown'})`,
|
|
88
|
+
pid,
|
|
89
|
+
heldSince,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (fd !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
fs.closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* closed */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { acquired: false, reason: 'lock reclaimed by another process during retry', pid: null, heldSince: null };
|
|
104
|
+
}
|
|
105
|
+
export function releaseIndexLock(indexDir) {
|
|
106
|
+
try {
|
|
107
|
+
fs.unlinkSync(path.join(indexDir, LOCK_FILENAME));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function withIndexLock(indexDir, fn) {
|
|
114
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
115
|
+
const r = acquireIndexLock(indexDir);
|
|
116
|
+
if (!r.acquired)
|
|
117
|
+
throw new Error(r.reason);
|
|
118
|
+
try {
|
|
119
|
+
return await fn();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
releaseIndexLock(indexDir);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function getPipelineModelSource(model, cacheDir, offline) {
|
|
126
|
+
const revision = model.revision || 'main';
|
|
127
|
+
return offline && revision !== 'main' ? path.join(cacheDir, model.id, revision) : model.id;
|
|
128
|
+
}
|
|
129
|
+
export function resolveSessionOptions(model) {
|
|
130
|
+
const envIntra = process.env.MDSS_INTRA_OP_THREADS || process.env.MDSS_NUM_THREADS;
|
|
131
|
+
const envInter = process.env.MDSS_INTER_OP_THREADS;
|
|
132
|
+
const intra = envIntra ? parseInt(envIntra, 10) : undefined;
|
|
133
|
+
const inter = envInter ? parseInt(envInter, 10) : undefined;
|
|
134
|
+
const opts = model?.sessionOptions || {};
|
|
135
|
+
const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0 ? intra : opts.intraOpNumThreads;
|
|
136
|
+
const interOpNumThreads = Number.isSafeInteger(inter) && inter && inter > 0 ? inter : opts.interOpNumThreads;
|
|
137
|
+
if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const result = {};
|
|
141
|
+
if (intraOpNumThreads !== undefined)
|
|
142
|
+
result.intraOpNumThreads = intraOpNumThreads;
|
|
143
|
+
if (interOpNumThreads !== undefined)
|
|
144
|
+
result.interOpNumThreads = interOpNumThreads;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
export function getExtractorCacheKey(model, cacheDir, offline) {
|
|
148
|
+
const revision = model.revision || 'main';
|
|
149
|
+
const source = getPipelineModelSource(model, cacheDir, !!offline);
|
|
150
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
151
|
+
const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
|
|
152
|
+
return `${model.id}@${revision}|${offline ? 'off' : 'on'}|${source}|${sessionKey}`;
|
|
153
|
+
}
|
|
154
|
+
export function isNetworkError(err) {
|
|
155
|
+
if (!err)
|
|
156
|
+
return false;
|
|
157
|
+
const code = err.code || err.cause?.code;
|
|
158
|
+
if (code && ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'FETCH_ERROR'].includes(code)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (err.name === 'FetchError')
|
|
162
|
+
return true;
|
|
163
|
+
const status = err.status || err.statusCode || err.cause?.status;
|
|
164
|
+
if (typeof status === 'number' && status >= 500 && status < 600)
|
|
165
|
+
return true;
|
|
166
|
+
const msg = (err.message || '').toLowerCase();
|
|
167
|
+
return (msg.includes('fetch failed') ||
|
|
168
|
+
msg.includes('etimedout') ||
|
|
169
|
+
msg.includes('enotfound') ||
|
|
170
|
+
msg.includes('network error') ||
|
|
171
|
+
msg.includes('503 service unavailable') ||
|
|
172
|
+
msg.includes('502 bad gateway') ||
|
|
173
|
+
msg.includes('504 gateway timeout'));
|
|
174
|
+
}
|
|
175
|
+
export async function retryWithBackoff(fn, opts = {}) {
|
|
176
|
+
const maxRetries = Number.isInteger(opts.maxRetries) && opts.maxRetries >= 0 ? opts.maxRetries : 3;
|
|
177
|
+
const delays = opts.delays || [1000, 4000, 16000];
|
|
178
|
+
const log = opts.log || (() => { });
|
|
179
|
+
let attempt = 0;
|
|
180
|
+
while (true) {
|
|
181
|
+
try {
|
|
182
|
+
return await fn();
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (attempt >= maxRetries || !isNetworkError(err)) {
|
|
186
|
+
if (attempt > 0 && isNetworkError(err)) {
|
|
187
|
+
throw new Error(`model download failed after ${attempt} attempts — check network or use --offline (${err.message})`, { cause: err });
|
|
188
|
+
}
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
attempt++;
|
|
192
|
+
const delayMs = delays[attempt - 1] ?? delays[delays.length - 1] ?? 1000;
|
|
193
|
+
log(`retrying model download (attempt ${attempt}/${maxRetries}) after ${delayMs}ms…`);
|
|
194
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function getExtractor(model, cacheDir, offline = false, retryOpts = {}) {
|
|
199
|
+
if (model.unknownAdapter === true) {
|
|
200
|
+
throw new Error(`model "${model.id}" is not a registered adapter and has no explicit ` +
|
|
201
|
+
'configuration, so it cannot embed safely (no E5 prefixes / pooling are ' +
|
|
202
|
+
'guessed). Register it in MODELS or pass an explicit adapter descriptor ' +
|
|
203
|
+
'(see README "How to add a model") — e.g. for a raw id, supply ' +
|
|
204
|
+
'queryPrefix/passagePrefix/dim/pooling explicitly.');
|
|
205
|
+
}
|
|
206
|
+
const revision = model.revision || 'main';
|
|
207
|
+
const source = getPipelineModelSource(model, cacheDir, offline);
|
|
208
|
+
const key = getExtractorCacheKey(model, cacheDir, offline);
|
|
209
|
+
if (_extractors.has(key))
|
|
210
|
+
return _extractors.get(key);
|
|
211
|
+
const loadPipeline = async () => {
|
|
212
|
+
const { pipeline, env } = await import('@huggingface/transformers');
|
|
213
|
+
if (cacheDir)
|
|
214
|
+
env.cacheDir = cacheDir;
|
|
215
|
+
env.allowRemoteModels = !offline;
|
|
216
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
217
|
+
const pipeOpts = {
|
|
218
|
+
revision,
|
|
219
|
+
dtype: model.dtype || 'q8',
|
|
220
|
+
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
221
|
+
};
|
|
222
|
+
const ext = await pipeline('feature-extraction', source, pipeOpts);
|
|
223
|
+
_extractors.set(key, ext);
|
|
224
|
+
return ext;
|
|
225
|
+
};
|
|
226
|
+
if (offline) {
|
|
227
|
+
return await loadPipeline();
|
|
228
|
+
}
|
|
229
|
+
return await retryWithBackoff(loadPipeline, retryOpts);
|
|
230
|
+
}
|
|
231
|
+
export function prepareEmbeddingRequest(model, texts, kind) {
|
|
232
|
+
if (model.unknownAdapter === true) {
|
|
233
|
+
throw new Error(`model "${model.id}" is not a registered adapter and has no explicit ` +
|
|
234
|
+
'configuration, so it cannot embed safely (issue #60) — supply an ' +
|
|
235
|
+
'explicit adapter descriptor, see README "How to add a model".');
|
|
236
|
+
}
|
|
237
|
+
const prefix = kind === 'query' ? model.queryPrefix : model.passagePrefix;
|
|
238
|
+
const input = prefix ? texts.map((text) => prefix + text) : texts;
|
|
239
|
+
return {
|
|
240
|
+
input,
|
|
241
|
+
options: { pooling: model.pooling ?? 'mean', normalize: model.normalize !== false },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export async function embed(texts, kind, model, cacheDir, offline = false, retryOpts = {}) {
|
|
245
|
+
const ext = await getExtractor(model, cacheDir, offline, retryOpts);
|
|
246
|
+
const { input, options } = prepareEmbeddingRequest(model, texts, kind);
|
|
247
|
+
const out = await ext(input, options);
|
|
248
|
+
return out.tolist();
|
|
249
|
+
}
|
|
250
|
+
export function cosine(a, b, bOffset = 0) {
|
|
251
|
+
const len = a.length;
|
|
252
|
+
let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
|
|
253
|
+
let s4 = 0, s5 = 0, s6 = 0, s7 = 0;
|
|
254
|
+
let i = 0;
|
|
255
|
+
const limit = len - 7;
|
|
256
|
+
while (i < limit) {
|
|
257
|
+
s0 += a[i] * b[bOffset + i];
|
|
258
|
+
s1 += a[i + 1] * b[bOffset + i + 1];
|
|
259
|
+
s2 += a[i + 2] * b[bOffset + i + 2];
|
|
260
|
+
s3 += a[i + 3] * b[bOffset + i + 3];
|
|
261
|
+
s4 += a[i + 4] * b[bOffset + i + 4];
|
|
262
|
+
s5 += a[i + 5] * b[bOffset + i + 5];
|
|
263
|
+
s6 += a[i + 6] * b[bOffset + i + 6];
|
|
264
|
+
s7 += a[i + 7] * b[bOffset + i + 7];
|
|
265
|
+
i += 8;
|
|
266
|
+
}
|
|
267
|
+
let s = s0 + s1 + (s2 + s3) + (s4 + s5) + (s6 + s7);
|
|
268
|
+
while (i < len) {
|
|
269
|
+
s += a[i] * b[bOffset + i];
|
|
270
|
+
i++;
|
|
271
|
+
}
|
|
272
|
+
return s;
|
|
273
|
+
}
|
|
274
|
+
export function encodeVec(vec) {
|
|
275
|
+
const f32 = vec instanceof Float32Array ? vec : Float32Array.from(vec);
|
|
276
|
+
return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength).toString('base64');
|
|
277
|
+
}
|
|
278
|
+
export function decodeVec(s, dim) {
|
|
279
|
+
if (s.length === 0) {
|
|
280
|
+
throw new Error('corrupt base64 vector: empty value — run `mdss index` to rebuild');
|
|
281
|
+
}
|
|
282
|
+
if (s.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
|
|
283
|
+
throw new Error('corrupt base64 vector: encoding is not canonical — run `mdss index` to rebuild');
|
|
284
|
+
}
|
|
285
|
+
const buf = Buffer.from(s, 'base64');
|
|
286
|
+
if (buf.toString('base64') !== s) {
|
|
287
|
+
throw new Error('corrupt base64 vector: encoding is not canonical — run `mdss index` to rebuild');
|
|
288
|
+
}
|
|
289
|
+
if (buf.byteLength % 4 !== 0) {
|
|
290
|
+
throw new Error(`corrupt base64 vector: ${buf.byteLength} bytes is not a multiple of 4 (a float32 is 4 bytes) — run \`mdss index\` to rebuild`);
|
|
291
|
+
}
|
|
292
|
+
const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
293
|
+
if (dim !== undefined && vec.length !== dim) {
|
|
294
|
+
throw new Error(`corrupt vector: ${vec.length} dims, expected ${dim} — run \`mdss index\` to rebuild`);
|
|
295
|
+
}
|
|
296
|
+
for (let i = 0; i < vec.length; i++) {
|
|
297
|
+
if (!Number.isFinite(vec[i])) {
|
|
298
|
+
throw new Error(`corrupt vector: non-finite value at index ${i} (NaN/Infinity) — run \`mdss index\` to rebuild`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return vec;
|
|
302
|
+
}
|
|
303
|
+
export function isBinaryIndex(index) {
|
|
304
|
+
return index.format === 'binary-v1';
|
|
305
|
+
}
|
|
306
|
+
export function parseSchemaVersion(value) {
|
|
307
|
+
if (value === undefined)
|
|
308
|
+
return 0;
|
|
309
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
310
|
+
throw new Error('schemaVersion must be a non-negative safe integer — run `mdss index` to rebuild');
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
export function resolveIndexDimension(stored, known) {
|
|
315
|
+
if (stored !== undefined) {
|
|
316
|
+
if (typeof stored !== 'number' || !Number.isSafeInteger(stored) || stored <= 0) {
|
|
317
|
+
throw new Error('index.dim must be a positive safe integer — run `mdss index` to rebuild');
|
|
318
|
+
}
|
|
319
|
+
if (known > 0 && stored !== known) {
|
|
320
|
+
throw new Error(`index.dim ${stored} does not match known model dimension ${known} — run \`mdss index\` to rebuild`);
|
|
321
|
+
}
|
|
322
|
+
return stored;
|
|
323
|
+
}
|
|
324
|
+
return known > 0 ? known : undefined;
|
|
325
|
+
}
|
|
326
|
+
export const SCHEMA_VERSION = 3;
|
|
327
|
+
export function migrateToSchemaV2(index) {
|
|
328
|
+
index.schemaVersion = 2;
|
|
329
|
+
}
|
|
330
|
+
export function migrateToSchemaV3() { }
|
|
331
|
+
export const SCHEMA_MIGRATIONS = {
|
|
332
|
+
1: () => { },
|
|
333
|
+
2: migrateToSchemaV2,
|
|
334
|
+
3: migrateToSchemaV3,
|
|
335
|
+
};
|
|
336
|
+
export function walkMarkdown(dir, ignore = []) {
|
|
337
|
+
const out = [];
|
|
338
|
+
const ignoreRe = ignore.map(globToRegExp);
|
|
339
|
+
const walk = (cur) => {
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
for (const e of entries) {
|
|
348
|
+
const full = path.join(cur, e.name);
|
|
349
|
+
const rel = path.relative(dir, full).split(path.sep).join('/');
|
|
350
|
+
if (e.name.startsWith('.'))
|
|
351
|
+
continue;
|
|
352
|
+
if (ignoreRe.some((re) => re.test(rel) || re.test(e.name)))
|
|
353
|
+
continue;
|
|
354
|
+
if (e.isDirectory())
|
|
355
|
+
walk(full);
|
|
356
|
+
else if (/\.(md|markdown)$/i.test(e.name))
|
|
357
|
+
out.push(full);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
walk(dir);
|
|
361
|
+
return out.sort();
|
|
362
|
+
}
|
|
363
|
+
export function assertSafePath(targetPath, allowedRoots) {
|
|
364
|
+
if (typeof targetPath !== 'string' || !targetPath.trim()) {
|
|
365
|
+
throw new Error('path must be a non-empty string');
|
|
366
|
+
}
|
|
367
|
+
const resolved = path.resolve(targetPath);
|
|
368
|
+
let canonical = resolved;
|
|
369
|
+
try {
|
|
370
|
+
canonical = fs.realpathSync(resolved);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* non-existent path */
|
|
374
|
+
}
|
|
375
|
+
let roots = allowedRoots;
|
|
376
|
+
if (!roots || roots.length === 0) {
|
|
377
|
+
if (process.env.MDSS_ROOT_GUARD) {
|
|
378
|
+
roots = process.env.MDSS_ROOT_GUARD.split(path.delimiter).map((p) => path.resolve(p.trim())).filter(Boolean);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
roots = [path.resolve(process.cwd())];
|
|
382
|
+
try {
|
|
383
|
+
const parent = path.resolve(process.cwd(), '..');
|
|
384
|
+
if (parent)
|
|
385
|
+
roots.push(parent);
|
|
386
|
+
}
|
|
387
|
+
catch { }
|
|
388
|
+
try {
|
|
389
|
+
const home = os.homedir();
|
|
390
|
+
if (home)
|
|
391
|
+
roots.push(path.resolve(home));
|
|
392
|
+
}
|
|
393
|
+
catch { }
|
|
394
|
+
try {
|
|
395
|
+
const tmp = os.tmpdir();
|
|
396
|
+
if (tmp)
|
|
397
|
+
roots.push(path.resolve(tmp));
|
|
398
|
+
}
|
|
399
|
+
catch { }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const isAllowed = roots.some((root) => {
|
|
403
|
+
let canonicalRoot = root;
|
|
404
|
+
try {
|
|
405
|
+
canonicalRoot = fs.realpathSync(root);
|
|
406
|
+
}
|
|
407
|
+
catch { }
|
|
408
|
+
const rel = path.relative(canonicalRoot, canonical);
|
|
409
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
410
|
+
});
|
|
411
|
+
if (!isAllowed) {
|
|
412
|
+
throw new Error(`path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`);
|
|
413
|
+
}
|
|
414
|
+
return canonical;
|
|
415
|
+
}
|
|
416
|
+
export function validateGlob(glob) {
|
|
417
|
+
if (typeof glob !== 'string')
|
|
418
|
+
throw new Error('glob must be a string');
|
|
419
|
+
if (/[|()$`{}]/.test(glob)) {
|
|
420
|
+
const match = glob.match(/[|()$`{}]/);
|
|
421
|
+
throw new Error(`invalid glob pattern "${glob}": forbidden character "${match ? match[0] : ''}"`);
|
|
422
|
+
}
|
|
423
|
+
return glob;
|
|
424
|
+
}
|
|
425
|
+
export function globToRegExp(glob) {
|
|
426
|
+
validateGlob(glob);
|
|
427
|
+
const esc = glob
|
|
428
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
429
|
+
.replace(/\*\*/g, '\u0000')
|
|
430
|
+
.replace(/\*/g, '[^/]*')
|
|
431
|
+
.replace(/\u0000/g, '.*');
|
|
432
|
+
return new RegExp(`^${esc}$`, 'i');
|
|
433
|
+
}
|
|
434
|
+
export function splitFrontmatter(raw) {
|
|
435
|
+
if (raw.startsWith('---')) {
|
|
436
|
+
const end = raw.indexOf('\n---', 3);
|
|
437
|
+
if (end !== -1) {
|
|
438
|
+
return {
|
|
439
|
+
frontmatter: raw.slice(3, end).trim(),
|
|
440
|
+
body: raw.slice(end + 4).replace(/^\s*\n/, ''),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return { frontmatter: '', body: raw };
|
|
445
|
+
}
|
|
446
|
+
export function extractTitle(frontmatter, body, relPath) {
|
|
447
|
+
const fmTitle = frontmatter.match(/^title:\s*(.+)$/m);
|
|
448
|
+
if (fmTitle)
|
|
449
|
+
return fmTitle[1].trim().replace(/^["']|["']$/g, '');
|
|
450
|
+
const h1 = body.match(/^#\s+(.+)$/m);
|
|
451
|
+
if (h1)
|
|
452
|
+
return h1[1].trim();
|
|
453
|
+
return path.basename(relPath).replace(/\.(md|markdown)$/i, '');
|
|
454
|
+
}
|
|
455
|
+
const DEFAULT_MAX_CHUNK = 1400;
|
|
456
|
+
export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
|
|
457
|
+
return chunkMarkdownStructural(body, maxChunk);
|
|
458
|
+
}
|
|
459
|
+
export function parseFile(absPath, dbDir, maxChunk, raw) {
|
|
460
|
+
const content = raw ?? fs.readFileSync(absPath, 'utf8');
|
|
461
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
462
|
+
const meta = parseFrontmatter(frontmatter);
|
|
463
|
+
const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
|
|
464
|
+
const title = meta.title || extractTitle(frontmatter, body, rel);
|
|
465
|
+
const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
|
|
466
|
+
return chunkMarkdown(body, maxChunk).map((c) => {
|
|
467
|
+
const heading = c.heading || title;
|
|
468
|
+
return {
|
|
469
|
+
file: rel,
|
|
470
|
+
title,
|
|
471
|
+
heading,
|
|
472
|
+
headingPath: c.headingPath.length > 0 ? c.headingPath : [heading],
|
|
473
|
+
text: c.text,
|
|
474
|
+
startLine: (c.startLine || 1) + frontmatterLineCount,
|
|
475
|
+
endLine: (c.endLine || 1) + frontmatterLineCount,
|
|
476
|
+
meta,
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed YAML Frontmatter parser and metadata normalizer (issue #58).
|
|
3
|
+
* Parses YAML frontmatter into a validated document metadata object without
|
|
4
|
+
* unsafe executable tags. Supports scalars, inline/block lists, quotes, booleans,
|
|
5
|
+
* dates, tags normalization, and canonical document identity.
|
|
6
|
+
*/
|
|
7
|
+
export interface DocumentMetadata {
|
|
8
|
+
title?: string;
|
|
9
|
+
aliases: string[];
|
|
10
|
+
tags: string[];
|
|
11
|
+
project?: string;
|
|
12
|
+
type?: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
canonical?: boolean;
|
|
15
|
+
canonicalRef?: string;
|
|
16
|
+
created?: string;
|
|
17
|
+
updated?: string;
|
|
18
|
+
custom: Record<string, string | number | boolean | string[]>;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseFrontmatter(rawYaml?: string): DocumentMetadata;
|