md-semantic-search 0.5.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/bin/cli.mjs +144 -30
- package/dist/binary-format.d.ts +25 -0
- package/dist/binary-format.js +178 -0
- package/dist/core.d.ts +3 -2
- package/dist/core.js +26 -31
- package/dist/federation.d.ts +15 -0
- package/dist/federation.js +94 -0
- package/dist/filter.d.ts +29 -0
- package/dist/filter.js +317 -0
- package/dist/index-format.js +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -0
- package/dist/indexer.d.ts +13 -0
- package/dist/indexer.js +21 -7
- package/dist/markdown-parser.d.ts +1 -0
- package/dist/markdown-parser.js +71 -12
- package/dist/mcp.d.ts +54 -6
- package/dist/mcp.js +59 -11
- package/dist/quantization.d.ts +24 -0
- package/dist/quantization.js +85 -0
- package/dist/rag.d.ts +49 -0
- package/dist/rag.js +128 -0
- package/dist/search.d.ts +20 -6
- package/dist/search.js +175 -23
- package/dist/serve.js +44 -3
- package/dist/summarize.d.ts +16 -0
- package/dist/summarize.js +82 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +36 -13
- package/dist/watcher.d.ts +11 -0
- package/dist/watcher.js +138 -0
- package/dist/wikilinks.d.ts +22 -0
- package/dist/wikilinks.js +100 -0
- package/package.json +3 -1
- package/src/binary-format.ts +216 -0
- package/src/core.ts +45 -37
- package/src/federation.ts +118 -0
- package/src/filter.ts +368 -0
- package/src/index-format.ts +1 -1
- package/src/index.ts +39 -1
- package/src/indexer.ts +42 -10
- package/src/markdown-parser.ts +80 -10
- package/src/mcp.ts +57 -11
- package/src/quantization.ts +93 -0
- package/src/rag.ts +189 -0
- package/src/search.ts +202 -25
- package/src/serve.ts +46 -4
- package/src/summarize.ts +98 -0
- package/src/tui.ts +36 -12
- package/src/watcher.ts +157 -0
- package/src/wikilinks.ts +132 -0
package/src/core.ts
CHANGED
|
@@ -9,8 +9,9 @@ import os from 'node:os';
|
|
|
9
9
|
import { resolveModel, ModelAdapter, Pooling, DType } from './models.js';
|
|
10
10
|
import { chunkMarkdownStructural, PARSER_VERSION, StructuralChunk } from './markdown-parser.js';
|
|
11
11
|
import { parseFrontmatter, DocumentMetadata } from './frontmatter.js';
|
|
12
|
+
import { asymmetricCosineInt8 } from './quantization.js';
|
|
12
13
|
|
|
13
|
-
export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType };
|
|
14
|
+
export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType, asymmetricCosineInt8 };
|
|
14
15
|
|
|
15
16
|
const _extractors = new Map<string, any>();
|
|
16
17
|
|
|
@@ -287,7 +288,17 @@ export async function embed(
|
|
|
287
288
|
return out.tolist();
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
export function cosine(
|
|
291
|
+
export function cosine(
|
|
292
|
+
a: Float32Array | number[],
|
|
293
|
+
b: Float32Array | Int8Array | number[],
|
|
294
|
+
bOffset: number = 0
|
|
295
|
+
): number {
|
|
296
|
+
if (b instanceof Int8Array) {
|
|
297
|
+
const qF32 = a instanceof Float32Array ? a : Float32Array.from(a);
|
|
298
|
+
const chunkInt8 = bOffset === 0 && b.length === qF32.length ? b : b.subarray(bOffset, bOffset + qF32.length);
|
|
299
|
+
return asymmetricCosineInt8(qF32, chunkInt8);
|
|
300
|
+
}
|
|
301
|
+
|
|
291
302
|
const len = a.length;
|
|
292
303
|
let s0 = 0,
|
|
293
304
|
s1 = 0,
|
|
@@ -300,19 +311,19 @@ export function cosine(a: Float32Array | number[], b: Float32Array | number[], b
|
|
|
300
311
|
let i = 0;
|
|
301
312
|
const limit = len - 7;
|
|
302
313
|
while (i < limit) {
|
|
303
|
-
s0 += a[i] * b[bOffset + i];
|
|
304
|
-
s1 += a[i + 1] * b[bOffset + i + 1];
|
|
305
|
-
s2 += a[i + 2] * b[bOffset + i + 2];
|
|
306
|
-
s3 += a[i + 3] * b[bOffset + i + 3];
|
|
307
|
-
s4 += a[i + 4] * b[bOffset + i + 4];
|
|
308
|
-
s5 += a[i + 5] * b[bOffset + i + 5];
|
|
309
|
-
s6 += a[i + 6] * b[bOffset + i + 6];
|
|
310
|
-
s7 += a[i + 7] * b[bOffset + i + 7];
|
|
314
|
+
s0 += a[i] * (b as ArrayLike<number>)[bOffset + i];
|
|
315
|
+
s1 += a[i + 1] * (b as ArrayLike<number>)[bOffset + i + 1];
|
|
316
|
+
s2 += a[i + 2] * (b as ArrayLike<number>)[bOffset + i + 2];
|
|
317
|
+
s3 += a[i + 3] * (b as ArrayLike<number>)[bOffset + i + 3];
|
|
318
|
+
s4 += a[i + 4] * (b as ArrayLike<number>)[bOffset + i + 4];
|
|
319
|
+
s5 += a[i + 5] * (b as ArrayLike<number>)[bOffset + i + 5];
|
|
320
|
+
s6 += a[i + 6] * (b as ArrayLike<number>)[bOffset + i + 6];
|
|
321
|
+
s7 += a[i + 7] * (b as ArrayLike<number>)[bOffset + i + 7];
|
|
311
322
|
i += 8;
|
|
312
323
|
}
|
|
313
324
|
let s = s0 + s1 + (s2 + s3) + (s4 + s5) + (s6 + s7);
|
|
314
325
|
while (i < len) {
|
|
315
|
-
s += a[i] * b[bOffset + i];
|
|
326
|
+
s += a[i] * (b as ArrayLike<number>)[bOffset + i];
|
|
316
327
|
i++;
|
|
317
328
|
}
|
|
318
329
|
return s;
|
|
@@ -419,6 +430,15 @@ export function assertSafePath(targetPath: string, allowedRoots?: string[]): str
|
|
|
419
430
|
if (typeof targetPath !== 'string' || !targetPath.trim()) {
|
|
420
431
|
throw new Error('path must be a non-empty string');
|
|
421
432
|
}
|
|
433
|
+
if (targetPath.includes('\0')) {
|
|
434
|
+
throw new Error('Forbidden path: null byte detected in path');
|
|
435
|
+
}
|
|
436
|
+
if (targetPath.startsWith('\\\\') || targetPath.startsWith('//')) {
|
|
437
|
+
throw new Error('Forbidden path: UNC network paths not allowed');
|
|
438
|
+
}
|
|
439
|
+
if (/^[a-zA-Z]:[\\/]/.test(targetPath) && process.platform !== 'win32') {
|
|
440
|
+
throw new Error('Forbidden path: Windows drive paths not allowed on non-Windows systems');
|
|
441
|
+
}
|
|
422
442
|
const resolved = path.resolve(targetPath);
|
|
423
443
|
let canonical = resolved;
|
|
424
444
|
try {
|
|
@@ -431,36 +451,24 @@ export function assertSafePath(targetPath: string, allowedRoots?: string[]): str
|
|
|
431
451
|
if (!roots || roots.length === 0) {
|
|
432
452
|
if (process.env.MDSS_ROOT_GUARD) {
|
|
433
453
|
roots = process.env.MDSS_ROOT_GUARD.split(path.delimiter).map((p) => path.resolve(p.trim())).filter(Boolean);
|
|
434
|
-
} else {
|
|
435
|
-
roots = [path.resolve(process.cwd())];
|
|
436
|
-
try {
|
|
437
|
-
const parent = path.resolve(process.cwd(), '..');
|
|
438
|
-
if (parent) roots.push(parent);
|
|
439
|
-
} catch {}
|
|
440
|
-
try {
|
|
441
|
-
const home = os.homedir();
|
|
442
|
-
if (home) roots.push(path.resolve(home));
|
|
443
|
-
} catch {}
|
|
444
|
-
try {
|
|
445
|
-
const tmp = os.tmpdir();
|
|
446
|
-
if (tmp) roots.push(path.resolve(tmp));
|
|
447
|
-
} catch {}
|
|
448
454
|
}
|
|
449
455
|
}
|
|
450
456
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
457
|
+
if (roots && roots.length > 0) {
|
|
458
|
+
const isAllowed = roots.some((root) => {
|
|
459
|
+
let canonicalRoot = root;
|
|
460
|
+
try {
|
|
461
|
+
canonicalRoot = fs.realpathSync(root);
|
|
462
|
+
} catch {}
|
|
463
|
+
const rel = path.relative(canonicalRoot, canonical);
|
|
464
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
465
|
+
});
|
|
459
466
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
467
|
+
if (!isAllowed) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
464
472
|
}
|
|
465
473
|
|
|
466
474
|
return canonical;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Vault & Multi-Repository Search Federation (issue #97).
|
|
3
|
+
*
|
|
4
|
+
* Coordinates querying across multiple independent vaults / repositories,
|
|
5
|
+
* executing per-vault index loads in parallel and merging candidates with
|
|
6
|
+
* calibrated reciprocal rank fusion (RRF) and vault attribution.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { loadIndex, searchIndex, LoadedIndex, SearchOptions, SearchResultHit } from './search.js';
|
|
11
|
+
|
|
12
|
+
export interface VaultConfig {
|
|
13
|
+
name?: string;
|
|
14
|
+
path: string;
|
|
15
|
+
indexDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
|
|
19
|
+
vaults: Array<string | VaultConfig>;
|
|
20
|
+
cacheDir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FederatedSearchResultHit extends SearchResultHit {
|
|
24
|
+
vault: string;
|
|
25
|
+
vaultPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]> {
|
|
29
|
+
const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
|
|
30
|
+
if (!vaults || vaults.length === 0) {
|
|
31
|
+
throw new Error('Federated search requires at least one vault specified');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalizedVaults: VaultConfig[] = vaults.map((v) => {
|
|
35
|
+
if (typeof v === 'string') {
|
|
36
|
+
const absPath = path.resolve(v);
|
|
37
|
+
const name = path.basename(absPath);
|
|
38
|
+
return { name, path: absPath };
|
|
39
|
+
}
|
|
40
|
+
const absPath = path.resolve(v.path);
|
|
41
|
+
const name = v.name || path.basename(absPath);
|
|
42
|
+
return { name, path: absPath, indexDir: v.indexDir };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Load and query each vault concurrently
|
|
46
|
+
const perVaultResults = await Promise.all(
|
|
47
|
+
normalizedVaults.map(async (vault) => {
|
|
48
|
+
const indexDir = vault.indexDir || path.join(vault.path, '.mdss');
|
|
49
|
+
if (!fs.existsSync(indexDir)) {
|
|
50
|
+
return { vault, hits: [] as SearchResultHit[], error: `No index at ${indexDir}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let loaded: LoadedIndex;
|
|
54
|
+
try {
|
|
55
|
+
loaded = loadIndex(indexDir);
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
return { vault, hits: [] as SearchResultHit[], error: err.message };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Query vault index with a slightly larger pool to ensure healthy global fusion
|
|
62
|
+
const poolK = Math.max(k * 2, 20);
|
|
63
|
+
const resolvedCache = cacheDir || path.join(vault.path, '.cache');
|
|
64
|
+
const hits = await searchIndex({
|
|
65
|
+
...searchOpts,
|
|
66
|
+
cacheDir: resolvedCache,
|
|
67
|
+
loaded,
|
|
68
|
+
k: poolK,
|
|
69
|
+
});
|
|
70
|
+
return { vault, hits, error: null };
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
return { vault, hits: [] as SearchResultHit[], error: err.message };
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// If single vault, return directly with attribution
|
|
78
|
+
if (normalizedVaults.length === 1) {
|
|
79
|
+
const { vault, hits } = perVaultResults[0];
|
|
80
|
+
return hits.slice(0, k).map((h) => ({
|
|
81
|
+
...h,
|
|
82
|
+
vault: vault.name || path.basename(vault.path),
|
|
83
|
+
vaultPath: vault.path,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Multi-vault reciprocal rank fusion (RRF)
|
|
88
|
+
// Collect all hits across vaults
|
|
89
|
+
const hitKeys = new Map<string, { hit: SearchResultHit; vault: VaultConfig; rrfSum: number }>();
|
|
90
|
+
|
|
91
|
+
for (const { vault, hits } of perVaultResults) {
|
|
92
|
+
hits.forEach((hit, rank) => {
|
|
93
|
+
const key = `${vault.path}::${hit.file}::${hit.heading || ''}`;
|
|
94
|
+
const rrfScore = 1.0 / (60 + rank + 1);
|
|
95
|
+
const existing = hitKeys.get(key);
|
|
96
|
+
if (!existing) {
|
|
97
|
+
hitKeys.set(key, { hit, vault, rrfSum: rrfScore });
|
|
98
|
+
} else {
|
|
99
|
+
existing.rrfSum += rrfScore;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Sort fused hits by combined rrfSum (or fallback to score)
|
|
105
|
+
const fused = Array.from(hitKeys.values())
|
|
106
|
+
.sort((a, b) => {
|
|
107
|
+
if (b.rrfSum !== a.rrfSum) return b.rrfSum - a.rrfSum;
|
|
108
|
+
return b.hit.score - a.hit.score;
|
|
109
|
+
})
|
|
110
|
+
.slice(0, k);
|
|
111
|
+
|
|
112
|
+
return fused.map(({ hit, vault, rrfSum }) => ({
|
|
113
|
+
...hit,
|
|
114
|
+
score: rrfSum,
|
|
115
|
+
vault: vault.name || path.basename(vault.path),
|
|
116
|
+
vaultPath: vault.path,
|
|
117
|
+
}));
|
|
118
|
+
}
|
package/src/filter.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich Frontmatter & Tag Filter Expression DSL (issue #94).
|
|
3
|
+
* Supports boolean expressions (AND, OR, NOT, grouping parentheses)
|
|
4
|
+
* across frontmatter tags, properties, dates, and custom fields.
|
|
5
|
+
*/
|
|
6
|
+
import { DocumentMetadata } from './frontmatter.js';
|
|
7
|
+
|
|
8
|
+
export type FilterNode =
|
|
9
|
+
| { type: 'AND'; left: FilterNode; right: FilterNode }
|
|
10
|
+
| { type: 'OR'; left: FilterNode; right: FilterNode }
|
|
11
|
+
| { type: 'NOT'; child: FilterNode }
|
|
12
|
+
| {
|
|
13
|
+
type: 'COMPARE';
|
|
14
|
+
field: string;
|
|
15
|
+
op: ':' | 'contains' | '=' | '==' | '!=' | '>' | '>=' | '<' | '<=';
|
|
16
|
+
value: string | number | boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function tokenizeFilter(expr: string): string[] {
|
|
20
|
+
const tokens: string[] = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < expr.length) {
|
|
23
|
+
const ch = expr[i];
|
|
24
|
+
|
|
25
|
+
if (/\s/.test(ch)) {
|
|
26
|
+
i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (ch === '(' || ch === ')') {
|
|
31
|
+
tokens.push(ch);
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Quoted string
|
|
37
|
+
if (ch === '"' || ch === "'") {
|
|
38
|
+
const quote = ch;
|
|
39
|
+
let str = '';
|
|
40
|
+
i++;
|
|
41
|
+
while (i < expr.length && expr[i] !== quote) {
|
|
42
|
+
if (expr[i] === '\\' && i + 1 < expr.length) {
|
|
43
|
+
str += expr[i + 1];
|
|
44
|
+
i += 2;
|
|
45
|
+
} else {
|
|
46
|
+
str += expr[i];
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
i++; // skip closing quote
|
|
51
|
+
tokens.push(`"${str}"`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Two-character operators: >=, <=, !=, ==
|
|
56
|
+
if (i + 1 < expr.length) {
|
|
57
|
+
const two = expr.slice(i, i + 2);
|
|
58
|
+
if (two === '>=' || two === '<=' || two === '!=' || two === '==') {
|
|
59
|
+
tokens.push(two);
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Single-character operators: >, <, =, :
|
|
66
|
+
if (ch === '>' || ch === '<' || ch === '=' || ch === ':') {
|
|
67
|
+
tokens.push(ch);
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Word / identifier / value token
|
|
73
|
+
let word = '';
|
|
74
|
+
while (
|
|
75
|
+
i < expr.length &&
|
|
76
|
+
!/\s/.test(expr[i]) &&
|
|
77
|
+
expr[i] !== '(' &&
|
|
78
|
+
expr[i] !== ')' &&
|
|
79
|
+
expr[i] !== ':' &&
|
|
80
|
+
expr[i] !== '=' &&
|
|
81
|
+
expr[i] !== '!' &&
|
|
82
|
+
expr[i] !== '>' &&
|
|
83
|
+
expr[i] !== '<' &&
|
|
84
|
+
expr[i] !== '"' &&
|
|
85
|
+
expr[i] !== "'"
|
|
86
|
+
) {
|
|
87
|
+
word += expr[i];
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
if (word) {
|
|
91
|
+
tokens.push(word);
|
|
92
|
+
} else if (i < expr.length) {
|
|
93
|
+
tokens.push(expr[i]);
|
|
94
|
+
i++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseFilter(expr: string): FilterNode {
|
|
101
|
+
const tokens = tokenizeFilter(expr);
|
|
102
|
+
if (tokens.length === 0) {
|
|
103
|
+
throw new Error('Empty filter expression');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let pos = 0;
|
|
107
|
+
|
|
108
|
+
function peek(): string | undefined {
|
|
109
|
+
return tokens[pos];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function consume(): string {
|
|
113
|
+
return tokens[pos++];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseExpression(): FilterNode {
|
|
117
|
+
return parseOr();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseOr(): FilterNode {
|
|
121
|
+
let node = parseAnd();
|
|
122
|
+
while (pos < tokens.length && peek()?.toUpperCase() === 'OR') {
|
|
123
|
+
consume(); // OR
|
|
124
|
+
const beforePos = pos;
|
|
125
|
+
const right = parseAnd();
|
|
126
|
+
if (pos === beforePos) {
|
|
127
|
+
throw new Error(`Invalid syntax in filter expression near token "${peek()}"`);
|
|
128
|
+
}
|
|
129
|
+
node = { type: 'OR', left: node, right };
|
|
130
|
+
}
|
|
131
|
+
return node;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseAnd(): FilterNode {
|
|
135
|
+
let node = parseNot();
|
|
136
|
+
while (
|
|
137
|
+
pos < tokens.length &&
|
|
138
|
+
peek()?.toUpperCase() !== 'OR' &&
|
|
139
|
+
peek() !== ')'
|
|
140
|
+
) {
|
|
141
|
+
if (peek()?.toUpperCase() === 'AND') {
|
|
142
|
+
consume(); // AND
|
|
143
|
+
}
|
|
144
|
+
const beforePos = pos;
|
|
145
|
+
const right = parseNot();
|
|
146
|
+
if (pos === beforePos) {
|
|
147
|
+
throw new Error(`Invalid syntax in filter expression near token "${peek()}"`);
|
|
148
|
+
}
|
|
149
|
+
node = { type: 'AND', left: node, right };
|
|
150
|
+
}
|
|
151
|
+
return node;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseNot(): FilterNode {
|
|
155
|
+
if (pos < tokens.length && (peek()?.toUpperCase() === 'NOT' || peek() === '!')) {
|
|
156
|
+
consume(); // NOT / !
|
|
157
|
+
const child = parsePrimary();
|
|
158
|
+
return { type: 'NOT', child };
|
|
159
|
+
}
|
|
160
|
+
return parsePrimary();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parsePrimary(): FilterNode {
|
|
164
|
+
const token = peek();
|
|
165
|
+
if (!token) {
|
|
166
|
+
throw new Error(`Unexpected end of filter expression at position ${pos}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (token === '(') {
|
|
170
|
+
consume(); // (
|
|
171
|
+
const node = parseExpression();
|
|
172
|
+
if (peek() !== ')') {
|
|
173
|
+
throw new Error('Expected closing parenthesis ")" in filter expression');
|
|
174
|
+
}
|
|
175
|
+
consume(); // )
|
|
176
|
+
return node;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Comparison term: <field> <op> <value> or <field>:<value>
|
|
180
|
+
const fieldToken = consume();
|
|
181
|
+
const cleanField = fieldToken.replace(/^["']|["']$/g, '');
|
|
182
|
+
|
|
183
|
+
let op: any = ':';
|
|
184
|
+
let rawVal = '';
|
|
185
|
+
|
|
186
|
+
const nextTok = peek();
|
|
187
|
+
if (
|
|
188
|
+
nextTok === ':' ||
|
|
189
|
+
nextTok === '=' ||
|
|
190
|
+
nextTok === '==' ||
|
|
191
|
+
nextTok === '!=' ||
|
|
192
|
+
nextTok === '>' ||
|
|
193
|
+
nextTok === '>=' ||
|
|
194
|
+
nextTok === '<' ||
|
|
195
|
+
nextTok === '<=' ||
|
|
196
|
+
nextTok?.toLowerCase() === 'contains'
|
|
197
|
+
) {
|
|
198
|
+
op = consume();
|
|
199
|
+
if (op === '==') op = '=';
|
|
200
|
+
rawVal = consume();
|
|
201
|
+
if (!rawVal) {
|
|
202
|
+
throw new Error(`Missing value after operator "${op}" for field "${cleanField}"`);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
op = ':';
|
|
206
|
+
rawVal = cleanField;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const unquoted = rawVal.replace(/^["']|["']$/g, '');
|
|
210
|
+
let parsedVal: string | number | boolean = unquoted;
|
|
211
|
+
if (/^true$/i.test(unquoted)) parsedVal = true;
|
|
212
|
+
else if (/^false$/i.test(unquoted)) parsedVal = false;
|
|
213
|
+
else if (/^-?\d+(\.\d+)?$/.test(unquoted)) parsedVal = Number(unquoted);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
type: 'COMPARE',
|
|
217
|
+
field: cleanField,
|
|
218
|
+
op,
|
|
219
|
+
value: parsedVal,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const result = parseExpression();
|
|
224
|
+
if (pos < tokens.length) {
|
|
225
|
+
throw new Error(`Unexpected trailing token in filter: "${tokens[pos]}"`);
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function evaluateFilter(
|
|
231
|
+
node: FilterNode | string,
|
|
232
|
+
meta?: DocumentMetadata,
|
|
233
|
+
doc?: { file?: string; title?: string }
|
|
234
|
+
): boolean {
|
|
235
|
+
if (typeof node === 'string') {
|
|
236
|
+
if (!node.trim()) return true;
|
|
237
|
+
try {
|
|
238
|
+
node = parseFilter(node);
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (node.type === 'AND') {
|
|
245
|
+
return evaluateFilter(node.left, meta, doc) && evaluateFilter(node.right, meta, doc);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (node.type === 'OR') {
|
|
249
|
+
return evaluateFilter(node.left, meta, doc) || evaluateFilter(node.right, meta, doc);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (node.type === 'NOT') {
|
|
253
|
+
return !evaluateFilter(node.child, meta, doc);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (node.type === 'COMPARE') {
|
|
257
|
+
return evaluateCompare(node.field, node.op, node.value, meta, doc);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function evaluateCompare(
|
|
264
|
+
field: string,
|
|
265
|
+
op: string,
|
|
266
|
+
targetVal: string | number | boolean,
|
|
267
|
+
meta?: DocumentMetadata,
|
|
268
|
+
doc?: { file?: string; title?: string }
|
|
269
|
+
): boolean {
|
|
270
|
+
const normField = field.toLowerCase().replace(/[-_]/g, '');
|
|
271
|
+
|
|
272
|
+
let actualVal: any = undefined;
|
|
273
|
+
|
|
274
|
+
if (normField === 'file' || normField === 'path') {
|
|
275
|
+
actualVal = doc?.file;
|
|
276
|
+
} else if (normField === 'title') {
|
|
277
|
+
actualVal = meta?.title || doc?.title;
|
|
278
|
+
} else if (normField === 'tag' || normField === 'tags') {
|
|
279
|
+
actualVal = meta?.tags || [];
|
|
280
|
+
} else if (normField === 'alias' || normField === 'aliases') {
|
|
281
|
+
actualVal = meta?.aliases || [];
|
|
282
|
+
} else if (normField === 'project') {
|
|
283
|
+
actualVal = meta?.project;
|
|
284
|
+
} else if (normField === 'type') {
|
|
285
|
+
actualVal = meta?.type;
|
|
286
|
+
} else if (normField === 'status') {
|
|
287
|
+
actualVal = meta?.status;
|
|
288
|
+
} else if (normField === 'canonical') {
|
|
289
|
+
actualVal = meta?.canonical;
|
|
290
|
+
} else if (normField === 'date' || normField === 'created') {
|
|
291
|
+
actualVal = meta?.created;
|
|
292
|
+
} else if (normField === 'updated') {
|
|
293
|
+
actualVal = meta?.updated;
|
|
294
|
+
} else if (meta?.custom && field in meta.custom) {
|
|
295
|
+
actualVal = meta.custom[field];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Tag array membership
|
|
299
|
+
if (normField === 'tag' || normField === 'tags' || Array.isArray(actualVal)) {
|
|
300
|
+
const list = Array.isArray(actualVal) ? actualVal : [];
|
|
301
|
+
const searchTarget = String(targetVal).toLowerCase().replace(/^#/, '');
|
|
302
|
+
|
|
303
|
+
if (op === ':' || op === 'contains' || op === '=' || op === '==') {
|
|
304
|
+
return list.some((item) => String(item).toLowerCase().replace(/^#/, '') === searchTarget);
|
|
305
|
+
}
|
|
306
|
+
if (op === '!=') {
|
|
307
|
+
return !list.some((item) => String(item).toLowerCase().replace(/^#/, '') === searchTarget);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (actualVal === undefined || actualVal === null) {
|
|
312
|
+
if (op === '!=') return true;
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Boolean compare
|
|
317
|
+
if (typeof targetVal === 'boolean') {
|
|
318
|
+
const b = Boolean(actualVal);
|
|
319
|
+
return op === '!=' ? b !== targetVal : b === targetVal;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Number compare
|
|
323
|
+
if (typeof targetVal === 'number') {
|
|
324
|
+
const num = Number(actualVal);
|
|
325
|
+
if (Number.isNaN(num)) return false;
|
|
326
|
+
switch (op) {
|
|
327
|
+
case '=':
|
|
328
|
+
case ':':
|
|
329
|
+
return num === targetVal;
|
|
330
|
+
case '!=':
|
|
331
|
+
return num !== targetVal;
|
|
332
|
+
case '>':
|
|
333
|
+
return num > targetVal;
|
|
334
|
+
case '>=':
|
|
335
|
+
return num >= targetVal;
|
|
336
|
+
case '<':
|
|
337
|
+
return num < targetVal;
|
|
338
|
+
case '<=':
|
|
339
|
+
return num <= targetVal;
|
|
340
|
+
default:
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// String / Date comparison
|
|
346
|
+
const strActual = String(actualVal).toLowerCase();
|
|
347
|
+
const strTarget = String(targetVal).toLowerCase();
|
|
348
|
+
|
|
349
|
+
switch (op) {
|
|
350
|
+
case '=':
|
|
351
|
+
return strActual === strTarget;
|
|
352
|
+
case ':':
|
|
353
|
+
case 'contains':
|
|
354
|
+
return strActual.includes(strTarget);
|
|
355
|
+
case '!=':
|
|
356
|
+
return strActual !== strTarget;
|
|
357
|
+
case '>':
|
|
358
|
+
return strActual > strTarget;
|
|
359
|
+
case '>=':
|
|
360
|
+
return strActual >= strTarget;
|
|
361
|
+
case '<':
|
|
362
|
+
return strActual < strTarget;
|
|
363
|
+
case '<=':
|
|
364
|
+
return strActual <= strTarget;
|
|
365
|
+
default:
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/index-format.ts
CHANGED
|
@@ -13,7 +13,7 @@ export function inspectIndexSchema(value: unknown, location: string): { index: R
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function validateNumericVector(value: unknown, expectedDim: number | undefined, where: string): number {
|
|
16
|
-
if (!Array.isArray(value) && !(value instanceof Float32Array)) {
|
|
16
|
+
if (!Array.isArray(value) && !(value instanceof Float32Array || value instanceof Int8Array)) {
|
|
17
17
|
throw new Error(`${where}: missing vector${REBUILD}`);
|
|
18
18
|
}
|
|
19
19
|
const vec = value as ArrayLike<number>;
|
package/src/index.ts
CHANGED
|
@@ -52,4 +52,42 @@ export {
|
|
|
52
52
|
ANN_THRESHOLD, DEFAULT_NPROBE,
|
|
53
53
|
} from './ivf.js';
|
|
54
54
|
|
|
55
|
-
export {
|
|
55
|
+
export {
|
|
56
|
+
extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes,
|
|
57
|
+
computePageRank, expandGraphNeighborhood,
|
|
58
|
+
} from './wikilinks.js';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
parseFilter, tokenizeFilter, evaluateFilter, FilterNode,
|
|
62
|
+
} from './filter.js';
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader,
|
|
66
|
+
BINARY_MAGIC, BINARY_HEADER_SIZE,
|
|
67
|
+
} from './binary-format.js';
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8,
|
|
71
|
+
INT8_SCALE, INT8_INV_SCALE,
|
|
72
|
+
} from './quantization.js';
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
searchFederated, FederatedSearchOptions, FederatedSearchResultHit, VaultConfig,
|
|
76
|
+
} from './federation.js';
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
createFileWatcher, FileWatcher, WatcherOptions,
|
|
80
|
+
} from './watcher.js';
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
askQuestion, synthesizeAnswer, extractAnswerFallback,
|
|
84
|
+
AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT,
|
|
85
|
+
} from './rag.js';
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
extractKeyphraseTags, summarizePassage, analyzeNote, NoteAnalysis,
|
|
89
|
+
} from './summarize.js';
|
|
90
|
+
|
|
91
|
+
export { runTui, TuiOptions } from './tui.js';
|
|
92
|
+
|
|
93
|
+
|