md-semantic-search 0.3.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 +438 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +931 -36
- 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 +25 -2
- package/src/collapse.ts +26 -0
- package/src/core.ts +546 -0
- package/src/frontmatter.ts +190 -0
- package/src/index-format.ts +151 -0
- package/src/index.ts +55 -0
- package/src/indexer.ts +585 -0
- package/src/ivf.ts +190 -0
- package/src/lexical.ts +257 -0
- package/src/markdown-parser.ts +303 -0
- 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.ts +244 -0
- package/src/core.mjs +0 -171
- package/src/indexer.mjs +0 -197
- package/src/models.mjs +0 -67
- package/src/search.mjs +0 -107
package/bin/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
2
3
|
/**
|
|
3
4
|
* md-semantic-search (mdss) — local semantic search over any folder of .md files.
|
|
4
5
|
*
|
|
@@ -10,35 +11,182 @@
|
|
|
10
11
|
* inside this project. The index defaults to <db>/.mdss unless --index-dir is given.
|
|
11
12
|
*/
|
|
12
13
|
import path from 'node:path';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
15
|
import fs from 'node:fs';
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
16
|
+
import os from 'node:os';
|
|
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';
|
|
18
24
|
|
|
19
25
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
26
|
const PKG_ROOT = path.resolve(HERE, '..');
|
|
21
|
-
const
|
|
27
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* User-level model cache. Default: ~/.cache/mdss (respecting XDG_CACHE_HOME on
|
|
31
|
+
* POSIX); falls back to <pkg>/.cache when homedir is unavailable. Explicit
|
|
32
|
+
* --cache-dir / MDSS_CACHE_DIR always win (issue #9).
|
|
33
|
+
*/
|
|
34
|
+
function defaultCacheDir() {
|
|
35
|
+
try {
|
|
36
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, 'mdss');
|
|
37
|
+
const home = os.homedir();
|
|
38
|
+
if (home) return path.join(home, '.cache', 'mdss');
|
|
39
|
+
} catch { /* homedir unavailable */ }
|
|
40
|
+
return path.join(PKG_ROOT, '.cache');
|
|
41
|
+
}
|
|
22
42
|
|
|
23
43
|
function parseArgs(argv) {
|
|
24
|
-
const opts = { _: [], ignore: [] };
|
|
44
|
+
const opts = { _: [], ignore: [], path: [] };
|
|
25
45
|
for (let i = 0; i < argv.length; i++) {
|
|
26
46
|
const a = argv[i];
|
|
27
|
-
if (a === '--
|
|
28
|
-
else if (a === '--index-dir') opts.indexDir = argv[++i];
|
|
29
|
-
else if (a === '--cache-dir') opts.cacheDir = argv[++i];
|
|
30
|
-
else if (a === '--model') opts.model = argv[++i];
|
|
31
|
-
else if (a === '--k') opts.k = parseInt(argv[++i], 10);
|
|
32
|
-
else if (a === '--ignore') opts.ignore.push(argv[++i]);
|
|
33
|
-
else if (a === '--json') opts.json = true;
|
|
47
|
+
if (a === '--json') opts.json = true;
|
|
34
48
|
else if (a === '--semantic') opts.semantic = true;
|
|
35
49
|
else if (a === '--offline') opts.offline = true;
|
|
50
|
+
else if (a === '--watch') opts.watch = true;
|
|
51
|
+
else if (a === '--rerank') opts.rerank = true;
|
|
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;
|
|
55
|
+
else if (a === '--version') opts.version = true;
|
|
36
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);
|
|
62
|
+
else if (a === '--db') opts.db = nextValue(argv, ++i, a);
|
|
63
|
+
else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
|
|
64
|
+
else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
|
|
65
|
+
else if (a === '--model') opts.model = nextValue(argv, ++i, a);
|
|
66
|
+
else if (a === '--k') opts.k = nextInt(argv, ++i, a);
|
|
67
|
+
else if (a === '--max-per-file') opts.maxPerFile = nextInt(argv, ++i, a);
|
|
68
|
+
else if (a === '--max-per-doc') opts.maxPerDoc = nextInt(argv, ++i, a);
|
|
69
|
+
else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
|
|
70
|
+
else if (a === '--port') opts.port = nextInt(argv, ++i, a);
|
|
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;
|
|
74
|
+
else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
|
|
75
|
+
else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
|
|
76
|
+
else if (a === '--since') opts.since = nextValue(argv, ++i, a);
|
|
77
|
+
else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
|
|
78
|
+
else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
|
|
79
|
+
else if (a === '--tag') {
|
|
80
|
+
const v = nextValue(argv, ++i, a);
|
|
81
|
+
opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
|
|
82
|
+
}
|
|
83
|
+
else if (a === '--ann') opts.ann = true;
|
|
84
|
+
else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
|
|
85
|
+
else if (a === '--project') opts.project = nextValue(argv, ++i, a);
|
|
86
|
+
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
87
|
+
else if (a === '--status') opts.status = nextValue(argv, ++i, a);
|
|
88
|
+
else if (a === '--canonical') opts.canonical = true;
|
|
89
|
+
else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
|
|
37
90
|
else opts._.push(a);
|
|
38
91
|
}
|
|
39
92
|
return opts;
|
|
40
93
|
}
|
|
41
94
|
|
|
95
|
+
/** Value of the flag at `i`; errors when missing or followed by another flag. */
|
|
96
|
+
function nextValue(argv, i, flag) {
|
|
97
|
+
const v = argv[i];
|
|
98
|
+
if (v === undefined || v.startsWith('-')) die(`missing value for ${flag}`);
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Like nextValue but requires a positive integer (issue #8: --k abc → clear error). */
|
|
103
|
+
function nextInt(argv, i, flag) {
|
|
104
|
+
const v = nextValue(argv, i, flag);
|
|
105
|
+
const k = Number.parseInt(v, 10);
|
|
106
|
+
if (!Number.isInteger(k) || k <= 0) die(`${flag} must be a positive integer, got "${v}"`);
|
|
107
|
+
return k;
|
|
108
|
+
}
|
|
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
|
+
|
|
42
190
|
function resolveDb(opts) {
|
|
43
191
|
const db = opts.db || process.env.MDSS_DB;
|
|
44
192
|
if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
|
|
@@ -46,17 +194,27 @@ function resolveDb(opts) {
|
|
|
46
194
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
47
195
|
die(`--db is not a directory: ${abs}`);
|
|
48
196
|
}
|
|
49
|
-
|
|
197
|
+
let safeDb;
|
|
198
|
+
try {
|
|
199
|
+
safeDb = assertSafePath(abs);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
die(e.message);
|
|
202
|
+
}
|
|
203
|
+
return safeDb;
|
|
50
204
|
}
|
|
51
205
|
|
|
52
206
|
function resolveIndexDir(opts, db) {
|
|
53
207
|
// Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
|
|
54
208
|
const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
|
|
55
|
-
|
|
209
|
+
try {
|
|
210
|
+
return assertSafePath(dir);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
die(e.message);
|
|
213
|
+
}
|
|
56
214
|
}
|
|
57
215
|
|
|
58
216
|
const resolveCache = (opts) =>
|
|
59
|
-
path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR ||
|
|
217
|
+
path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || defaultCacheDir());
|
|
60
218
|
|
|
61
219
|
/** --offline flag or MDSS_OFFLINE=1 env → never touch the network. */
|
|
62
220
|
const resolveOffline = (opts) => !!opts.offline || !!process.env.MDSS_OFFLINE;
|
|
@@ -65,24 +223,46 @@ function die(msg) {
|
|
|
65
223
|
process.stderr.write(`error: ${msg}\n`);
|
|
66
224
|
process.exit(1);
|
|
67
225
|
}
|
|
68
|
-
|
|
69
226
|
const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
|
|
70
227
|
|
|
71
228
|
Usage:
|
|
72
229
|
mdss index --db <dir> [options] Build/refresh the index
|
|
230
|
+
mdss stats --db <dir> [--json] Index stats without loading the model
|
|
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
|
|
73
233
|
mdss search --db <dir> [options] "query" Search by meaning
|
|
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
|
|
74
236
|
mdss models List available models
|
|
75
237
|
|
|
76
238
|
Options:
|
|
239
|
+
--config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
|
|
77
240
|
--db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
|
|
78
241
|
--index-dir <dir> Where to store the index (default: <db>/.mdss).
|
|
79
|
-
--cache-dir <dir> Model cache dir (default:
|
|
242
|
+
--cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
|
|
80
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).
|
|
81
248
|
--ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
|
|
249
|
+
--path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
|
|
250
|
+
--since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
|
|
82
251
|
--k <n> Number of results (search, default 6).
|
|
83
|
-
--json Machine-readable output (search).
|
|
252
|
+
--json Machine-readable output (index, stats, search).
|
|
84
253
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
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).
|
|
257
|
+
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
258
|
+
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
259
|
+
only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
|
|
260
|
+
--watch serve: re-index incrementally on file changes (mtime poll).
|
|
261
|
+
--watch-interval <ms> serve --watch: poll every N ms (default 3000).
|
|
262
|
+
--watch-delay <ms> serve --watch: quiet-period debounce before a burst of
|
|
263
|
+
saves triggers ONE re-index (default 1000; issue #42).
|
|
85
264
|
--offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
|
|
265
|
+
--version Print the version and exit.
|
|
86
266
|
-h, --help Show this help.
|
|
87
267
|
|
|
88
268
|
Examples:
|
|
@@ -90,30 +270,604 @@ Examples:
|
|
|
90
270
|
mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
|
|
91
271
|
mdss search --db ./docs "how do I rotate the api token"
|
|
92
272
|
MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
|
|
273
|
+
mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
|
|
274
|
+
mdss serve --db ./docs --port 8747 --watch
|
|
275
|
+
curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
|
|
93
276
|
`;
|
|
94
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
|
+
|
|
95
286
|
async function cmdIndex(opts) {
|
|
96
287
|
const db = resolveDb(opts);
|
|
97
288
|
const indexDir = resolveIndexDir(opts, db);
|
|
98
289
|
const cacheDir = resolveCache(opts);
|
|
99
290
|
const t0 = Date.now();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
|
|
313
|
+
let r;
|
|
314
|
+
try {
|
|
315
|
+
r = await buildIndex({
|
|
316
|
+
db, indexDir, cacheDir,
|
|
317
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
318
|
+
ignore: opts.ignore,
|
|
319
|
+
offline: resolveOffline(opts),
|
|
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,
|
|
327
|
+
});
|
|
328
|
+
} catch (e) {
|
|
329
|
+
// A held index lock (issue #37) is an EXPECTED operational state (a second
|
|
330
|
+
// terminal also indexing, or a `serve --watch` daemon mid-cycle) — surface
|
|
331
|
+
// it as a clean one-line error + hint, not a stack of "locked by pid".
|
|
332
|
+
if (/(being written by pid|index is being written)/i.test(String(e?.message || ''))) {
|
|
333
|
+
die(`${e.message}\nAnother mdss process is writing to this index. ` +
|
|
334
|
+
'Retry once it finishes, or remove a stale .mdss.lock if the holder crashed.');
|
|
335
|
+
}
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
// Machine-readable build result for scripts/CI (issue #21): the exact
|
|
339
|
+
// buildIndex return value — lets automation assert "0 embedded" (fully
|
|
340
|
+
// incremental) or "N skipped" without parsing prose.
|
|
341
|
+
if (opts.json) {
|
|
342
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
107
345
|
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
346
|
+
const skipped = r.skipped > 0 ? `, ${r.skipped} skipped` : '';
|
|
108
347
|
process.stderr.write(
|
|
109
348
|
`\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
|
|
110
349
|
`(${r.reused} reused [${r.reusedChunks} chunk-level, ${r.reusedFiles} file-level], ` +
|
|
111
|
-
`${r.embedded} embedded), dim=${r.dim}, ` +
|
|
350
|
+
`${r.embedded} embedded)${skipped}, dim=${r.dim}, ` +
|
|
112
351
|
`model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
|
|
113
352
|
);
|
|
114
353
|
}
|
|
115
354
|
|
|
355
|
+
/**
|
|
356
|
+
* `mdss stats` — machine-readable index statistics WITHOUT loading the
|
|
357
|
+
* embedding model: parses only vectors.json + .hashes.json (issue #21).
|
|
358
|
+
* Gives scripts/CI a cheap sanity check after a re-index (counts, model, dim),
|
|
359
|
+
* staleness detection (ageSeconds), and index-path/format info for migrations.
|
|
360
|
+
*/
|
|
361
|
+
function cmdStats(opts) {
|
|
362
|
+
const db = resolveDb(opts);
|
|
363
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
364
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
365
|
+
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
366
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
367
|
+
die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
368
|
+
}
|
|
369
|
+
let index;
|
|
370
|
+
try {
|
|
371
|
+
index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
|
|
372
|
+
} catch (e) {
|
|
373
|
+
die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
validateIndexEnvelope(index, vectorsPath, { encoding: 'stored' });
|
|
377
|
+
} catch (error) {
|
|
378
|
+
die(error.message);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// File count = keys of .hashes.json (per-file md5 map written by buildIndex).
|
|
382
|
+
// Fall back to unique chunk file paths when the hashes file is missing (a
|
|
383
|
+
// legacy/corrupt index) rather than dying.
|
|
384
|
+
let files;
|
|
385
|
+
try {
|
|
386
|
+
const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
|
|
387
|
+
files = Object.keys(hashes).length;
|
|
388
|
+
} catch {
|
|
389
|
+
files = new Set((index.chunks || []).map(c => c.file)).size;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const chunks = index.chunkCount ?? (index.chunks ? index.chunks.length : 0);
|
|
393
|
+
const builtMs = index.built ? Date.parse(index.built) : NaN;
|
|
394
|
+
const adapterRepr = (index.model || index.modelAlias)
|
|
395
|
+
? resolveModel(index.model || index.modelAlias) : null;
|
|
396
|
+
const stats = {
|
|
397
|
+
indexDir,
|
|
398
|
+
schemaVersion: index.schemaVersion ?? 0,
|
|
399
|
+
format: index.format || 'legacy', // binary-v1 vs pre-0.4 decimal
|
|
400
|
+
lexicalFormat: index.schemaVersion >= 3 ? (index.lexical?.format || 'invalid') : null,
|
|
401
|
+
lexicalStatus: index.schemaVersion >= 3 ? 'persisted-bm25' : 'legacy-overlap',
|
|
402
|
+
model: index.model || null, // id@revision (issue #27)
|
|
403
|
+
modelAlias: index.modelAlias || null,
|
|
404
|
+
dim: index.dim ?? null,
|
|
405
|
+
pooling: adapterRepr?.pooling ?? 'mean',
|
|
406
|
+
normalize: adapterRepr?.normalize !== false,
|
|
407
|
+
adapterFamily: adapterRepr?.family ?? null,
|
|
408
|
+
adapterFingerprint: index.adapterFingerprint || null,
|
|
409
|
+
chunks,
|
|
410
|
+
files,
|
|
411
|
+
indexBytes: fs.statSync(vectorsPath).size,
|
|
412
|
+
built: index.built || null,
|
|
413
|
+
ageSeconds: Number.isFinite(builtMs) ? Math.floor((Date.now() - builtMs) / 1000) : null,
|
|
414
|
+
db: index.db || db,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
if (opts.json) {
|
|
418
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const age = stats.ageSeconds === null ? '?' :
|
|
422
|
+
stats.ageSeconds < 60 ? `${stats.ageSeconds}s` :
|
|
423
|
+
stats.ageSeconds < 3600 ? `${Math.floor(stats.ageSeconds / 60)}m ${stats.ageSeconds % 60}s` :
|
|
424
|
+
stats.ageSeconds < 86400 ? `${Math.floor(stats.ageSeconds / 3600)}h ${Math.floor((stats.ageSeconds % 3600) / 60)}m` :
|
|
425
|
+
`${Math.floor(stats.ageSeconds / 86400)}d`;
|
|
426
|
+
process.stdout.write(
|
|
427
|
+
`Index at ${indexDir}\n` +
|
|
428
|
+
` format: ${stats.format} · model: ${stats.modelAlias || stats.model || '?'} (dim ${stats.dim ?? '?'})\n` +
|
|
429
|
+
` adapter: ${stats.pooling}${stats.normalize ? '' : ' · raw'}${stats.adapterFamily ? ' · ' + stats.adapterFamily : ''}\n` +
|
|
430
|
+
` schema: v${stats.schemaVersion} · lexical: ${stats.lexicalFormat || stats.lexicalStatus}\n` +
|
|
431
|
+
` chunks: ${chunks} · files: ${files}\n` +
|
|
432
|
+
` size: ${(stats.indexBytes / 1024).toFixed(1)} KiB (vectors.json)\n` +
|
|
433
|
+
` built: ${stats.built || '?'} (${age} ago)\n` +
|
|
434
|
+
` db: ${stats.db}\n`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
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
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Offline diagnostics for the index/db/model-cache trio (issue #43) — the
|
|
553
|
+
* `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
|
|
554
|
+
* .hashes.json, validates every stored vector with decodeVec (the same
|
|
555
|
+
* validator the loader uses), walks the db for staleness, and checks the
|
|
556
|
+
* transformers.js cache layout for the index's model. NEVER loads the
|
|
557
|
+
* embedding model and NEVER touches the network.
|
|
558
|
+
* @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean, config?:any}} paths
|
|
559
|
+
* @returns {CheckReport}
|
|
560
|
+
*/
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Structured result of checkHealth. Every sub-check is a small object with an
|
|
564
|
+
* `error` (string|null) plus check-specific fields; `healthy` is the AND of
|
|
565
|
+
* all checks (a warning — e.g. missing model cache when NOT offline — does not
|
|
566
|
+
* flip it).
|
|
567
|
+
* @typedef {object} CheckReport
|
|
568
|
+
* @property {boolean} healthy
|
|
569
|
+
* @property {{exists:boolean, parses:boolean, schemaVersion:(number|null), format:(string|null), recognized:boolean, error:(string|null)}} index
|
|
570
|
+
* @property {{exists:boolean, parses:boolean, files:number, error:(string|null)}} hashes
|
|
571
|
+
* @property {{total:number, valid:number, invalid:Array<{where:string, error:string}>}} chunks
|
|
572
|
+
* @property {{exists:boolean, stale:boolean, error:(string|null)}} db
|
|
573
|
+
* @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
|
|
574
|
+
*/
|
|
575
|
+
function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config = null }) {
|
|
576
|
+
const report = {
|
|
577
|
+
healthy: true,
|
|
578
|
+
index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
|
|
579
|
+
hashes: { exists: false, parses: false, files: 0, error: null },
|
|
580
|
+
chunks: { total: 0, valid: 0, invalid: [] },
|
|
581
|
+
db: { exists: true, stale: false, error: null },
|
|
582
|
+
model: { id: null, cached: false, cachePath: null, error: null },
|
|
583
|
+
config: null,
|
|
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
|
+
}
|
|
598
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
599
|
+
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
600
|
+
|
|
601
|
+
// --- vectors.json: exists, parses, schema + format recognized (#39) ---
|
|
602
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
603
|
+
report.index.error = `No index at ${vectorsPath}. Run \`mdss index\` first.`;
|
|
604
|
+
report.healthy = false;
|
|
605
|
+
return report; // nothing further is checkable without an index
|
|
606
|
+
}
|
|
607
|
+
report.index.exists = true;
|
|
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
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
index = JSON.parse(raw);
|
|
625
|
+
report.index.parses = true;
|
|
626
|
+
} catch (e) {
|
|
627
|
+
report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
|
|
628
|
+
report.healthy = false;
|
|
629
|
+
return report;
|
|
630
|
+
}
|
|
631
|
+
let schemaVersion;
|
|
632
|
+
try {
|
|
633
|
+
({ schema: schemaVersion } = inspectIndexSchema(index, 'vectors.json'));
|
|
634
|
+
} catch (error) {
|
|
635
|
+
report.index.recognized = false;
|
|
636
|
+
report.index.error = error.message;
|
|
637
|
+
report.healthy = false;
|
|
638
|
+
return report;
|
|
639
|
+
}
|
|
640
|
+
report.index.schemaVersion = schemaVersion;
|
|
641
|
+
report.index.format = index.format || 'legacy';
|
|
642
|
+
report.index.recognized = schemaVersion <= SCHEMA_VERSION;
|
|
643
|
+
let validated;
|
|
644
|
+
try {
|
|
645
|
+
validated = validateIndexEnvelope(index, 'vectors.json', {
|
|
646
|
+
encoding: 'stored', validateVectors: false,
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
report.index.error = error.message;
|
|
650
|
+
report.healthy = false;
|
|
651
|
+
return report;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// --- .hashes.json: parses; file count vs chunk count ---
|
|
655
|
+
if (fs.existsSync(hashesPath)) {
|
|
656
|
+
report.hashes.exists = true;
|
|
657
|
+
try {
|
|
658
|
+
const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
|
|
659
|
+
report.hashes.parses = true;
|
|
660
|
+
report.hashes.files = Object.keys(hashes).length;
|
|
661
|
+
} catch (e) {
|
|
662
|
+
report.hashes.error = `${hashesPath} is not valid JSON (${e.message})`;
|
|
663
|
+
report.healthy = false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// --- every chunk's vector: length == dim, no NaN/Infinity, not truncated ---
|
|
668
|
+
// Reuses decodeVec — the exact validator the loader runs (issue #40) — so
|
|
669
|
+
// `mdss check` reports the same "corrupt vector" verdicts without loading
|
|
670
|
+
// the 280MB model.
|
|
671
|
+
const chunks = index.chunks;
|
|
672
|
+
const expectedDim = validated.dim;
|
|
673
|
+
const dim = expectedDim ?? null;
|
|
674
|
+
report.chunks.total = chunks.length;
|
|
675
|
+
for (let position = 0; position < chunks.length; position++) {
|
|
676
|
+
const c = chunks[position];
|
|
677
|
+
const where = c && typeof c === 'object' && !Array.isArray(c)
|
|
678
|
+
? `${c.file}${c.heading ? ` › ${c.heading}` : ''}` : `chunk ${position}`;
|
|
679
|
+
let bad = null;
|
|
680
|
+
try {
|
|
681
|
+
if (schemaVersion >= 3) {
|
|
682
|
+
validateCurrentChunk(c, position, {
|
|
683
|
+
dim: dim ?? undefined, encoding: 'stored', allowMissingVector: undefined,
|
|
684
|
+
});
|
|
685
|
+
} else if (c === null || typeof c !== 'object' || Array.isArray(c)) {
|
|
686
|
+
throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
|
|
687
|
+
} else if (typeof c.vec === 'string') {
|
|
688
|
+
decodeVec(c.vec, dim ?? undefined);
|
|
689
|
+
} else {
|
|
690
|
+
validateNumericVector(c.vec, dim ?? undefined, `chunk ${where}`);
|
|
691
|
+
}
|
|
692
|
+
} catch (error) {
|
|
693
|
+
bad = error.message;
|
|
694
|
+
}
|
|
695
|
+
if (bad) {
|
|
696
|
+
report.healthy = false;
|
|
697
|
+
report.chunks.invalid.push({ where, error: bad });
|
|
698
|
+
} else {
|
|
699
|
+
report.chunks.valid++;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// --- db dir: exists; newest file mtime vs built (stale?) — same 5s grace ---
|
|
704
|
+
// as warnIfStale, so a same-second touch doesn't count as a problem.
|
|
705
|
+
if (!fs.existsSync(db) || !fs.statSync(db).isDirectory()) {
|
|
706
|
+
report.db.exists = false;
|
|
707
|
+
report.db.error = `db dir missing: ${db}`;
|
|
708
|
+
report.healthy = false;
|
|
709
|
+
} else {
|
|
710
|
+
let newest = 0;
|
|
711
|
+
try {
|
|
712
|
+
for (const f of walkMarkdown(db)) {
|
|
713
|
+
const st = fs.statSync(f);
|
|
714
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
715
|
+
}
|
|
716
|
+
} catch { /* unreadable db → newest stays 0, staleness check is moot */ }
|
|
717
|
+
const builtMs = index.built ? Date.parse(index.built) : NaN;
|
|
718
|
+
if (Number.isFinite(builtMs) && newest > builtMs + 5000) {
|
|
719
|
+
report.db.stale = true;
|
|
720
|
+
report.healthy = false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// --- model cache: transformers.js FileCache keys under cacheDir ---
|
|
725
|
+
// Offline readiness — the model must be downloaded before search works in
|
|
726
|
+
// --offline mode. Missing cache is a warning unless the user explicitly
|
|
727
|
+
// demands offline readiness (then it's a failure, issue #43).
|
|
728
|
+
const modelId = index.model ?? null;
|
|
729
|
+
report.model.id = modelId;
|
|
730
|
+
if (modelId) {
|
|
731
|
+
const model = resolveModel(modelId);
|
|
732
|
+
report.model.pooling = model.pooling ?? 'mean';
|
|
733
|
+
report.model.normalize = model.normalize !== false;
|
|
734
|
+
report.model.adapterFamily = model.family ?? null;
|
|
735
|
+
report.model.dim = model.dim ?? null;
|
|
736
|
+
const cachePath = path.join(cacheDir, model.id,
|
|
737
|
+
...((model.revision ?? 'main') === 'main' ? [] : [model.revision]));
|
|
738
|
+
report.model.cachePath = cachePath;
|
|
739
|
+
report.model.cached = fs.existsSync(cachePath);
|
|
740
|
+
if (!report.model.cached) {
|
|
741
|
+
report.model.error =
|
|
742
|
+
`model cache for ${modelId} not found at ${cachePath} — offline search will fail ` +
|
|
743
|
+
'(the first online run downloads it)';
|
|
744
|
+
if (requireOffline) report.healthy = false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return report;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* `mdss check` (alias: `mdss doctor`) — offline diagnostics (issue #43).
|
|
752
|
+
* Reports what is broken about the index/db/model-cache without loading the
|
|
753
|
+
* embedding model or touching the network. Exit code 0 when healthy, 1 with a
|
|
754
|
+
* summary when not; `--json` for scripting.
|
|
755
|
+
*/
|
|
756
|
+
function cmdCheck(opts) {
|
|
757
|
+
const db = resolveDb(opts);
|
|
758
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
759
|
+
const cacheDir = resolveCache(opts);
|
|
760
|
+
const report = checkHealth({
|
|
761
|
+
db, indexDir, cacheDir,
|
|
762
|
+
requireOffline: resolveOffline(opts),
|
|
763
|
+
config: opts._config,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
if (opts.json) {
|
|
767
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
768
|
+
process.exitCode = report.healthy ? 0 : 1;
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const ok = (s) => ` ok ${s}\n`;
|
|
773
|
+
const warn = (s) => ` warn ${s}\n`;
|
|
774
|
+
const fail = (s) => ` FAIL ${s}\n`;
|
|
775
|
+
let out = `check: ${indexDir}\n`;
|
|
776
|
+
|
|
777
|
+
// If there is no index or it doesn't parse, the report contains only the
|
|
778
|
+
// index error — chunks/db/model are NOT checkable, show just that.
|
|
779
|
+
if (!report.index.exists || !report.index.parses) {
|
|
780
|
+
out += fail(report.index.error);
|
|
781
|
+
out += 'check: 1+ problem(s) found (exit 1)\n';
|
|
782
|
+
process.stdout.write(out);
|
|
783
|
+
process.exitCode = 1;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
out += ok(`vectors.json: parses (schema v${report.index.schemaVersion}, ` +
|
|
787
|
+
`${report.index.format})`);
|
|
788
|
+
if (report.index.error) {
|
|
789
|
+
out += fail(report.index.error);
|
|
790
|
+
out += 'check: 1+ problem(s) found (exit 1)\n';
|
|
791
|
+
process.stdout.write(out);
|
|
792
|
+
process.exitCode = 1;
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (report.hashes.exists && report.hashes.parses) {
|
|
797
|
+
out += ok(`.hashes.json: parses (${report.hashes.files} file(s))`);
|
|
798
|
+
} else if (report.hashes.exists) {
|
|
799
|
+
out += fail(report.hashes.error);
|
|
800
|
+
} else {
|
|
801
|
+
out += warn('.hashes.json: missing (file count unknown)');
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (report.chunks.invalid.length === 0) {
|
|
805
|
+
out += ok(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid (dim from index)`);
|
|
806
|
+
} else {
|
|
807
|
+
out += fail(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid, ` +
|
|
808
|
+
`${report.chunks.invalid.length} invalid`);
|
|
809
|
+
for (const bad of report.chunks.invalid.slice(0, 5)) {
|
|
810
|
+
out += ` ${bad.where}: ${bad.error}\n`;
|
|
811
|
+
}
|
|
812
|
+
if (report.chunks.invalid.length > 5) {
|
|
813
|
+
out += ` …and ${report.chunks.invalid.length - 5} more\n`;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (report.db.error) {
|
|
818
|
+
out += fail(report.db.error);
|
|
819
|
+
} else if (report.db.stale) {
|
|
820
|
+
out += fail(`db ${db}: STALE — files changed after the index was built; ` +
|
|
821
|
+
'run `mdss index` to refresh');
|
|
822
|
+
} else {
|
|
823
|
+
out += ok(`db ${db}: fresh`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (report.model.id) {
|
|
827
|
+
if (report.model.cached) {
|
|
828
|
+
out += ok(`model cache: ${report.model.id} present at ${report.model.cachePath}`);
|
|
829
|
+
} else if (resolveOffline(opts)) {
|
|
830
|
+
out += fail(report.model.error); // required offline → missing cache is a failure
|
|
831
|
+
} else {
|
|
832
|
+
out += warn(report.model.error);
|
|
833
|
+
}
|
|
834
|
+
} else {
|
|
835
|
+
out += warn('model: index has no model field (legacy index)');
|
|
836
|
+
}
|
|
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
|
+
|
|
846
|
+
out += report.healthy
|
|
847
|
+
? 'check: healthy (exit 0)\n'
|
|
848
|
+
: 'check: 1+ problem(s) found (exit 1)\n';
|
|
849
|
+
process.stdout.write(out);
|
|
850
|
+
process.exitCode = report.healthy ? 0 : 1;
|
|
851
|
+
}
|
|
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
|
+
|
|
116
869
|
async function cmdSearch(opts) {
|
|
870
|
+
if (opts.interactive) return cmdTui(opts);
|
|
117
871
|
const db = resolveDb(opts);
|
|
118
872
|
const indexDir = resolveIndexDir(opts, db);
|
|
119
873
|
const cacheDir = resolveCache(opts);
|
|
@@ -125,41 +879,182 @@ async function cmdSearch(opts) {
|
|
|
125
879
|
k: opts.k || 6,
|
|
126
880
|
semanticOnly: !!opts.semantic,
|
|
127
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,
|
|
128
894
|
});
|
|
129
895
|
|
|
130
896
|
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
131
897
|
if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
|
|
132
898
|
|
|
133
899
|
process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
900
|
+
|
|
901
|
+
// Group by file (issue #13): each file becomes a header with its hits below.
|
|
902
|
+
const byFile = new Map();
|
|
903
|
+
for (const r of results) {
|
|
904
|
+
if (!byFile.has(r.file)) byFile.set(r.file, []);
|
|
905
|
+
byFile.get(r.file).push(r);
|
|
906
|
+
}
|
|
907
|
+
const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
|
|
908
|
+
let rank = 0;
|
|
909
|
+
for (const [file, hits] of byFile) {
|
|
910
|
+
process.stdout.write(`${file}\n`);
|
|
911
|
+
for (const r of hits) {
|
|
912
|
+
rank++;
|
|
913
|
+
let snippet = r.snippet;
|
|
914
|
+
if (color && r.matches && r.matches.length > 0) {
|
|
915
|
+
const re = new RegExp(`(${r.matches.map(escRe).join('|')})`, 'gi');
|
|
916
|
+
snippet = snippet.replace(re, '\x1b[1m$1\x1b[0m');
|
|
917
|
+
}
|
|
918
|
+
const loc = r.heading ? ` ${r.heading}\n` : '';
|
|
919
|
+
process.stdout.write(` ${rank}. [cos ${r.cosine}] ${r.title}\n`);
|
|
920
|
+
if (loc) process.stdout.write(loc);
|
|
921
|
+
process.stdout.write(` ${snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Escape a string for use inside a RegExp. */
|
|
927
|
+
function escRe(s) {
|
|
928
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
140
929
|
}
|
|
141
930
|
|
|
142
931
|
function cmdModels() {
|
|
143
932
|
process.stdout.write('Available models (alias → id):\n\n');
|
|
144
933
|
for (const [alias, m] of Object.entries(MODELS)) {
|
|
145
934
|
const star = alias === DEFAULT_MODEL ? ' (default)' : '';
|
|
146
|
-
|
|
935
|
+
const pooling = m.pooling ?? 'mean';
|
|
936
|
+
const normalized = m.normalize !== false ? 'L2' : 'raw';
|
|
937
|
+
const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
|
|
938
|
+
process.stdout.write(
|
|
939
|
+
` ${alias}${star}\n` +
|
|
940
|
+
` ${m.id} · dim ${m.dim}${rev}\n` +
|
|
941
|
+
` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
|
|
942
|
+
(m.family ? `· family ${m.family}\n` : '\n') +
|
|
943
|
+
` ${m.note}\n\n`,
|
|
944
|
+
);
|
|
147
945
|
}
|
|
148
|
-
process.stdout.write(
|
|
946
|
+
process.stdout.write(
|
|
947
|
+
'Pass a registered id or alias to use a built-in adapter. A raw Hugging Face\n' +
|
|
948
|
+
'id is NOT guessed to be E5-compatible: it fails at embed time until an\n' +
|
|
949
|
+
'explicit adapter descriptor is supplied (see README "How to add a model").\n',
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function cmdServe(opts) {
|
|
954
|
+
const db = resolveDb(opts);
|
|
955
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
956
|
+
const cacheDir = resolveCache(opts);
|
|
957
|
+
const port = opts.port || Number(process.env.MDSS_PORT) || DEFAULT_PORT;
|
|
958
|
+
// Loopback by default — LAN exposure is opt-in via --host (issue #16).
|
|
959
|
+
const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
|
|
960
|
+
const log = s => process.stderr.write(s + '\n');
|
|
961
|
+
|
|
962
|
+
const { server, state, close } = await createServe({
|
|
963
|
+
db, indexDir, cacheDir,
|
|
964
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
965
|
+
ignore: opts.ignore,
|
|
966
|
+
offline: resolveOffline(opts),
|
|
967
|
+
watch: !!opts.watch,
|
|
968
|
+
watchInterval: opts.watchInterval,
|
|
969
|
+
watchDelay: opts.watchDelay,
|
|
970
|
+
apiKey: opts.apiKey || process.env.MDSS_API_KEY,
|
|
971
|
+
healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
|
|
972
|
+
log,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
server.listen(port, host, () => {
|
|
976
|
+
const idx = state.loaded.index;
|
|
977
|
+
log(`mdss serve listening on http://${host}:${port}` +
|
|
978
|
+
(host === '127.0.0.1' || host === '::1' ? ' (loopback only)' : ''));
|
|
979
|
+
log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
|
|
980
|
+
(state.watching ? ' · watching for changes' : ''));
|
|
981
|
+
log(` POST /search {"query": "...", "k": 6}`);
|
|
982
|
+
log(` GET /health`);
|
|
983
|
+
log(` Ctrl+C to stop.`);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
const stop = async (sig) => {
|
|
987
|
+
log(`\n${sig} — shutting down.`);
|
|
988
|
+
await close();
|
|
989
|
+
process.exit(0);
|
|
990
|
+
};
|
|
991
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
992
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
993
|
+
}
|
|
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
|
+
});
|
|
149
1013
|
}
|
|
150
1014
|
|
|
151
1015
|
async function main() {
|
|
152
1016
|
const argv = process.argv.slice(2);
|
|
153
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
|
+
|
|
154
1026
|
const cmd = opts._.shift();
|
|
155
1027
|
|
|
1028
|
+
if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
|
|
156
1029
|
if (opts.help || !cmd) { process.stdout.write(HELP); return; }
|
|
157
1030
|
switch (cmd) {
|
|
158
1031
|
case 'index': return cmdIndex(opts);
|
|
1032
|
+
case 'stats': return cmdStats(opts);
|
|
1033
|
+
case 'check':
|
|
1034
|
+
case 'doctor': return cmdCheck(opts);
|
|
1035
|
+
case 'export': return cmdExport(opts);
|
|
159
1036
|
case 'search': return cmdSearch(opts);
|
|
1037
|
+
case 'tui': return cmdTui(opts);
|
|
1038
|
+
case 'serve': return cmdServe(opts);
|
|
1039
|
+
case 'mcp': return cmdMcp(opts);
|
|
160
1040
|
case 'models': return cmdModels();
|
|
161
1041
|
default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
|
|
162
1042
|
}
|
|
163
1043
|
}
|
|
164
1044
|
|
|
165
|
-
|
|
1045
|
+
// Run only when executed directly (`node bin/cli.mjs`, `mdss`), never when
|
|
1046
|
+
// imported by tests or library consumers (issue #29).
|
|
1047
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1048
|
+
main().catch(e => die(e.message));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Exported for the CLI harness tests (issue #29): the pure argument/resolution
|
|
1052
|
+
// functions are unit-tested directly; die() is asserted via a mocked
|
|
1053
|
+
// process.exit so a bad flag cannot kill the test runner.
|
|
1054
|
+
export {
|
|
1055
|
+
parseArgs, nextInt, nextValue,
|
|
1056
|
+
resolveDb, resolveIndexDir, resolveCache, resolveOffline,
|
|
1057
|
+
findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
|
|
1058
|
+
checkHealth, cmdExport,
|
|
1059
|
+
die, main, HELP, VERSION,
|
|
1060
|
+
};
|