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
package/README.md
CHANGED
|
@@ -255,6 +255,52 @@ error, and `serve --watch` politely defers its cycle until the other writer
|
|
|
255
255
|
finishes. A crashed writer can't wedge the index — the lock self-heals by
|
|
256
256
|
pid-liveness + staleness (a dead-pid or abandoned lock is reclaimed).
|
|
257
257
|
|
|
258
|
+
### Export the index (`mdss export`)
|
|
259
|
+
|
|
260
|
+
Export chunks and decoded embedding vectors to JSONL, CSV, or Parquet for downstream pipelines (LangChain, LlamaIndex, Qdrant, Pandas):
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
# Export all chunks with decoded float vectors as JSONL:
|
|
264
|
+
mdss export --db ./notes --format jsonl > chunks.jsonl
|
|
265
|
+
|
|
266
|
+
# Export metadata only without vectors (fast & compact):
|
|
267
|
+
mdss export --db ./notes --format jsonl --no-vectors > chunks_meta.jsonl
|
|
268
|
+
|
|
269
|
+
# Export as CSV (metadata only):
|
|
270
|
+
mdss export --db ./notes --format csv > chunks.csv
|
|
271
|
+
|
|
272
|
+
# Export directly to file:
|
|
273
|
+
mdss export --db ./notes --format jsonl --output ./data/corpus.jsonl
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### JSONL output schema:
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"file": "runbooks/failover.md",
|
|
280
|
+
"title": "Database Failover",
|
|
281
|
+
"heading": "Step 2: Promote Replica",
|
|
282
|
+
"headingPath": ["Database Failover", "Step 2: Promote Replica"],
|
|
283
|
+
"text": "Run the promote script...",
|
|
284
|
+
"vec": [0.012, -0.045, 0.089],
|
|
285
|
+
"chunkHash": "a1b2c3...",
|
|
286
|
+
"startLine": 14,
|
|
287
|
+
"endLine": 28
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
#### Downstream integration example (Python / LangChain / LlamaIndex):
|
|
292
|
+
```python
|
|
293
|
+
import json
|
|
294
|
+
|
|
295
|
+
# Load into custom vector store / pipeline
|
|
296
|
+
with open("chunks.jsonl") as f:
|
|
297
|
+
for line in f:
|
|
298
|
+
chunk = json.loads(line)
|
|
299
|
+
text = chunk["text"]
|
|
300
|
+
vector = chunk["vec"]
|
|
301
|
+
meta = {"file": chunk["file"], "heading": chunk["heading"]}
|
|
302
|
+
```
|
|
303
|
+
|
|
258
304
|
### Diagnose problems (`mdss check` / `mdss doctor`)
|
|
259
305
|
|
|
260
306
|
`mdss check` (alias: `mdss doctor`) is an **offline, read-only** diagnostic for
|
|
@@ -384,10 +430,15 @@ instead of being silently swallowed.
|
|
|
384
430
|
|
|
385
431
|
| Flag | Meaning |
|
|
386
432
|
|------|---------|
|
|
433
|
+
| `--config <file>` | Path to config file (default: `.mdssrc.json`, `mdss.config.json`, `.mdssrc`). |
|
|
387
434
|
| `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
|
|
388
435
|
| `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
|
|
389
436
|
| `--cache-dir <dir>` | Model cache dir. Default: `$XDG_CACHE_HOME/mdss` if set, else `~/.cache/mdss` on **all** platforms (so on Windows that's `C:\Users\<you>\.cache\mdss` — *not* `%LOCALAPPDATA%`; override with `MDSS_CACHE_DIR`). |
|
|
390
437
|
| `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
|
|
438
|
+
| `--workers <n>` | Number of parallel batch workers for embedding (default: 1). |
|
|
439
|
+
| `--format <fmt>` | Export format: `jsonl` (default), `csv`, `parquet` (`export`). |
|
|
440
|
+
| `--no-vectors` | Omit embeddings from export (`export`). |
|
|
441
|
+
| `--output <file>` | Output file path (`export`, default: stdout). |
|
|
391
442
|
| `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
|
|
392
443
|
| `--path <glob>` | Search only files matching glob; repeatable. e.g. `--path "docs/**"`. |
|
|
393
444
|
| `--since <date>` | Search only files modified at/after date (`YYYY-MM-DD` or ISO 8601). |
|
|
@@ -395,6 +446,8 @@ instead of being silently swallowed.
|
|
|
395
446
|
| `--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). |
|
|
396
447
|
| `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
|
|
397
448
|
| `--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. |
|
|
449
|
+
| `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
|
|
450
|
+
| `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
|
|
398
451
|
| `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
|
|
399
452
|
| `--host <ip>` | Bind address for `serve` (default `127.0.0.1` — loopback only; use `0.0.0.0` to expose on the LAN, or `MDSS_HOST`). |
|
|
400
453
|
| `--watch` | `serve`: re-index incrementally when files change. |
|
|
@@ -422,6 +475,30 @@ mdss index
|
|
|
422
475
|
mdss search "incident runbook for db failover"
|
|
423
476
|
```
|
|
424
477
|
|
|
478
|
+
### Configuration file (`.mdssrc` / `mdss.config.json`)
|
|
479
|
+
|
|
480
|
+
Instead of repeating CLI options on every command, create a `.mdssrc.json` or `mdss.config.json` file.
|
|
481
|
+
`mdss` discovers config files by searching:
|
|
482
|
+
1. An explicit path passed via `--config <path>` or the `MDSS_CONFIG` environment variable.
|
|
483
|
+
2. Current working directory up through ancestor directories (`.mdssrc.json`, `mdss.config.json`, `.mdssrc`).
|
|
484
|
+
3. User global config at `~/.mdssrc.json` or `~/.mdssrc`.
|
|
485
|
+
|
|
486
|
+
CLI flags always override configuration values.
|
|
487
|
+
|
|
488
|
+
Example `.mdssrc.json`:
|
|
489
|
+
|
|
490
|
+
```json
|
|
491
|
+
{
|
|
492
|
+
"db": "./notes",
|
|
493
|
+
"model": "e5-base",
|
|
494
|
+
"indexDir": "./notes/.mdss",
|
|
495
|
+
"ignore": ["archive/**", "drafts/**"],
|
|
496
|
+
"k": 6
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Run `mdss check` to validate your configuration file schema and detect unknown keys.
|
|
501
|
+
|
|
425
502
|
## Models
|
|
426
503
|
|
|
427
504
|
```bash
|
|
@@ -527,6 +604,10 @@ section instead of silently producing wrong vectors.
|
|
|
527
604
|
No external services, no database — the whole index is one JSON file and search
|
|
528
605
|
is an in-memory dot-product sweep.
|
|
529
606
|
|
|
607
|
+
## Integrations
|
|
608
|
+
|
|
609
|
+
- **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`.
|
|
610
|
+
|
|
530
611
|
## License
|
|
531
612
|
|
|
532
613
|
MIT © chelslava
|
package/bin/cli.mjs
CHANGED
|
@@ -14,12 +14,13 @@ import path from 'node:path';
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { buildIndex } from '../dist/indexer.js';
|
|
19
|
+
import { search } from '../dist/search.js';
|
|
20
|
+
import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
|
|
21
|
+
import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
|
|
22
|
+
import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
|
|
23
|
+
import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
|
|
23
24
|
|
|
24
25
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const PKG_ROOT = path.resolve(HERE, '..');
|
|
@@ -49,8 +50,15 @@ function parseArgs(argv) {
|
|
|
49
50
|
else if (a === '--watch') opts.watch = true;
|
|
50
51
|
else if (a === '--rerank') opts.rerank = true;
|
|
51
52
|
else if (a === '--explain') opts.explain = true;
|
|
53
|
+
else if (a === '--interactive' || a === '-i') opts.interactive = true;
|
|
54
|
+
else if (a === '--list-tools') opts.listTools = true;
|
|
52
55
|
else if (a === '--version') opts.version = true;
|
|
53
56
|
else if (a === '-h' || a === '--help') opts.help = true;
|
|
57
|
+
else if (a === '--workers') opts.workers = nextInt(argv, ++i, a);
|
|
58
|
+
else if (a === '--config') opts.config = nextValue(argv, ++i, a);
|
|
59
|
+
else if (a === '--format') opts.format = nextValue(argv, ++i, a);
|
|
60
|
+
else if (a === '--no-vectors' || a === '--no-vector') opts.noVectors = true;
|
|
61
|
+
else if (a === '--output' || a === '-o') opts.output = nextValue(argv, ++i, a);
|
|
54
62
|
else if (a === '--db') opts.db = nextValue(argv, ++i, a);
|
|
55
63
|
else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
|
|
56
64
|
else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
|
|
@@ -61,6 +69,8 @@ function parseArgs(argv) {
|
|
|
61
69
|
else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
|
|
62
70
|
else if (a === '--port') opts.port = nextInt(argv, ++i, a);
|
|
63
71
|
else if (a === '--host') opts.host = nextValue(argv, ++i, a);
|
|
72
|
+
else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
|
|
73
|
+
else if (a === '--health-public') opts.healthPublic = true;
|
|
64
74
|
else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
|
|
65
75
|
else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
|
|
66
76
|
else if (a === '--since') opts.since = nextValue(argv, ++i, a);
|
|
@@ -70,6 +80,8 @@ function parseArgs(argv) {
|
|
|
70
80
|
const v = nextValue(argv, ++i, a);
|
|
71
81
|
opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
|
|
72
82
|
}
|
|
83
|
+
else if (a === '--ann') opts.ann = true;
|
|
84
|
+
else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
|
|
73
85
|
else if (a === '--project') opts.project = nextValue(argv, ++i, a);
|
|
74
86
|
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
75
87
|
else if (a === '--status') opts.status = nextValue(argv, ++i, a);
|
|
@@ -95,6 +107,86 @@ function nextInt(argv, i, flag) {
|
|
|
95
107
|
return k;
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
const KNOWN_CONFIG_KEYS = new Set([
|
|
111
|
+
'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
|
|
112
|
+
'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
|
|
113
|
+
'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
|
|
114
|
+
'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
|
|
115
|
+
'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
|
|
116
|
+
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe'
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
function findConfigFile(explicitPath) {
|
|
120
|
+
if (explicitPath) {
|
|
121
|
+
const abs = path.resolve(explicitPath);
|
|
122
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file not found: ${abs}`);
|
|
123
|
+
return abs;
|
|
124
|
+
}
|
|
125
|
+
if (process.env.MDSS_CONFIG) {
|
|
126
|
+
const abs = path.resolve(process.env.MDSS_CONFIG);
|
|
127
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file from MDSS_CONFIG not found: ${abs}`);
|
|
128
|
+
return abs;
|
|
129
|
+
}
|
|
130
|
+
const configNames = ['.mdssrc.json', 'mdss.config.json', '.mdssrc'];
|
|
131
|
+
let curr = process.cwd();
|
|
132
|
+
while (true) {
|
|
133
|
+
for (const name of configNames) {
|
|
134
|
+
const candidate = path.join(curr, name);
|
|
135
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const parent = path.dirname(curr);
|
|
140
|
+
if (parent === curr) break;
|
|
141
|
+
curr = parent;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const home = os.homedir();
|
|
145
|
+
if (home) {
|
|
146
|
+
for (const name of ['.mdssrc.json', '.mdssrc']) {
|
|
147
|
+
const candidate = path.join(home, name);
|
|
148
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
149
|
+
return candidate;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function loadConfigFile(configPath) {
|
|
158
|
+
if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
|
|
159
|
+
try {
|
|
160
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
163
|
+
return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
|
|
164
|
+
}
|
|
165
|
+
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
|
|
166
|
+
const normalized = {};
|
|
167
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
168
|
+
const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
169
|
+
normalized[camel] = v;
|
|
170
|
+
}
|
|
171
|
+
return { config: normalized, path: configPath, unknownKeys, error: null };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function applyConfigDefaults(opts, config) {
|
|
178
|
+
for (const [k, v] of Object.entries(config)) {
|
|
179
|
+
if (k === 'ignore' || k === 'path') {
|
|
180
|
+
if ((!opts[k] || opts[k].length === 0) && v !== undefined) {
|
|
181
|
+
opts[k] = Array.isArray(v) ? [...v] : [v];
|
|
182
|
+
}
|
|
183
|
+
} else if (opts[k] === undefined && v !== undefined) {
|
|
184
|
+
opts[k] = v;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return opts;
|
|
188
|
+
}
|
|
189
|
+
|
|
98
190
|
function resolveDb(opts) {
|
|
99
191
|
const db = opts.db || process.env.MDSS_DB;
|
|
100
192
|
if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
|
|
@@ -102,13 +194,23 @@ function resolveDb(opts) {
|
|
|
102
194
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
103
195
|
die(`--db is not a directory: ${abs}`);
|
|
104
196
|
}
|
|
105
|
-
|
|
197
|
+
let safeDb;
|
|
198
|
+
try {
|
|
199
|
+
safeDb = assertSafePath(abs);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
die(e.message);
|
|
202
|
+
}
|
|
203
|
+
return safeDb;
|
|
106
204
|
}
|
|
107
205
|
|
|
108
206
|
function resolveIndexDir(opts, db) {
|
|
109
207
|
// Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
|
|
110
208
|
const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
|
|
111
|
-
|
|
209
|
+
try {
|
|
210
|
+
return assertSafePath(dir);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
die(e.message);
|
|
213
|
+
}
|
|
112
214
|
}
|
|
113
215
|
|
|
114
216
|
const resolveCache = (opts) =>
|
|
@@ -127,15 +229,22 @@ Usage:
|
|
|
127
229
|
mdss index --db <dir> [options] Build/refresh the index
|
|
128
230
|
mdss stats --db <dir> [--json] Index stats without loading the model
|
|
129
231
|
mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
|
|
232
|
+
mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
|
|
130
233
|
mdss search --db <dir> [options] "query" Search by meaning
|
|
131
234
|
mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
|
|
235
|
+
mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
|
|
132
236
|
mdss models List available models
|
|
133
237
|
|
|
134
238
|
Options:
|
|
239
|
+
--config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
|
|
135
240
|
--db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
|
|
136
241
|
--index-dir <dir> Where to store the index (default: <db>/.mdss).
|
|
137
242
|
--cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
|
|
138
243
|
--model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
|
|
244
|
+
--workers <n> Number of parallel batch workers for indexing (default: 1).
|
|
245
|
+
--format <fmt> Export format: jsonl (default), csv, parquet (export).
|
|
246
|
+
--no-vectors Omit vector embeddings from export (export).
|
|
247
|
+
--output <file> Output file path (export, default: stdout).
|
|
139
248
|
--ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
|
|
140
249
|
--path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
|
|
141
250
|
--since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
|
|
@@ -143,6 +252,8 @@ Options:
|
|
|
143
252
|
--json Machine-readable output (index, stats, search).
|
|
144
253
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
145
254
|
--rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
|
|
255
|
+
--ann Approximate nearest neighbor search via IVF (search).
|
|
256
|
+
--nprobe <n> Number of centroids to probe for ANN (search, default: 8).
|
|
146
257
|
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
147
258
|
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
148
259
|
only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
|
|
@@ -164,11 +275,41 @@ Examples:
|
|
|
164
275
|
curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
|
|
165
276
|
`;
|
|
166
277
|
|
|
278
|
+
function formatDuration(sec) {
|
|
279
|
+
const s = Math.max(0, Math.floor(sec));
|
|
280
|
+
const h = Math.floor(s / 3600);
|
|
281
|
+
const m = Math.floor((s % 3600) / 60);
|
|
282
|
+
const remainingS = s % 60;
|
|
283
|
+
return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
167
286
|
async function cmdIndex(opts) {
|
|
168
287
|
const db = resolveDb(opts);
|
|
169
288
|
const indexDir = resolveIndexDir(opts, db);
|
|
170
289
|
const cacheDir = resolveCache(opts);
|
|
171
290
|
const t0 = Date.now();
|
|
291
|
+
let lastProgressUpdate = 0;
|
|
292
|
+
|
|
293
|
+
const onProgress = (done, total, chunksPerSec) => {
|
|
294
|
+
if (!process.stderr.isTTY || opts.json || total <= 100) return;
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
if (now - lastProgressUpdate < 500 && done < total) return;
|
|
297
|
+
lastProgressUpdate = now;
|
|
298
|
+
const percent = Math.floor((done / total) * 100);
|
|
299
|
+
const barWidth = 10;
|
|
300
|
+
const filled = Math.min(barWidth, Math.max(0, Math.round((done / total) * barWidth)));
|
|
301
|
+
const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
|
|
302
|
+
const remaining = total - done;
|
|
303
|
+
const etaSec = chunksPerSec > 0 ? remaining / chunksPerSec : 0;
|
|
304
|
+
const etaStr = formatDuration(etaSec);
|
|
305
|
+
const speedStr = `${chunksPerSec.toFixed(1)} chunks/s`;
|
|
306
|
+
const line = `${bar} ${percent}% │ ${done}/${total} chunks │ ${speedStr} │ ETA ${etaStr}`;
|
|
307
|
+
process.stderr.write(`\r${line}`);
|
|
308
|
+
if (done >= total) {
|
|
309
|
+
process.stderr.write('\n');
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
172
313
|
let r;
|
|
173
314
|
try {
|
|
174
315
|
r = await buildIndex({
|
|
@@ -176,7 +317,13 @@ async function cmdIndex(opts) {
|
|
|
176
317
|
modelName: opts.model || DEFAULT_MODEL,
|
|
177
318
|
ignore: opts.ignore,
|
|
178
319
|
offline: resolveOffline(opts),
|
|
179
|
-
|
|
320
|
+
workers: opts.workers,
|
|
321
|
+
ann: opts.ann,
|
|
322
|
+
log: s => {
|
|
323
|
+
if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
|
|
324
|
+
process.stderr.write(s + '\n');
|
|
325
|
+
},
|
|
326
|
+
onProgress,
|
|
180
327
|
});
|
|
181
328
|
} catch (e) {
|
|
182
329
|
// A held index lock (issue #37) is an EXPECTED operational state (a second
|
|
@@ -234,7 +381,7 @@ function cmdStats(opts) {
|
|
|
234
381
|
// File count = keys of .hashes.json (per-file md5 map written by buildIndex).
|
|
235
382
|
// Fall back to unique chunk file paths when the hashes file is missing (a
|
|
236
383
|
// legacy/corrupt index) rather than dying.
|
|
237
|
-
let files
|
|
384
|
+
let files;
|
|
238
385
|
try {
|
|
239
386
|
const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
|
|
240
387
|
files = Object.keys(hashes).length;
|
|
@@ -288,6 +435,119 @@ function cmdStats(opts) {
|
|
|
288
435
|
);
|
|
289
436
|
}
|
|
290
437
|
|
|
438
|
+
/**
|
|
439
|
+
* `mdss export` — export the index as JSONL / CSV / Parquet for downstream pipelines (issue #77).
|
|
440
|
+
*/
|
|
441
|
+
async function cmdExport(opts) {
|
|
442
|
+
const db = resolveDb(opts);
|
|
443
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
444
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
445
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
446
|
+
die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let index;
|
|
450
|
+
try {
|
|
451
|
+
index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
|
|
452
|
+
} catch (e) {
|
|
453
|
+
die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const format = (opts.format || 'jsonl').toLowerCase();
|
|
457
|
+
const noVectors = !!opts.noVectors;
|
|
458
|
+
const outStream = opts.output ? fs.createWriteStream(path.resolve(opts.output), 'utf8') : process.stdout;
|
|
459
|
+
|
|
460
|
+
const chunks = Array.isArray(index.chunks) ? index.chunks : [];
|
|
461
|
+
const dim = index.dim;
|
|
462
|
+
|
|
463
|
+
if (format === 'jsonl') {
|
|
464
|
+
for (const c of chunks) {
|
|
465
|
+
let vec = undefined;
|
|
466
|
+
if (!noVectors && c.vec !== undefined) {
|
|
467
|
+
if (typeof c.vec === 'string') {
|
|
468
|
+
try {
|
|
469
|
+
vec = Array.from(decodeVec(c.vec, dim));
|
|
470
|
+
} catch {
|
|
471
|
+
vec = null;
|
|
472
|
+
}
|
|
473
|
+
} else if (Array.isArray(c.vec) || ArrayBuffer.isView(c.vec)) {
|
|
474
|
+
vec = Array.from(c.vec);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const record = {
|
|
478
|
+
file: c.file,
|
|
479
|
+
title: c.title,
|
|
480
|
+
heading: c.heading,
|
|
481
|
+
headingPath: c.headingPath,
|
|
482
|
+
text: c.text,
|
|
483
|
+
...(noVectors ? {} : { vec }),
|
|
484
|
+
...(c.chunkHash ? { chunkHash: c.chunkHash } : {}),
|
|
485
|
+
...(c.startLine !== undefined ? { startLine: c.startLine } : {}),
|
|
486
|
+
...(c.endLine !== undefined ? { endLine: c.endLine } : {}),
|
|
487
|
+
...(c.meta ? { meta: c.meta } : {}),
|
|
488
|
+
};
|
|
489
|
+
outStream.write(JSON.stringify(record) + '\n');
|
|
490
|
+
}
|
|
491
|
+
if (opts.output) {
|
|
492
|
+
await new Promise((resolve) => outStream.end(resolve));
|
|
493
|
+
}
|
|
494
|
+
} else if (format === 'csv') {
|
|
495
|
+
const escapeCsv = (val) => {
|
|
496
|
+
const str = String(val ?? '');
|
|
497
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
498
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
499
|
+
}
|
|
500
|
+
return str;
|
|
501
|
+
};
|
|
502
|
+
outStream.write('file,title,heading,headingPath,text\n');
|
|
503
|
+
for (const c of chunks) {
|
|
504
|
+
const pathStr = Array.isArray(c.headingPath) ? c.headingPath.join(' > ') : (c.heading || '');
|
|
505
|
+
const row = [
|
|
506
|
+
escapeCsv(c.file),
|
|
507
|
+
escapeCsv(c.title),
|
|
508
|
+
escapeCsv(c.heading),
|
|
509
|
+
escapeCsv(pathStr),
|
|
510
|
+
escapeCsv(c.text),
|
|
511
|
+
].join(',');
|
|
512
|
+
outStream.write(row + '\n');
|
|
513
|
+
}
|
|
514
|
+
if (opts.output) {
|
|
515
|
+
await new Promise((resolve) => outStream.end(resolve));
|
|
516
|
+
}
|
|
517
|
+
} else if (format === 'parquet') {
|
|
518
|
+
let parquet;
|
|
519
|
+
try {
|
|
520
|
+
// @ts-ignore
|
|
521
|
+
parquet = await import('parquetjs-lite');
|
|
522
|
+
} catch {
|
|
523
|
+
die('Parquet export requires optional dependency "parquetjs-lite". Install with: npm install parquetjs-lite');
|
|
524
|
+
}
|
|
525
|
+
if (!opts.output) {
|
|
526
|
+
die('Parquet export requires an output file via --output <file.parquet>');
|
|
527
|
+
}
|
|
528
|
+
const schemaFields = {
|
|
529
|
+
file: { type: 'UTF8' },
|
|
530
|
+
title: { type: 'UTF8' },
|
|
531
|
+
heading: { type: 'UTF8' },
|
|
532
|
+
text: { type: 'UTF8' },
|
|
533
|
+
};
|
|
534
|
+
const schema = new parquet.ParquetSchema(schemaFields);
|
|
535
|
+
const outFile = path.resolve(opts.output);
|
|
536
|
+
const writer = await parquet.ParquetWriter.openFile(schema, outFile);
|
|
537
|
+
for (const c of chunks) {
|
|
538
|
+
await writer.appendRow({
|
|
539
|
+
file: c.file || '',
|
|
540
|
+
title: c.title || '',
|
|
541
|
+
heading: c.heading || '',
|
|
542
|
+
text: c.text || '',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
await writer.close();
|
|
546
|
+
} else {
|
|
547
|
+
die(`unknown export format: "${format}". Supported formats: jsonl, csv, parquet.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
291
551
|
/**
|
|
292
552
|
* Offline diagnostics for the index/db/model-cache trio (issue #43) — the
|
|
293
553
|
* `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
|
|
@@ -295,7 +555,7 @@ function cmdStats(opts) {
|
|
|
295
555
|
* validator the loader uses), walks the db for staleness, and checks the
|
|
296
556
|
* transformers.js cache layout for the index's model. NEVER loads the
|
|
297
557
|
* embedding model and NEVER touches the network.
|
|
298
|
-
* @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean}} paths
|
|
558
|
+
* @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean, config?:any}} paths
|
|
299
559
|
* @returns {CheckReport}
|
|
300
560
|
*/
|
|
301
561
|
|
|
@@ -312,7 +572,7 @@ function cmdStats(opts) {
|
|
|
312
572
|
* @property {{exists:boolean, stale:boolean, error:(string|null)}} db
|
|
313
573
|
* @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
|
|
314
574
|
*/
|
|
315
|
-
|
|
575
|
+
function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config = null }) {
|
|
316
576
|
const report = {
|
|
317
577
|
healthy: true,
|
|
318
578
|
index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
|
|
@@ -320,7 +580,21 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
|
|
|
320
580
|
chunks: { total: 0, valid: 0, invalid: [] },
|
|
321
581
|
db: { exists: true, stale: false, error: null },
|
|
322
582
|
model: { id: null, cached: false, cachePath: null, error: null },
|
|
583
|
+
config: null,
|
|
323
584
|
};
|
|
585
|
+
|
|
586
|
+
// --- config file: exists, valid JSON, schema / unknown keys (issue #68) ---
|
|
587
|
+
if (config && config.path) {
|
|
588
|
+
report.config = {
|
|
589
|
+
path: config.path,
|
|
590
|
+
valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
|
|
591
|
+
unknownKeys: config.unknownKeys || [],
|
|
592
|
+
error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
|
|
593
|
+
};
|
|
594
|
+
if (!report.config.valid) {
|
|
595
|
+
report.healthy = false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
324
598
|
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
325
599
|
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
326
600
|
|
|
@@ -332,8 +606,22 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
|
|
|
332
606
|
}
|
|
333
607
|
report.index.exists = true;
|
|
334
608
|
let index;
|
|
609
|
+
const raw = fs.readFileSync(vectorsPath, 'utf8');
|
|
610
|
+
const shaPath = path.join(indexDir, 'vectors.json.sha256');
|
|
611
|
+
if (fs.existsSync(shaPath)) {
|
|
612
|
+
try {
|
|
613
|
+
const shaContent = fs.readFileSync(shaPath, 'utf8').trim();
|
|
614
|
+
const expectedHash = shaContent.split(/\s+/)[0];
|
|
615
|
+
const actualHash = crypto.createHash('sha256').update(raw).digest('hex');
|
|
616
|
+
if (expectedHash && actualHash !== expectedHash) {
|
|
617
|
+
report.index.error = `vectors.json integrity check failed (SHA-256 mismatch) — run \`mdss index\` to rebuild`;
|
|
618
|
+
report.healthy = false;
|
|
619
|
+
return report;
|
|
620
|
+
}
|
|
621
|
+
} catch { /* ignore unreadable sha file */ }
|
|
622
|
+
}
|
|
335
623
|
try {
|
|
336
|
-
index = JSON.parse(
|
|
624
|
+
index = JSON.parse(raw);
|
|
337
625
|
report.index.parses = true;
|
|
338
626
|
} catch (e) {
|
|
339
627
|
report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
|
|
@@ -469,7 +757,11 @@ function cmdCheck(opts) {
|
|
|
469
757
|
const db = resolveDb(opts);
|
|
470
758
|
const indexDir = resolveIndexDir(opts, db);
|
|
471
759
|
const cacheDir = resolveCache(opts);
|
|
472
|
-
const report = checkHealth({
|
|
760
|
+
const report = checkHealth({
|
|
761
|
+
db, indexDir, cacheDir,
|
|
762
|
+
requireOffline: resolveOffline(opts),
|
|
763
|
+
config: opts._config,
|
|
764
|
+
});
|
|
473
765
|
|
|
474
766
|
if (opts.json) {
|
|
475
767
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
@@ -543,6 +835,14 @@ function cmdCheck(opts) {
|
|
|
543
835
|
out += warn('model: index has no model field (legacy index)');
|
|
544
836
|
}
|
|
545
837
|
|
|
838
|
+
if (report.config) {
|
|
839
|
+
if (report.config.valid) {
|
|
840
|
+
out += ok(`config: ${report.config.path}`);
|
|
841
|
+
} else {
|
|
842
|
+
out += fail(`config: ${report.config.path} (${report.config.error})`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
546
846
|
out += report.healthy
|
|
547
847
|
? 'check: healthy (exit 0)\n'
|
|
548
848
|
: 'check: 1+ problem(s) found (exit 1)\n';
|
|
@@ -550,7 +850,24 @@ function cmdCheck(opts) {
|
|
|
550
850
|
process.exitCode = report.healthy ? 0 : 1;
|
|
551
851
|
}
|
|
552
852
|
|
|
853
|
+
async function cmdTui(opts) {
|
|
854
|
+
const db = resolveDb(opts);
|
|
855
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
856
|
+
const cacheDir = resolveCache(opts);
|
|
857
|
+
const query = opts._.join(' ').trim();
|
|
858
|
+
const { runTui } = await import('../dist/tui.js');
|
|
859
|
+
await runTui({
|
|
860
|
+
db, indexDir, cacheDir, query,
|
|
861
|
+
k: opts.k || 20,
|
|
862
|
+
semanticOnly: !!opts.semantic,
|
|
863
|
+
offline: resolveOffline(opts),
|
|
864
|
+
path: opts.path.length > 0 ? opts.path : undefined,
|
|
865
|
+
rerank: !!opts.rerank,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
553
869
|
async function cmdSearch(opts) {
|
|
870
|
+
if (opts.interactive) return cmdTui(opts);
|
|
554
871
|
const db = resolveDb(opts);
|
|
555
872
|
const indexDir = resolveIndexDir(opts, db);
|
|
556
873
|
const cacheDir = resolveCache(opts);
|
|
@@ -572,6 +889,8 @@ async function cmdSearch(opts) {
|
|
|
572
889
|
canonicalOnly: opts.canonical,
|
|
573
890
|
explain: !!opts.explain,
|
|
574
891
|
maxPerFile: opts.maxPerFile || opts.maxPerDoc,
|
|
892
|
+
ann: opts.ann,
|
|
893
|
+
nprobe: opts.nprobe,
|
|
575
894
|
});
|
|
576
895
|
|
|
577
896
|
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
@@ -648,6 +967,8 @@ async function cmdServe(opts) {
|
|
|
648
967
|
watch: !!opts.watch,
|
|
649
968
|
watchInterval: opts.watchInterval,
|
|
650
969
|
watchDelay: opts.watchDelay,
|
|
970
|
+
apiKey: opts.apiKey || process.env.MDSS_API_KEY,
|
|
971
|
+
healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
|
|
651
972
|
log,
|
|
652
973
|
});
|
|
653
974
|
|
|
@@ -671,9 +992,37 @@ async function cmdServe(opts) {
|
|
|
671
992
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
672
993
|
}
|
|
673
994
|
|
|
995
|
+
async function cmdMcp(opts) {
|
|
996
|
+
if (opts.listTools) {
|
|
997
|
+
const { MCP_TOOLS } = await import('../dist/mcp.js');
|
|
998
|
+
process.stdout.write(JSON.stringify(MCP_TOOLS, null, 2) + '\n');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const db = resolveDb(opts);
|
|
1002
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
1003
|
+
const cacheDir = resolveCache(opts);
|
|
1004
|
+
const log = s => process.stderr.write(s + '\n');
|
|
1005
|
+
const { startMcpServer } = await import('../dist/mcp.js');
|
|
1006
|
+
await startMcpServer({
|
|
1007
|
+
db, indexDir, cacheDir,
|
|
1008
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
1009
|
+
ignore: opts.ignore,
|
|
1010
|
+
offline: resolveOffline(opts),
|
|
1011
|
+
log,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
674
1015
|
async function main() {
|
|
675
1016
|
const argv = process.argv.slice(2);
|
|
676
1017
|
const opts = parseArgs(argv);
|
|
1018
|
+
const configPath = findConfigFile(opts.config);
|
|
1019
|
+
const loadedConfig = loadConfigFile(configPath);
|
|
1020
|
+
if (loadedConfig.error && opts.config) {
|
|
1021
|
+
die(loadedConfig.error);
|
|
1022
|
+
}
|
|
1023
|
+
opts._config = loadedConfig;
|
|
1024
|
+
applyConfigDefaults(opts, loadedConfig.config);
|
|
1025
|
+
|
|
677
1026
|
const cmd = opts._.shift();
|
|
678
1027
|
|
|
679
1028
|
if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
|
|
@@ -683,8 +1032,11 @@ async function main() {
|
|
|
683
1032
|
case 'stats': return cmdStats(opts);
|
|
684
1033
|
case 'check':
|
|
685
1034
|
case 'doctor': return cmdCheck(opts);
|
|
1035
|
+
case 'export': return cmdExport(opts);
|
|
686
1036
|
case 'search': return cmdSearch(opts);
|
|
1037
|
+
case 'tui': return cmdTui(opts);
|
|
687
1038
|
case 'serve': return cmdServe(opts);
|
|
1039
|
+
case 'mcp': return cmdMcp(opts);
|
|
688
1040
|
case 'models': return cmdModels();
|
|
689
1041
|
default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
|
|
690
1042
|
}
|
|
@@ -702,5 +1054,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
702
1054
|
export {
|
|
703
1055
|
parseArgs, nextInt, nextValue,
|
|
704
1056
|
resolveDb, resolveIndexDir, resolveCache, resolveOffline,
|
|
1057
|
+
findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
|
|
1058
|
+
checkHealth, cmdExport,
|
|
705
1059
|
die, main, HELP, VERSION,
|
|
706
1060
|
};
|