md-semantic-search 0.5.0 → 1.0.1

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 +121 -27
  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 +23 -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 +21 -1
  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 +130 -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 +42 -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 +22 -1
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +149 -0
  52. package/src/wikilinks.ts +132 -0
package/README.md CHANGED
@@ -419,13 +419,53 @@ const r = await fetch('http://localhost:8747/search', {
419
419
  const { results } = await r.json();
420
420
  ```
421
421
 
422
- The API is unauthenticated — by default it binds to **loopback only**
422
+ The API is unauthenticated by default (or secured via `--api-key`) — by default it binds to **loopback only**
423
423
  (`127.0.0.1`), so other machines on your LAN cannot reach it. Pass `--host
424
424
  0.0.0.0` (or `MDSS_HOST`) only if you intend to expose it on a network, and put
425
425
  a reverse proxy (or at least a firewall rule) in front of it. Request bodies
426
426
  are capped at 64 KB (oversized → `413`), and malformed JSON gets a clear `400`
427
427
  instead of being silently swallowed.
428
428
 
429
+ ### 4. MCP Server (Claude Desktop, Cursor, Copilot, Antigravity)
430
+
431
+ `md-semantic-search` provides a zero-dependency **Model Context Protocol (MCP)** server over standard I/O (JSON-RPC 2.0). This allows AI agents and IDEs to query your local notes privately by meaning without uploading text to external clouds.
432
+
433
+ ```bash
434
+ mdss mcp --db /path/to/your/markdown
435
+ ```
436
+
437
+ #### Available MCP Tools:
438
+ - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob and `tag` filters.
439
+ - **`get_chunk`**: Retrieve a specific section by file and heading.
440
+ - **`list_files`**: List indexed Markdown files with chunk counts.
441
+ - **`index_status`**: Inspect index health, chunk counts, and model metadata.
442
+
443
+ #### Claude Desktop Configuration:
444
+ Add to your `claude_desktop_config.json`:
445
+
446
+ ```json
447
+ {
448
+ "mcpServers": {
449
+ "md-semantic-search": {
450
+ "command": "npx",
451
+ "args": ["-y", "md-semantic-search", "mcp", "--db", "C:\\path\\to\\your\\notes"]
452
+ }
453
+ }
454
+ }
455
+ ```
456
+
457
+ #### Cursor Configuration (`.cursor/mcp.json`):
458
+ ```json
459
+ {
460
+ "mcpServers": {
461
+ "md-semantic-search": {
462
+ "command": "npx",
463
+ "args": ["-y", "md-semantic-search", "mcp", "--db", "./notes"]
464
+ }
465
+ }
466
+ }
467
+ ```
468
+
429
469
  ### Options
430
470
 
431
471
  | Flag | Meaning |
@@ -446,6 +486,8 @@ instead of being silently swallowed.
446
486
  | `--json` | Machine-readable output: `index` → build result JSON, `stats` → index stats JSON, `search` → hit list JSON (each hit includes `matches` — the query terms found in the chunk). |
447
487
  | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
448
488
  | `--rerank` | Re-rank the candidate pool with a cross-encoder (`Xenova/bge-reranker-base`, ~280 MB model, downloaded on first use). Slower, sharper results — see *Reranking* below. |
489
+ | `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
490
+ | `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
449
491
  | `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
450
492
  | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
451
493
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
@@ -607,6 +649,7 @@ is an in-memory dot-product sweep.
607
649
  ## Integrations
608
650
 
609
651
  - **Obsidian**: An official plugin is available in [`integrations/obsidian`](./integrations/obsidian) providing a live semantic search panel directly in your vault connected to `mdss serve`.
652
+ - **Model Context Protocol (MCP)**: Native stdio JSON-RPC 2.0 server (`mdss mcp`) integrating with Claude Desktop, Cursor, Copilot, Antigravity, and AI agents for local knowledge retrieval.
610
653
 
611
654
  ## License
612
655
 
package/bin/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import os from 'node:os';
17
17
  import crypto from 'node:crypto';
18
18
  import { buildIndex } from '../dist/indexer.js';
19
19
  import { search } from '../dist/search.js';
20
+ import { searchFederated } from '../dist/federation.js';
20
21
  import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
21
22
  import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
22
23
  import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
@@ -41,7 +42,7 @@ function defaultCacheDir() {
41
42
  }
42
43
 
43
44
  function parseArgs(argv) {
44
- const opts = { _: [], ignore: [], path: [] };
45
+ const opts = { _: [], ignore: [], path: [], vaults: [] };
45
46
  for (let i = 0; i < argv.length; i++) {
46
47
  const a = argv[i];
47
48
  if (a === '--json') opts.json = true;
@@ -60,6 +61,7 @@ function parseArgs(argv) {
60
61
  else if (a === '--no-vectors' || a === '--no-vector') opts.noVectors = true;
61
62
  else if (a === '--output' || a === '-o') opts.output = nextValue(argv, ++i, a);
62
63
  else if (a === '--db') opts.db = nextValue(argv, ++i, a);
64
+ else if (a === '--vault') opts.vaults.push(nextValue(argv, ++i, a));
63
65
  else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
64
66
  else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
65
67
  else if (a === '--model') opts.model = nextValue(argv, ++i, a);
@@ -86,6 +88,15 @@ function parseArgs(argv) {
86
88
  else if (a === '--type') opts.type = nextValue(argv, ++i, a);
87
89
  else if (a === '--status') opts.status = nextValue(argv, ++i, a);
88
90
  else if (a === '--canonical') opts.canonical = true;
91
+ else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
92
+ else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
93
+ else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
94
+ else if (a === '--rag') opts.rag = true;
95
+ else if (a === '--auto-tag') opts.autoTag = true;
96
+ else if (a === '--auto-summarize') opts.autoSummarize = true;
97
+ else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
98
+ else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
99
+ else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
89
100
  else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
90
101
  else opts._.push(a);
91
102
  }
@@ -107,13 +118,23 @@ function nextInt(argv, i, flag) {
107
118
  return k;
108
119
  }
109
120
 
121
+ /** Like nextValue but requires a non-negative float. */
122
+ function nextFloat(argv, i, flag) {
123
+ const v = nextValue(argv, i, flag);
124
+ const f = Number.parseFloat(v);
125
+ if (!Number.isFinite(f) || f < 0) die(`${flag} must be a non-negative number, got "${v}"`);
126
+ return f;
127
+ }
128
+
110
129
  const KNOWN_CONFIG_KEYS = new Set([
111
130
  'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
112
131
  'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
113
132
  'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
114
133
  'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
115
134
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
116
- 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe'
135
+ 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
136
+ 'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
137
+ 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
117
138
  ]);
118
139
 
119
140
  function findConfigFile(explicitPath) {
@@ -252,6 +273,8 @@ Options:
252
273
  --json Machine-readable output (index, stats, search).
253
274
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
254
275
  --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
276
+ --graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
277
+ --filter <expr> Rich boolean filter across frontmatter tags and properties (search).
255
278
  --ann Approximate nearest neighbor search via IVF (search).
256
279
  --nprobe <n> Number of centroids to probe for ANN (search, default: 8).
257
280
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
@@ -319,6 +342,7 @@ async function cmdIndex(opts) {
319
342
  offline: resolveOffline(opts),
320
343
  workers: opts.workers,
321
344
  ann: opts.ann,
345
+ quantize: opts.quantize,
322
346
  log: s => {
323
347
  if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
324
348
  process.stderr.write(s + '\n');
@@ -863,35 +887,103 @@ async function cmdTui(opts) {
863
887
  offline: resolveOffline(opts),
864
888
  path: opts.path.length > 0 ? opts.path : undefined,
865
889
  rerank: !!opts.rerank,
890
+ rag: !!opts.rag,
866
891
  });
867
892
  }
868
893
 
869
- async function cmdSearch(opts) {
870
- if (opts.interactive) return cmdTui(opts);
894
+ async function cmdAsk(opts) {
895
+ const query = opts._.join(' ').trim();
896
+ if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
897
+
871
898
  const db = resolveDb(opts);
872
899
  const indexDir = resolveIndexDir(opts, db);
873
900
  const cacheDir = resolveCache(opts);
901
+ const k = opts.k || 5;
902
+
903
+ const { askQuestion } = await import('../dist/rag.js');
904
+ const result = await askQuestion({
905
+ query,
906
+ indexDir,
907
+ cacheDir,
908
+ k,
909
+ llmEndpoint: opts.llmEndpoint,
910
+ llmModel: opts.llmModel,
911
+ systemPrompt: opts.systemPrompt,
912
+ });
913
+
914
+ if (opts.json) {
915
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
916
+ return;
917
+ }
918
+
919
+ process.stdout.write(`\n${result.answer}\n\n`);
920
+ if (result.citations && result.citations.length > 0) {
921
+ process.stdout.write('Sources:\n');
922
+ for (const c of result.citations) {
923
+ const heading = c.heading ? ` › ${c.heading}` : '';
924
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
925
+ }
926
+ process.stdout.write('\n');
927
+ }
928
+ }
929
+
930
+ async function cmdSearch(opts) {
931
+ if (opts.interactive) return cmdTui(opts);
932
+ const cacheDir = resolveCache(opts);
874
933
  const query = opts._.join(' ').trim();
875
934
  if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
876
935
 
877
- const results = await search({
878
- indexDir, cacheDir, query,
879
- k: opts.k || 6,
880
- semanticOnly: !!opts.semantic,
881
- offline: resolveOffline(opts),
882
- path: opts.path.length > 0 ? opts.path : undefined,
883
- since: opts.since,
884
- rerank: !!opts.rerank,
885
- tag: opts.tag,
886
- project: opts.project,
887
- type: opts.type,
888
- status: opts.status,
889
- canonicalOnly: opts.canonical,
890
- explain: !!opts.explain,
891
- maxPerFile: opts.maxPerFile || opts.maxPerDoc,
892
- ann: opts.ann,
893
- nprobe: opts.nprobe,
894
- });
936
+ let results;
937
+ const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
938
+
939
+ if (isFederated) {
940
+ results = await searchFederated({
941
+ vaults: opts.vaults,
942
+ cacheDir,
943
+ query,
944
+ k: opts.k || 6,
945
+ semanticOnly: !!opts.semantic,
946
+ offline: resolveOffline(opts),
947
+ path: opts.path.length > 0 ? opts.path : undefined,
948
+ since: opts.since,
949
+ rerank: !!opts.rerank,
950
+ tag: opts.tag,
951
+ project: opts.project,
952
+ type: opts.type,
953
+ status: opts.status,
954
+ canonicalOnly: opts.canonical,
955
+ explain: !!opts.explain,
956
+ maxPerFile: opts.maxPerFile || opts.maxPerDoc,
957
+ ann: opts.ann,
958
+ nprobe: opts.nprobe,
959
+ graphBoost: opts.graphBoost,
960
+ filter: opts.filter,
961
+ });
962
+ } else {
963
+ const db = resolveDb(opts);
964
+ const indexDir = resolveIndexDir(opts, db);
965
+
966
+ results = await search({
967
+ indexDir, cacheDir, query,
968
+ k: opts.k || 6,
969
+ semanticOnly: !!opts.semantic,
970
+ offline: resolveOffline(opts),
971
+ path: opts.path.length > 0 ? opts.path : undefined,
972
+ since: opts.since,
973
+ rerank: !!opts.rerank,
974
+ tag: opts.tag,
975
+ project: opts.project,
976
+ type: opts.type,
977
+ status: opts.status,
978
+ canonicalOnly: opts.canonical,
979
+ explain: !!opts.explain,
980
+ maxPerFile: opts.maxPerFile || opts.maxPerDoc,
981
+ ann: opts.ann,
982
+ nprobe: opts.nprobe,
983
+ graphBoost: opts.graphBoost,
984
+ filter: opts.filter,
985
+ });
986
+ }
895
987
 
896
988
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
897
989
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
@@ -901,13 +993,14 @@ async function cmdSearch(opts) {
901
993
  // Group by file (issue #13): each file becomes a header with its hits below.
902
994
  const byFile = new Map();
903
995
  for (const r of results) {
904
- if (!byFile.has(r.file)) byFile.set(r.file, []);
905
- byFile.get(r.file).push(r);
996
+ const fileKey = r.vault ? `[${r.vault}] ${r.file}` : r.file;
997
+ if (!byFile.has(fileKey)) byFile.set(fileKey, []);
998
+ byFile.get(fileKey).push(r);
906
999
  }
907
1000
  const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
908
1001
  let rank = 0;
909
- for (const [file, hits] of byFile) {
910
- process.stdout.write(`${file}\n`);
1002
+ for (const [fileHeader, hits] of byFile) {
1003
+ process.stdout.write(`${fileHeader}\n`);
911
1004
  for (const r of hits) {
912
1005
  rank++;
913
1006
  let snippet = r.snippet;
@@ -1034,6 +1127,7 @@ async function main() {
1034
1127
  case 'doctor': return cmdCheck(opts);
1035
1128
  case 'export': return cmdExport(opts);
1036
1129
  case 'search': return cmdSearch(opts);
1130
+ case 'ask': return cmdAsk(opts);
1037
1131
  case 'tui': return cmdTui(opts);
1038
1132
  case 'serve': return cmdServe(opts);
1039
1133
  case 'mcp': return cmdMcp(opts);
@@ -1052,7 +1146,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
1052
1146
  // functions are unit-tested directly; die() is asserted via a mocked
1053
1147
  // process.exit so a bad flag cannot kill the test runner.
1054
1148
  export {
1055
- parseArgs, nextInt, nextValue,
1149
+ parseArgs, nextInt, nextFloat, nextValue,
1056
1150
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1057
1151
  findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1058
1152
  checkHealth, cmdExport,
@@ -0,0 +1,25 @@
1
+ import { PersistedIndex } from './indexer.js';
2
+ export declare const BINARY_MAGIC = "MDSSBIN1";
3
+ export declare const BINARY_HEADER_SIZE = 64;
4
+ export declare const BINARY_VERSION = 1;
5
+ export declare const BINARY_FLAG_FP32 = 0;
6
+ export declare const BINARY_FLAG_INT8 = 1;
7
+ export interface BinaryIndexHeader {
8
+ version: number;
9
+ flags: number;
10
+ dim: number;
11
+ chunkCount: number;
12
+ vectorsOffset: number;
13
+ vectorsBytes: number;
14
+ metadataOffset: number;
15
+ metadataBytes: number;
16
+ }
17
+ export interface SerializeBinaryOptions {
18
+ quantize?: 'fp32' | 'int8';
19
+ }
20
+ export declare function serializeBinaryIndex(index: PersistedIndex, dim: number, options?: SerializeBinaryOptions): Buffer;
21
+ export declare function readBinaryHeader(buffer: Buffer): BinaryIndexHeader;
22
+ export declare function deserializeBinaryIndex(buffer: Buffer): PersistedIndex & {
23
+ rawVectorsBuffer: Float32Array | Int8Array;
24
+ quantized?: 'fp32' | 'int8';
25
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Zero-Copy Binary Index Storage (vectors.bin) format (issue #95).
3
+ *
4
+ * Binary Layout:
5
+ * ┌────────────────────────────────────────────────────────┐
6
+ * │ Header (64 bytes): │
7
+ * │ 0..7: Magic 'MDSSBIN1' │
8
+ * │ 8..11: Version (uint32 = 1) │
9
+ * │ 12..15: Flags (uint32: 0=FP32, 1=INT8) │
10
+ * │ 16..19: Dim (uint32) │
11
+ * │ 20..23: ChunkCount (uint32) │
12
+ * │ 24..31: VectorsOffset (uint64) │
13
+ * │ 32..39: VectorsBytes (uint64) │
14
+ * │ 40..47: MetadataOffset (uint64) │
15
+ * │ 48..55: MetadataBytes (uint64) │
16
+ * │ 56..63: Reserved (8 bytes zeros) │
17
+ * ├────────────────────────────────────────────────────────┤
18
+ * │ Vectors Section: │
19
+ * │ Contiguous float32 array: [chunkCount * dim * 4 bytes]│
20
+ * ├────────────────────────────────────────────────────────┤
21
+ * │ Metadata Section: │
22
+ * │ UTF-8 JSON string (chunks metadata + lexical state) │
23
+ * └────────────────────────────────────────────────────────┘
24
+ */
25
+ import { decodeVec } from './core.js';
26
+ import { quantizeToInt8 } from './quantization.js';
27
+ export const BINARY_MAGIC = 'MDSSBIN1';
28
+ export const BINARY_HEADER_SIZE = 64;
29
+ export const BINARY_VERSION = 1;
30
+ export const BINARY_FLAG_FP32 = 0;
31
+ export const BINARY_FLAG_INT8 = 1;
32
+ export function serializeBinaryIndex(index, dim, options = {}) {
33
+ const isInt8 = options.quantize === 'int8';
34
+ const bytesPerElement = isInt8 ? 1 : 4;
35
+ const count = index.chunks.length;
36
+ const vectorsBytes = count * dim * bytesPerElement;
37
+ const vectorsOffset = BINARY_HEADER_SIZE;
38
+ const flags = isInt8 ? BINARY_FLAG_INT8 : BINARY_FLAG_FP32;
39
+ // Build contiguous typed array
40
+ const rawArray = isInt8 ? new Int8Array(count * dim) : new Float32Array(count * dim);
41
+ for (let i = 0; i < count; i++) {
42
+ const chunk = index.chunks[i];
43
+ let v;
44
+ if (typeof chunk.vec === 'string') {
45
+ v = decodeVec(chunk.vec, dim);
46
+ }
47
+ else if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array || Array.isArray(chunk.vec)) {
48
+ v = chunk.vec;
49
+ }
50
+ else {
51
+ throw new Error(`chunk ${i} is missing vector for binary serialization`);
52
+ }
53
+ if (isInt8) {
54
+ const q = v instanceof Int8Array ? v : quantizeToInt8(v);
55
+ rawArray.set(q, i * dim);
56
+ }
57
+ else {
58
+ rawArray.set(v, i * dim);
59
+ }
60
+ }
61
+ // Build metadata JSON omitting per-chunk vec strings to save space
62
+ const metadataChunks = index.chunks.map((c) => ({
63
+ file: c.file,
64
+ title: c.title,
65
+ heading: c.heading,
66
+ headingPath: c.headingPath,
67
+ text: c.text,
68
+ chunkHash: c.chunkHash,
69
+ startLine: c.startLine,
70
+ endLine: c.endLine,
71
+ meta: c.meta,
72
+ }));
73
+ const metadataObj = {
74
+ schemaVersion: index.schemaVersion,
75
+ format: 'binary-v1',
76
+ model: index.model,
77
+ modelAlias: index.modelAlias,
78
+ adapterFingerprint: index.adapterFingerprint,
79
+ dim,
80
+ db: index.db,
81
+ built: index.built,
82
+ complete: index.complete,
83
+ chunkCount: count,
84
+ quantized: isInt8 ? 'int8' : 'fp32',
85
+ lexical: index.lexical,
86
+ lexicalFormat: index.lexicalFormat,
87
+ chunks: metadataChunks,
88
+ };
89
+ const metadataBuffer = Buffer.from(JSON.stringify(metadataObj), 'utf8');
90
+ const metadataOffset = vectorsOffset + vectorsBytes;
91
+ const metadataBytes = metadataBuffer.length;
92
+ const totalSize = metadataOffset + metadataBytes;
93
+ const out = Buffer.alloc(totalSize);
94
+ // Write 64-byte header
95
+ out.write(BINARY_MAGIC, 0, 8, 'ascii');
96
+ out.writeUInt32LE(BINARY_VERSION, 8);
97
+ out.writeUInt32LE(flags, 12);
98
+ out.writeUInt32LE(dim, 16);
99
+ out.writeUInt32LE(count, 20);
100
+ out.writeBigUInt64LE(BigInt(vectorsOffset), 24);
101
+ out.writeBigUInt64LE(BigInt(vectorsBytes), 32);
102
+ out.writeBigUInt64LE(BigInt(metadataOffset), 40);
103
+ out.writeBigUInt64LE(BigInt(metadataBytes), 48);
104
+ // Copy vectors
105
+ Buffer.from(rawArray.buffer, rawArray.byteOffset, rawArray.byteLength).copy(out, vectorsOffset);
106
+ // Copy metadata
107
+ metadataBuffer.copy(out, metadataOffset);
108
+ return out;
109
+ }
110
+ export function readBinaryHeader(buffer) {
111
+ if (buffer.length < BINARY_HEADER_SIZE) {
112
+ throw new Error(`Invalid binary index: file too small (${buffer.length} bytes, expected at least ${BINARY_HEADER_SIZE})`);
113
+ }
114
+ const magic = buffer.toString('ascii', 0, 8);
115
+ if (magic !== BINARY_MAGIC) {
116
+ throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
117
+ }
118
+ const version = buffer.readUInt32LE(8);
119
+ const flags = buffer.readUInt32LE(12);
120
+ const dim = buffer.readUInt32LE(16);
121
+ const chunkCount = buffer.readUInt32LE(20);
122
+ const vectorsOffset = Number(buffer.readBigUInt64LE(24));
123
+ const vectorsBytes = Number(buffer.readBigUInt64LE(32));
124
+ const metadataOffset = Number(buffer.readBigUInt64LE(40));
125
+ const metadataBytes = Number(buffer.readBigUInt64LE(48));
126
+ return {
127
+ version,
128
+ flags,
129
+ dim,
130
+ chunkCount,
131
+ vectorsOffset,
132
+ vectorsBytes,
133
+ metadataOffset,
134
+ metadataBytes,
135
+ };
136
+ }
137
+ export function deserializeBinaryIndex(buffer) {
138
+ const header = readBinaryHeader(buffer);
139
+ const { flags, dim, chunkCount, vectorsOffset, vectorsBytes, metadataOffset, metadataBytes } = header;
140
+ const isInt8 = (flags & BINARY_FLAG_INT8) !== 0;
141
+ const expectedVectorsBytes = chunkCount * dim * (isInt8 ? 1 : 4);
142
+ if (buffer.length < vectorsOffset + expectedVectorsBytes || buffer.length < metadataOffset + metadataBytes) {
143
+ throw new Error('Corrupt binary index: unexpected truncated file');
144
+ }
145
+ const byteOffset = buffer.byteOffset + vectorsOffset;
146
+ let rawVectorsBuffer;
147
+ if (isInt8) {
148
+ rawVectorsBuffer = new Int8Array(buffer.buffer, byteOffset, chunkCount * dim);
149
+ }
150
+ else {
151
+ // Aligned 4-byte Float32Array view
152
+ if (byteOffset % 4 === 0) {
153
+ rawVectorsBuffer = new Float32Array(buffer.buffer, byteOffset, chunkCount * dim);
154
+ }
155
+ else {
156
+ const aligned = Buffer.alloc(vectorsBytes);
157
+ buffer.copy(aligned, 0, vectorsOffset, vectorsOffset + vectorsBytes);
158
+ rawVectorsBuffer = new Float32Array(aligned.buffer, aligned.byteOffset, chunkCount * dim);
159
+ }
160
+ }
161
+ // Deserialize metadata JSON
162
+ const metaJson = buffer.toString('utf8', metadataOffset, metadataOffset + metadataBytes);
163
+ const parsed = JSON.parse(metaJson);
164
+ // Re-attach subarray views to chunks
165
+ const chunks = parsed.chunks.map((c, i) => {
166
+ const vecSlice = rawVectorsBuffer.subarray(i * dim, (i + 1) * dim);
167
+ return {
168
+ ...c,
169
+ vec: vecSlice,
170
+ };
171
+ });
172
+ return {
173
+ ...parsed,
174
+ chunks,
175
+ rawVectorsBuffer,
176
+ quantized: isInt8 ? 'int8' : 'fp32',
177
+ };
178
+ }
package/dist/core.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { resolveModel, ModelAdapter, Pooling, DType } from './models.js';
2
2
  import { PARSER_VERSION, StructuralChunk } from './markdown-parser.js';
3
3
  import { parseFrontmatter, DocumentMetadata } from './frontmatter.js';
4
- export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType };
4
+ import { asymmetricCosineInt8 } from './quantization.js';
5
+ export { PARSER_VERSION, parseFrontmatter, resolveModel, DocumentMetadata, ModelAdapter, Pooling, DType, asymmetricCosineInt8 };
5
6
  export declare const LOCK_FILENAME = ".mdss.lock";
6
7
  export declare function pidAlive(pid: number | null): boolean;
7
8
  export interface LockInfo {
@@ -47,7 +48,7 @@ export declare function embed(texts: string[], kind: 'query' | 'passage', model:
47
48
  delays?: number[];
48
49
  log?: (msg: string) => void;
49
50
  }): Promise<number[][]>;
50
- export declare function cosine(a: Float32Array | number[], b: Float32Array | number[], bOffset?: number): number;
51
+ export declare function cosine(a: Float32Array | number[], b: Float32Array | Int8Array | number[], bOffset?: number): number;
51
52
  export declare function encodeVec(vec: number[] | Float32Array): string;
52
53
  export declare function decodeVec(s: string, dim?: number): Float32Array;
53
54
  export declare function isBinaryIndex(index: {
package/dist/core.js CHANGED
@@ -5,11 +5,11 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
- import os from 'node:os';
9
8
  import { resolveModel } from './models.js';
10
9
  import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
11
10
  import { parseFrontmatter } from './frontmatter.js';
12
- export { PARSER_VERSION, parseFrontmatter, resolveModel };
11
+ import { asymmetricCosineInt8 } from './quantization.js';
12
+ export { PARSER_VERSION, parseFrontmatter, resolveModel, asymmetricCosineInt8 };
13
13
  const _extractors = new Map();
14
14
  export const LOCK_FILENAME = '.mdss.lock';
15
15
  const LOCK_STALE_MS = 10 * 60 * 1000;
@@ -248,6 +248,11 @@ export async function embed(texts, kind, model, cacheDir, offline = false, retry
248
248
  return out.tolist();
249
249
  }
250
250
  export function cosine(a, b, bOffset = 0) {
251
+ if (b instanceof Int8Array) {
252
+ const qF32 = a instanceof Float32Array ? a : Float32Array.from(a);
253
+ const chunkInt8 = bOffset === 0 && b.length === qF32.length ? b : b.subarray(bOffset, bOffset + qF32.length);
254
+ return asymmetricCosineInt8(qF32, chunkInt8);
255
+ }
251
256
  const len = a.length;
252
257
  let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
253
258
  let s4 = 0, s5 = 0, s6 = 0, s7 = 0;
@@ -364,6 +369,12 @@ export function assertSafePath(targetPath, allowedRoots) {
364
369
  if (typeof targetPath !== 'string' || !targetPath.trim()) {
365
370
  throw new Error('path must be a non-empty string');
366
371
  }
372
+ if (targetPath.includes('\0')) {
373
+ throw new Error('Forbidden path: null byte detected in path');
374
+ }
375
+ if (targetPath.startsWith('\\\\') || targetPath.startsWith('//')) {
376
+ throw new Error('Forbidden path: UNC network paths not allowed');
377
+ }
367
378
  const resolved = path.resolve(targetPath);
368
379
  let canonical = resolved;
369
380
  try {
@@ -377,40 +388,21 @@ export function assertSafePath(targetPath, allowedRoots) {
377
388
  if (process.env.MDSS_ROOT_GUARD) {
378
389
  roots = process.env.MDSS_ROOT_GUARD.split(path.delimiter).map((p) => path.resolve(p.trim())).filter(Boolean);
379
390
  }
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 { }
391
+ }
392
+ if (roots && roots.length > 0) {
393
+ const isAllowed = roots.some((root) => {
394
+ let canonicalRoot = root;
394
395
  try {
395
- const tmp = os.tmpdir();
396
- if (tmp)
397
- roots.push(path.resolve(tmp));
396
+ canonicalRoot = fs.realpathSync(root);
398
397
  }
399
398
  catch { }
399
+ const rel = path.relative(canonicalRoot, canonical);
400
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
401
+ });
402
+ if (!isAllowed) {
403
+ throw new Error(`path traversal guard: "${targetPath}" (resolving to "${canonical}") is outside allowed directory roots`);
400
404
  }
401
405
  }
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
406
  return canonical;
415
407
  }
416
408
  export function validateGlob(glob) {
@@ -0,0 +1,15 @@
1
+ import { SearchOptions, SearchResultHit } from './search.js';
2
+ export interface VaultConfig {
3
+ name?: string;
4
+ path: string;
5
+ indexDir?: string;
6
+ }
7
+ export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
8
+ vaults: Array<string | VaultConfig>;
9
+ cacheDir?: string;
10
+ }
11
+ export interface FederatedSearchResultHit extends SearchResultHit {
12
+ vault: string;
13
+ vaultPath: string;
14
+ }
15
+ export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]>;