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.
Files changed (52) hide show
  1. package/README.md +44 -1
  2. package/bin/cli.mjs +144 -30
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/core.d.ts +3 -2
  6. package/dist/core.js +26 -31
  7. package/dist/federation.d.ts +15 -0
  8. package/dist/federation.js +94 -0
  9. package/dist/filter.d.ts +29 -0
  10. package/dist/filter.js +317 -0
  11. package/dist/index-format.js +1 -1
  12. package/dist/index.d.ts +9 -1
  13. package/dist/index.js +8 -0
  14. package/dist/indexer.d.ts +13 -0
  15. package/dist/indexer.js +21 -7
  16. package/dist/markdown-parser.d.ts +1 -0
  17. package/dist/markdown-parser.js +71 -12
  18. package/dist/mcp.d.ts +54 -6
  19. package/dist/mcp.js +59 -11
  20. package/dist/quantization.d.ts +24 -0
  21. package/dist/quantization.js +85 -0
  22. package/dist/rag.d.ts +49 -0
  23. package/dist/rag.js +128 -0
  24. package/dist/search.d.ts +20 -6
  25. package/dist/search.js +175 -23
  26. package/dist/serve.js +44 -3
  27. package/dist/summarize.d.ts +16 -0
  28. package/dist/summarize.js +82 -0
  29. package/dist/tui.d.ts +1 -0
  30. package/dist/tui.js +36 -13
  31. package/dist/watcher.d.ts +11 -0
  32. package/dist/watcher.js +138 -0
  33. package/dist/wikilinks.d.ts +22 -0
  34. package/dist/wikilinks.js +100 -0
  35. package/package.json +3 -1
  36. package/src/binary-format.ts +216 -0
  37. package/src/core.ts +45 -37
  38. package/src/federation.ts +118 -0
  39. package/src/filter.ts +368 -0
  40. package/src/index-format.ts +1 -1
  41. package/src/index.ts +39 -1
  42. package/src/indexer.ts +42 -10
  43. package/src/markdown-parser.ts +80 -10
  44. package/src/mcp.ts +57 -11
  45. package/src/quantization.ts +93 -0
  46. package/src/rag.ts +189 -0
  47. package/src/search.ts +202 -25
  48. package/src/serve.ts +46 -4
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +157 -0
  52. 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(a: Float32Array | number[], b: Float32Array | number[], bOffset: number = 0): number {
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
- const isAllowed = roots.some((root) => {
452
- let canonicalRoot = root;
453
- try {
454
- canonicalRoot = fs.realpathSync(root);
455
- } catch {}
456
- const rel = path.relative(canonicalRoot, canonical);
457
- return !rel.startsWith('..') && !path.isAbsolute(rel);
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
- if (!isAllowed) {
461
- throw new Error(
462
- `path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`
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
+ }
@@ -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 { runTui } from './tui.js';
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
+