md-semantic-search 0.3.0 → 0.4.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 +357 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +573 -32
- package/package.json +11 -2
- package/src/collapse.mjs +31 -0
- package/src/core.mjs +424 -60
- package/src/frontmatter.mjs +213 -0
- package/src/index-format.mjs +134 -0
- package/src/index.mjs +47 -0
- package/src/indexer.mjs +390 -47
- package/src/lexical.mjs +291 -0
- package/src/markdown-parser.mjs +347 -0
- package/src/metrics.mjs +186 -0
- package/src/models.mjs +250 -18
- package/src/rerank.mjs +75 -0
- package/src/search.mjs +571 -42
- package/src/serve.mjs +387 -0
- package/src/wikilinks.mjs +241 -0
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,90 @@
|
|
|
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';
|
|
16
|
+
import os from 'node:os';
|
|
15
17
|
import { buildIndex } from '../src/indexer.mjs';
|
|
16
18
|
import { search } from '../src/search.mjs';
|
|
17
|
-
import {
|
|
19
|
+
import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../src/serve.mjs';
|
|
20
|
+
import { MODELS, DEFAULT_MODEL, resolveModel } from '../src/models.mjs';
|
|
21
|
+
import { decodeVec, walkMarkdown, SCHEMA_VERSION } from '../src/core.mjs';
|
|
22
|
+
import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../src/index-format.mjs';
|
|
18
23
|
|
|
19
24
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
25
|
const PKG_ROOT = path.resolve(HERE, '..');
|
|
21
|
-
const
|
|
26
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* User-level model cache. Default: ~/.cache/mdss (respecting XDG_CACHE_HOME on
|
|
30
|
+
* POSIX); falls back to <pkg>/.cache when homedir is unavailable. Explicit
|
|
31
|
+
* --cache-dir / MDSS_CACHE_DIR always win (issue #9).
|
|
32
|
+
*/
|
|
33
|
+
function defaultCacheDir() {
|
|
34
|
+
try {
|
|
35
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, 'mdss');
|
|
36
|
+
const home = os.homedir();
|
|
37
|
+
if (home) return path.join(home, '.cache', 'mdss');
|
|
38
|
+
} catch { /* homedir unavailable */ }
|
|
39
|
+
return path.join(PKG_ROOT, '.cache');
|
|
40
|
+
}
|
|
22
41
|
|
|
23
42
|
function parseArgs(argv) {
|
|
24
|
-
const opts = { _: [], ignore: [] };
|
|
43
|
+
const opts = { _: [], ignore: [], path: [] };
|
|
25
44
|
for (let i = 0; i < argv.length; i++) {
|
|
26
45
|
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;
|
|
46
|
+
if (a === '--json') opts.json = true;
|
|
34
47
|
else if (a === '--semantic') opts.semantic = true;
|
|
35
48
|
else if (a === '--offline') opts.offline = true;
|
|
49
|
+
else if (a === '--watch') opts.watch = true;
|
|
50
|
+
else if (a === '--rerank') opts.rerank = true;
|
|
51
|
+
else if (a === '--explain') opts.explain = true;
|
|
52
|
+
else if (a === '--version') opts.version = true;
|
|
36
53
|
else if (a === '-h' || a === '--help') opts.help = true;
|
|
54
|
+
else if (a === '--db') opts.db = nextValue(argv, ++i, a);
|
|
55
|
+
else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
|
|
56
|
+
else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
|
|
57
|
+
else if (a === '--model') opts.model = nextValue(argv, ++i, a);
|
|
58
|
+
else if (a === '--k') opts.k = nextInt(argv, ++i, a);
|
|
59
|
+
else if (a === '--max-per-file') opts.maxPerFile = nextInt(argv, ++i, a);
|
|
60
|
+
else if (a === '--max-per-doc') opts.maxPerDoc = nextInt(argv, ++i, a);
|
|
61
|
+
else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
|
|
62
|
+
else if (a === '--port') opts.port = nextInt(argv, ++i, a);
|
|
63
|
+
else if (a === '--host') opts.host = nextValue(argv, ++i, a);
|
|
64
|
+
else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
|
|
65
|
+
else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
|
|
66
|
+
else if (a === '--since') opts.since = nextValue(argv, ++i, a);
|
|
67
|
+
else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
|
|
68
|
+
else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
|
|
69
|
+
else if (a === '--tag') {
|
|
70
|
+
const v = nextValue(argv, ++i, a);
|
|
71
|
+
opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
|
|
72
|
+
}
|
|
73
|
+
else if (a === '--project') opts.project = nextValue(argv, ++i, a);
|
|
74
|
+
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
75
|
+
else if (a === '--status') opts.status = nextValue(argv, ++i, a);
|
|
76
|
+
else if (a === '--canonical') opts.canonical = true;
|
|
77
|
+
else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
|
|
37
78
|
else opts._.push(a);
|
|
38
79
|
}
|
|
39
80
|
return opts;
|
|
40
81
|
}
|
|
41
82
|
|
|
83
|
+
/** Value of the flag at `i`; errors when missing or followed by another flag. */
|
|
84
|
+
function nextValue(argv, i, flag) {
|
|
85
|
+
const v = argv[i];
|
|
86
|
+
if (v === undefined || v.startsWith('-')) die(`missing value for ${flag}`);
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Like nextValue but requires a positive integer (issue #8: --k abc → clear error). */
|
|
91
|
+
function nextInt(argv, i, flag) {
|
|
92
|
+
const v = nextValue(argv, i, flag);
|
|
93
|
+
const k = Number.parseInt(v, 10);
|
|
94
|
+
if (!Number.isInteger(k) || k <= 0) die(`${flag} must be a positive integer, got "${v}"`);
|
|
95
|
+
return k;
|
|
96
|
+
}
|
|
97
|
+
|
|
42
98
|
function resolveDb(opts) {
|
|
43
99
|
const db = opts.db || process.env.MDSS_DB;
|
|
44
100
|
if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
|
|
@@ -56,7 +112,7 @@ function resolveIndexDir(opts, db) {
|
|
|
56
112
|
}
|
|
57
113
|
|
|
58
114
|
const resolveCache = (opts) =>
|
|
59
|
-
path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR ||
|
|
115
|
+
path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || defaultCacheDir());
|
|
60
116
|
|
|
61
117
|
/** --offline flag or MDSS_OFFLINE=1 env → never touch the network. */
|
|
62
118
|
const resolveOffline = (opts) => !!opts.offline || !!process.env.MDSS_OFFLINE;
|
|
@@ -65,24 +121,37 @@ function die(msg) {
|
|
|
65
121
|
process.stderr.write(`error: ${msg}\n`);
|
|
66
122
|
process.exit(1);
|
|
67
123
|
}
|
|
68
|
-
|
|
69
124
|
const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
|
|
70
125
|
|
|
71
126
|
Usage:
|
|
72
127
|
mdss index --db <dir> [options] Build/refresh the index
|
|
128
|
+
mdss stats --db <dir> [--json] Index stats without loading the model
|
|
129
|
+
mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
|
|
73
130
|
mdss search --db <dir> [options] "query" Search by meaning
|
|
131
|
+
mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
|
|
74
132
|
mdss models List available models
|
|
75
133
|
|
|
76
134
|
Options:
|
|
77
135
|
--db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
|
|
78
136
|
--index-dir <dir> Where to store the index (default: <db>/.mdss).
|
|
79
|
-
--cache-dir <dir> Model cache dir (default:
|
|
137
|
+
--cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
|
|
80
138
|
--model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
|
|
81
139
|
--ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
|
|
140
|
+
--path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
|
|
141
|
+
--since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
|
|
82
142
|
--k <n> Number of results (search, default 6).
|
|
83
|
-
--json Machine-readable output (search).
|
|
143
|
+
--json Machine-readable output (index, stats, search).
|
|
84
144
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
145
|
+
--rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
|
|
146
|
+
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
147
|
+
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
148
|
+
only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
|
|
149
|
+
--watch serve: re-index incrementally on file changes (mtime poll).
|
|
150
|
+
--watch-interval <ms> serve --watch: poll every N ms (default 3000).
|
|
151
|
+
--watch-delay <ms> serve --watch: quiet-period debounce before a burst of
|
|
152
|
+
saves triggers ONE re-index (default 1000; issue #42).
|
|
85
153
|
--offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
|
|
154
|
+
--version Print the version and exit.
|
|
86
155
|
-h, --help Show this help.
|
|
87
156
|
|
|
88
157
|
Examples:
|
|
@@ -90,6 +159,9 @@ Examples:
|
|
|
90
159
|
mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
|
|
91
160
|
mdss search --db ./docs "how do I rotate the api token"
|
|
92
161
|
MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
|
|
162
|
+
mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
|
|
163
|
+
mdss serve --db ./docs --port 8747 --watch
|
|
164
|
+
curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
|
|
93
165
|
`;
|
|
94
166
|
|
|
95
167
|
async function cmdIndex(opts) {
|
|
@@ -97,22 +169,387 @@ async function cmdIndex(opts) {
|
|
|
97
169
|
const indexDir = resolveIndexDir(opts, db);
|
|
98
170
|
const cacheDir = resolveCache(opts);
|
|
99
171
|
const t0 = Date.now();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
172
|
+
let r;
|
|
173
|
+
try {
|
|
174
|
+
r = await buildIndex({
|
|
175
|
+
db, indexDir, cacheDir,
|
|
176
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
177
|
+
ignore: opts.ignore,
|
|
178
|
+
offline: resolveOffline(opts),
|
|
179
|
+
log: s => process.stderr.write(s + '\n'),
|
|
180
|
+
});
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// A held index lock (issue #37) is an EXPECTED operational state (a second
|
|
183
|
+
// terminal also indexing, or a `serve --watch` daemon mid-cycle) — surface
|
|
184
|
+
// it as a clean one-line error + hint, not a stack of "locked by pid".
|
|
185
|
+
if (/(being written by pid|index is being written)/i.test(String(e?.message || ''))) {
|
|
186
|
+
die(`${e.message}\nAnother mdss process is writing to this index. ` +
|
|
187
|
+
'Retry once it finishes, or remove a stale .mdss.lock if the holder crashed.');
|
|
188
|
+
}
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
// Machine-readable build result for scripts/CI (issue #21): the exact
|
|
192
|
+
// buildIndex return value — lets automation assert "0 embedded" (fully
|
|
193
|
+
// incremental) or "N skipped" without parsing prose.
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
107
198
|
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
199
|
+
const skipped = r.skipped > 0 ? `, ${r.skipped} skipped` : '';
|
|
108
200
|
process.stderr.write(
|
|
109
201
|
`\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
|
|
110
202
|
`(${r.reused} reused [${r.reusedChunks} chunk-level, ${r.reusedFiles} file-level], ` +
|
|
111
|
-
`${r.embedded} embedded), dim=${r.dim}, ` +
|
|
203
|
+
`${r.embedded} embedded)${skipped}, dim=${r.dim}, ` +
|
|
112
204
|
`model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
|
|
113
205
|
);
|
|
114
206
|
}
|
|
115
207
|
|
|
208
|
+
/**
|
|
209
|
+
* `mdss stats` — machine-readable index statistics WITHOUT loading the
|
|
210
|
+
* embedding model: parses only vectors.json + .hashes.json (issue #21).
|
|
211
|
+
* Gives scripts/CI a cheap sanity check after a re-index (counts, model, dim),
|
|
212
|
+
* staleness detection (ageSeconds), and index-path/format info for migrations.
|
|
213
|
+
*/
|
|
214
|
+
function cmdStats(opts) {
|
|
215
|
+
const db = resolveDb(opts);
|
|
216
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
217
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
218
|
+
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
219
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
220
|
+
die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
221
|
+
}
|
|
222
|
+
let index;
|
|
223
|
+
try {
|
|
224
|
+
index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
validateIndexEnvelope(index, vectorsPath, { encoding: 'stored' });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
die(error.message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// File count = keys of .hashes.json (per-file md5 map written by buildIndex).
|
|
235
|
+
// Fall back to unique chunk file paths when the hashes file is missing (a
|
|
236
|
+
// legacy/corrupt index) rather than dying.
|
|
237
|
+
let files = 0;
|
|
238
|
+
try {
|
|
239
|
+
const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
|
|
240
|
+
files = Object.keys(hashes).length;
|
|
241
|
+
} catch {
|
|
242
|
+
files = new Set((index.chunks || []).map(c => c.file)).size;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const chunks = index.chunkCount ?? (index.chunks ? index.chunks.length : 0);
|
|
246
|
+
const builtMs = index.built ? Date.parse(index.built) : NaN;
|
|
247
|
+
const adapterRepr = (index.model || index.modelAlias)
|
|
248
|
+
? resolveModel(index.model || index.modelAlias) : null;
|
|
249
|
+
const stats = {
|
|
250
|
+
indexDir,
|
|
251
|
+
schemaVersion: index.schemaVersion ?? 0,
|
|
252
|
+
format: index.format || 'legacy', // binary-v1 vs pre-0.4 decimal
|
|
253
|
+
lexicalFormat: index.schemaVersion >= 3 ? (index.lexical?.format || 'invalid') : null,
|
|
254
|
+
lexicalStatus: index.schemaVersion >= 3 ? 'persisted-bm25' : 'legacy-overlap',
|
|
255
|
+
model: index.model || null, // id@revision (issue #27)
|
|
256
|
+
modelAlias: index.modelAlias || null,
|
|
257
|
+
dim: index.dim ?? null,
|
|
258
|
+
pooling: adapterRepr?.pooling ?? 'mean',
|
|
259
|
+
normalize: adapterRepr?.normalize !== false,
|
|
260
|
+
adapterFamily: adapterRepr?.family ?? null,
|
|
261
|
+
adapterFingerprint: index.adapterFingerprint || null,
|
|
262
|
+
chunks,
|
|
263
|
+
files,
|
|
264
|
+
indexBytes: fs.statSync(vectorsPath).size,
|
|
265
|
+
built: index.built || null,
|
|
266
|
+
ageSeconds: Number.isFinite(builtMs) ? Math.floor((Date.now() - builtMs) / 1000) : null,
|
|
267
|
+
db: index.db || db,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (opts.json) {
|
|
271
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const age = stats.ageSeconds === null ? '?' :
|
|
275
|
+
stats.ageSeconds < 60 ? `${stats.ageSeconds}s` :
|
|
276
|
+
stats.ageSeconds < 3600 ? `${Math.floor(stats.ageSeconds / 60)}m ${stats.ageSeconds % 60}s` :
|
|
277
|
+
stats.ageSeconds < 86400 ? `${Math.floor(stats.ageSeconds / 3600)}h ${Math.floor((stats.ageSeconds % 3600) / 60)}m` :
|
|
278
|
+
`${Math.floor(stats.ageSeconds / 86400)}d`;
|
|
279
|
+
process.stdout.write(
|
|
280
|
+
`Index at ${indexDir}\n` +
|
|
281
|
+
` format: ${stats.format} · model: ${stats.modelAlias || stats.model || '?'} (dim ${stats.dim ?? '?'})\n` +
|
|
282
|
+
` adapter: ${stats.pooling}${stats.normalize ? '' : ' · raw'}${stats.adapterFamily ? ' · ' + stats.adapterFamily : ''}\n` +
|
|
283
|
+
` schema: v${stats.schemaVersion} · lexical: ${stats.lexicalFormat || stats.lexicalStatus}\n` +
|
|
284
|
+
` chunks: ${chunks} · files: ${files}\n` +
|
|
285
|
+
` size: ${(stats.indexBytes / 1024).toFixed(1)} KiB (vectors.json)\n` +
|
|
286
|
+
` built: ${stats.built || '?'} (${age} ago)\n` +
|
|
287
|
+
` db: ${stats.db}\n`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Offline diagnostics for the index/db/model-cache trio (issue #43) — the
|
|
293
|
+
* `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
|
|
294
|
+
* .hashes.json, validates every stored vector with decodeVec (the same
|
|
295
|
+
* validator the loader uses), walks the db for staleness, and checks the
|
|
296
|
+
* transformers.js cache layout for the index's model. NEVER loads the
|
|
297
|
+
* embedding model and NEVER touches the network.
|
|
298
|
+
* @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean}} paths
|
|
299
|
+
* @returns {CheckReport}
|
|
300
|
+
*/
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Structured result of checkHealth. Every sub-check is a small object with an
|
|
304
|
+
* `error` (string|null) plus check-specific fields; `healthy` is the AND of
|
|
305
|
+
* all checks (a warning — e.g. missing model cache when NOT offline — does not
|
|
306
|
+
* flip it).
|
|
307
|
+
* @typedef {object} CheckReport
|
|
308
|
+
* @property {boolean} healthy
|
|
309
|
+
* @property {{exists:boolean, parses:boolean, schemaVersion:(number|null), format:(string|null), recognized:boolean, error:(string|null)}} index
|
|
310
|
+
* @property {{exists:boolean, parses:boolean, files:number, error:(string|null)}} hashes
|
|
311
|
+
* @property {{total:number, valid:number, invalid:Array<{where:string, error:string}>}} chunks
|
|
312
|
+
* @property {{exists:boolean, stale:boolean, error:(string|null)}} db
|
|
313
|
+
* @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
|
|
314
|
+
*/
|
|
315
|
+
export function checkHealth({ db, indexDir, cacheDir, requireOffline = false }) {
|
|
316
|
+
const report = {
|
|
317
|
+
healthy: true,
|
|
318
|
+
index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
|
|
319
|
+
hashes: { exists: false, parses: false, files: 0, error: null },
|
|
320
|
+
chunks: { total: 0, valid: 0, invalid: [] },
|
|
321
|
+
db: { exists: true, stale: false, error: null },
|
|
322
|
+
model: { id: null, cached: false, cachePath: null, error: null },
|
|
323
|
+
};
|
|
324
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
325
|
+
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
326
|
+
|
|
327
|
+
// --- vectors.json: exists, parses, schema + format recognized (#39) ---
|
|
328
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
329
|
+
report.index.error = `No index at ${vectorsPath}. Run \`mdss index\` first.`;
|
|
330
|
+
report.healthy = false;
|
|
331
|
+
return report; // nothing further is checkable without an index
|
|
332
|
+
}
|
|
333
|
+
report.index.exists = true;
|
|
334
|
+
let index;
|
|
335
|
+
try {
|
|
336
|
+
index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
|
|
337
|
+
report.index.parses = true;
|
|
338
|
+
} catch (e) {
|
|
339
|
+
report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
|
|
340
|
+
report.healthy = false;
|
|
341
|
+
return report;
|
|
342
|
+
}
|
|
343
|
+
let schemaVersion;
|
|
344
|
+
try {
|
|
345
|
+
({ schema: schemaVersion } = inspectIndexSchema(index, 'vectors.json'));
|
|
346
|
+
} catch (error) {
|
|
347
|
+
report.index.recognized = false;
|
|
348
|
+
report.index.error = error.message;
|
|
349
|
+
report.healthy = false;
|
|
350
|
+
return report;
|
|
351
|
+
}
|
|
352
|
+
report.index.schemaVersion = schemaVersion;
|
|
353
|
+
report.index.format = index.format || 'legacy';
|
|
354
|
+
report.index.recognized = schemaVersion <= SCHEMA_VERSION;
|
|
355
|
+
let validated;
|
|
356
|
+
try {
|
|
357
|
+
validated = validateIndexEnvelope(index, 'vectors.json', {
|
|
358
|
+
encoding: 'stored', validateVectors: false,
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
report.index.error = error.message;
|
|
362
|
+
report.healthy = false;
|
|
363
|
+
return report;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- .hashes.json: parses; file count vs chunk count ---
|
|
367
|
+
if (fs.existsSync(hashesPath)) {
|
|
368
|
+
report.hashes.exists = true;
|
|
369
|
+
try {
|
|
370
|
+
const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
|
|
371
|
+
report.hashes.parses = true;
|
|
372
|
+
report.hashes.files = Object.keys(hashes).length;
|
|
373
|
+
} catch (e) {
|
|
374
|
+
report.hashes.error = `${hashesPath} is not valid JSON (${e.message})`;
|
|
375
|
+
report.healthy = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- every chunk's vector: length == dim, no NaN/Infinity, not truncated ---
|
|
380
|
+
// Reuses decodeVec — the exact validator the loader runs (issue #40) — so
|
|
381
|
+
// `mdss check` reports the same "corrupt vector" verdicts without loading
|
|
382
|
+
// the 280MB model.
|
|
383
|
+
const chunks = index.chunks;
|
|
384
|
+
const expectedDim = validated.dim;
|
|
385
|
+
const dim = expectedDim ?? null;
|
|
386
|
+
report.chunks.total = chunks.length;
|
|
387
|
+
for (let position = 0; position < chunks.length; position++) {
|
|
388
|
+
const c = chunks[position];
|
|
389
|
+
const where = c && typeof c === 'object' && !Array.isArray(c)
|
|
390
|
+
? `${c.file}${c.heading ? ` › ${c.heading}` : ''}` : `chunk ${position}`;
|
|
391
|
+
let bad = null;
|
|
392
|
+
try {
|
|
393
|
+
if (schemaVersion >= 3) {
|
|
394
|
+
validateCurrentChunk(c, position, {
|
|
395
|
+
dim: dim ?? undefined, encoding: 'stored', allowMissingVector: undefined,
|
|
396
|
+
});
|
|
397
|
+
} else if (c === null || typeof c !== 'object' || Array.isArray(c)) {
|
|
398
|
+
throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
|
|
399
|
+
} else if (typeof c.vec === 'string') {
|
|
400
|
+
decodeVec(c.vec, dim ?? undefined);
|
|
401
|
+
} else {
|
|
402
|
+
validateNumericVector(c.vec, dim ?? undefined, `chunk ${where}`);
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
bad = error.message;
|
|
406
|
+
}
|
|
407
|
+
if (bad) {
|
|
408
|
+
report.healthy = false;
|
|
409
|
+
report.chunks.invalid.push({ where, error: bad });
|
|
410
|
+
} else {
|
|
411
|
+
report.chunks.valid++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- db dir: exists; newest file mtime vs built (stale?) — same 5s grace ---
|
|
416
|
+
// as warnIfStale, so a same-second touch doesn't count as a problem.
|
|
417
|
+
if (!fs.existsSync(db) || !fs.statSync(db).isDirectory()) {
|
|
418
|
+
report.db.exists = false;
|
|
419
|
+
report.db.error = `db dir missing: ${db}`;
|
|
420
|
+
report.healthy = false;
|
|
421
|
+
} else {
|
|
422
|
+
let newest = 0;
|
|
423
|
+
try {
|
|
424
|
+
for (const f of walkMarkdown(db)) {
|
|
425
|
+
const st = fs.statSync(f);
|
|
426
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
427
|
+
}
|
|
428
|
+
} catch { /* unreadable db → newest stays 0, staleness check is moot */ }
|
|
429
|
+
const builtMs = index.built ? Date.parse(index.built) : NaN;
|
|
430
|
+
if (Number.isFinite(builtMs) && newest > builtMs + 5000) {
|
|
431
|
+
report.db.stale = true;
|
|
432
|
+
report.healthy = false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// --- model cache: transformers.js FileCache keys under cacheDir ---
|
|
437
|
+
// Offline readiness — the model must be downloaded before search works in
|
|
438
|
+
// --offline mode. Missing cache is a warning unless the user explicitly
|
|
439
|
+
// demands offline readiness (then it's a failure, issue #43).
|
|
440
|
+
const modelId = index.model ?? null;
|
|
441
|
+
report.model.id = modelId;
|
|
442
|
+
if (modelId) {
|
|
443
|
+
const model = resolveModel(modelId);
|
|
444
|
+
report.model.pooling = model.pooling ?? 'mean';
|
|
445
|
+
report.model.normalize = model.normalize !== false;
|
|
446
|
+
report.model.adapterFamily = model.family ?? null;
|
|
447
|
+
report.model.dim = model.dim ?? null;
|
|
448
|
+
const cachePath = path.join(cacheDir, model.id,
|
|
449
|
+
...((model.revision ?? 'main') === 'main' ? [] : [model.revision]));
|
|
450
|
+
report.model.cachePath = cachePath;
|
|
451
|
+
report.model.cached = fs.existsSync(cachePath);
|
|
452
|
+
if (!report.model.cached) {
|
|
453
|
+
report.model.error =
|
|
454
|
+
`model cache for ${modelId} not found at ${cachePath} — offline search will fail ` +
|
|
455
|
+
'(the first online run downloads it)';
|
|
456
|
+
if (requireOffline) report.healthy = false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return report;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* `mdss check` (alias: `mdss doctor`) — offline diagnostics (issue #43).
|
|
464
|
+
* Reports what is broken about the index/db/model-cache without loading the
|
|
465
|
+
* embedding model or touching the network. Exit code 0 when healthy, 1 with a
|
|
466
|
+
* summary when not; `--json` for scripting.
|
|
467
|
+
*/
|
|
468
|
+
function cmdCheck(opts) {
|
|
469
|
+
const db = resolveDb(opts);
|
|
470
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
471
|
+
const cacheDir = resolveCache(opts);
|
|
472
|
+
const report = checkHealth({ db, indexDir, cacheDir, requireOffline: resolveOffline(opts) });
|
|
473
|
+
|
|
474
|
+
if (opts.json) {
|
|
475
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
476
|
+
process.exitCode = report.healthy ? 0 : 1;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const ok = (s) => ` ok ${s}\n`;
|
|
481
|
+
const warn = (s) => ` warn ${s}\n`;
|
|
482
|
+
const fail = (s) => ` FAIL ${s}\n`;
|
|
483
|
+
let out = `check: ${indexDir}\n`;
|
|
484
|
+
|
|
485
|
+
// If there is no index or it doesn't parse, the report contains only the
|
|
486
|
+
// index error — chunks/db/model are NOT checkable, show just that.
|
|
487
|
+
if (!report.index.exists || !report.index.parses) {
|
|
488
|
+
out += fail(report.index.error);
|
|
489
|
+
out += 'check: 1+ problem(s) found (exit 1)\n';
|
|
490
|
+
process.stdout.write(out);
|
|
491
|
+
process.exitCode = 1;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
out += ok(`vectors.json: parses (schema v${report.index.schemaVersion}, ` +
|
|
495
|
+
`${report.index.format})`);
|
|
496
|
+
if (report.index.error) {
|
|
497
|
+
out += fail(report.index.error);
|
|
498
|
+
out += 'check: 1+ problem(s) found (exit 1)\n';
|
|
499
|
+
process.stdout.write(out);
|
|
500
|
+
process.exitCode = 1;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (report.hashes.exists && report.hashes.parses) {
|
|
505
|
+
out += ok(`.hashes.json: parses (${report.hashes.files} file(s))`);
|
|
506
|
+
} else if (report.hashes.exists) {
|
|
507
|
+
out += fail(report.hashes.error);
|
|
508
|
+
} else {
|
|
509
|
+
out += warn('.hashes.json: missing (file count unknown)');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (report.chunks.invalid.length === 0) {
|
|
513
|
+
out += ok(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid (dim from index)`);
|
|
514
|
+
} else {
|
|
515
|
+
out += fail(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid, ` +
|
|
516
|
+
`${report.chunks.invalid.length} invalid`);
|
|
517
|
+
for (const bad of report.chunks.invalid.slice(0, 5)) {
|
|
518
|
+
out += ` ${bad.where}: ${bad.error}\n`;
|
|
519
|
+
}
|
|
520
|
+
if (report.chunks.invalid.length > 5) {
|
|
521
|
+
out += ` …and ${report.chunks.invalid.length - 5} more\n`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (report.db.error) {
|
|
526
|
+
out += fail(report.db.error);
|
|
527
|
+
} else if (report.db.stale) {
|
|
528
|
+
out += fail(`db ${db}: STALE — files changed after the index was built; ` +
|
|
529
|
+
'run `mdss index` to refresh');
|
|
530
|
+
} else {
|
|
531
|
+
out += ok(`db ${db}: fresh`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (report.model.id) {
|
|
535
|
+
if (report.model.cached) {
|
|
536
|
+
out += ok(`model cache: ${report.model.id} present at ${report.model.cachePath}`);
|
|
537
|
+
} else if (resolveOffline(opts)) {
|
|
538
|
+
out += fail(report.model.error); // required offline → missing cache is a failure
|
|
539
|
+
} else {
|
|
540
|
+
out += warn(report.model.error);
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
out += warn('model: index has no model field (legacy index)');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
out += report.healthy
|
|
547
|
+
? 'check: healthy (exit 0)\n'
|
|
548
|
+
: 'check: 1+ problem(s) found (exit 1)\n';
|
|
549
|
+
process.stdout.write(out);
|
|
550
|
+
process.exitCode = report.healthy ? 0 : 1;
|
|
551
|
+
}
|
|
552
|
+
|
|
116
553
|
async function cmdSearch(opts) {
|
|
117
554
|
const db = resolveDb(opts);
|
|
118
555
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -125,27 +562,113 @@ async function cmdSearch(opts) {
|
|
|
125
562
|
k: opts.k || 6,
|
|
126
563
|
semanticOnly: !!opts.semantic,
|
|
127
564
|
offline: resolveOffline(opts),
|
|
565
|
+
path: opts.path.length > 0 ? opts.path : undefined,
|
|
566
|
+
since: opts.since,
|
|
567
|
+
rerank: !!opts.rerank,
|
|
568
|
+
tag: opts.tag,
|
|
569
|
+
project: opts.project,
|
|
570
|
+
type: opts.type,
|
|
571
|
+
status: opts.status,
|
|
572
|
+
canonicalOnly: opts.canonical,
|
|
573
|
+
explain: !!opts.explain,
|
|
574
|
+
maxPerFile: opts.maxPerFile || opts.maxPerDoc,
|
|
128
575
|
});
|
|
129
576
|
|
|
130
577
|
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
131
578
|
if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
|
|
132
579
|
|
|
133
580
|
process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
581
|
+
|
|
582
|
+
// Group by file (issue #13): each file becomes a header with its hits below.
|
|
583
|
+
const byFile = new Map();
|
|
584
|
+
for (const r of results) {
|
|
585
|
+
if (!byFile.has(r.file)) byFile.set(r.file, []);
|
|
586
|
+
byFile.get(r.file).push(r);
|
|
587
|
+
}
|
|
588
|
+
const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
|
|
589
|
+
let rank = 0;
|
|
590
|
+
for (const [file, hits] of byFile) {
|
|
591
|
+
process.stdout.write(`${file}\n`);
|
|
592
|
+
for (const r of hits) {
|
|
593
|
+
rank++;
|
|
594
|
+
let snippet = r.snippet;
|
|
595
|
+
if (color && r.matches && r.matches.length > 0) {
|
|
596
|
+
const re = new RegExp(`(${r.matches.map(escRe).join('|')})`, 'gi');
|
|
597
|
+
snippet = snippet.replace(re, '\x1b[1m$1\x1b[0m');
|
|
598
|
+
}
|
|
599
|
+
const loc = r.heading ? ` ${r.heading}\n` : '';
|
|
600
|
+
process.stdout.write(` ${rank}. [cos ${r.cosine}] ${r.title}\n`);
|
|
601
|
+
if (loc) process.stdout.write(loc);
|
|
602
|
+
process.stdout.write(` ${snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Escape a string for use inside a RegExp. */
|
|
608
|
+
function escRe(s) {
|
|
609
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
140
610
|
}
|
|
141
611
|
|
|
142
612
|
function cmdModels() {
|
|
143
613
|
process.stdout.write('Available models (alias → id):\n\n');
|
|
144
614
|
for (const [alias, m] of Object.entries(MODELS)) {
|
|
145
615
|
const star = alias === DEFAULT_MODEL ? ' (default)' : '';
|
|
146
|
-
|
|
616
|
+
const pooling = m.pooling ?? 'mean';
|
|
617
|
+
const normalized = m.normalize !== false ? 'L2' : 'raw';
|
|
618
|
+
const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
|
|
619
|
+
process.stdout.write(
|
|
620
|
+
` ${alias}${star}\n` +
|
|
621
|
+
` ${m.id} · dim ${m.dim}${rev}\n` +
|
|
622
|
+
` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
|
|
623
|
+
(m.family ? `· family ${m.family}\n` : '\n') +
|
|
624
|
+
` ${m.note}\n\n`,
|
|
625
|
+
);
|
|
147
626
|
}
|
|
148
|
-
process.stdout.write(
|
|
627
|
+
process.stdout.write(
|
|
628
|
+
'Pass a registered id or alias to use a built-in adapter. A raw Hugging Face\n' +
|
|
629
|
+
'id is NOT guessed to be E5-compatible: it fails at embed time until an\n' +
|
|
630
|
+
'explicit adapter descriptor is supplied (see README "How to add a model").\n',
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function cmdServe(opts) {
|
|
635
|
+
const db = resolveDb(opts);
|
|
636
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
637
|
+
const cacheDir = resolveCache(opts);
|
|
638
|
+
const port = opts.port || Number(process.env.MDSS_PORT) || DEFAULT_PORT;
|
|
639
|
+
// Loopback by default — LAN exposure is opt-in via --host (issue #16).
|
|
640
|
+
const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
|
|
641
|
+
const log = s => process.stderr.write(s + '\n');
|
|
642
|
+
|
|
643
|
+
const { server, state, close } = await createServe({
|
|
644
|
+
db, indexDir, cacheDir,
|
|
645
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
646
|
+
ignore: opts.ignore,
|
|
647
|
+
offline: resolveOffline(opts),
|
|
648
|
+
watch: !!opts.watch,
|
|
649
|
+
watchInterval: opts.watchInterval,
|
|
650
|
+
watchDelay: opts.watchDelay,
|
|
651
|
+
log,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
server.listen(port, host, () => {
|
|
655
|
+
const idx = state.loaded.index;
|
|
656
|
+
log(`mdss serve listening on http://${host}:${port}` +
|
|
657
|
+
(host === '127.0.0.1' || host === '::1' ? ' (loopback only)' : ''));
|
|
658
|
+
log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
|
|
659
|
+
(state.watching ? ' · watching for changes' : ''));
|
|
660
|
+
log(` POST /search {"query": "...", "k": 6}`);
|
|
661
|
+
log(` GET /health`);
|
|
662
|
+
log(` Ctrl+C to stop.`);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const stop = async (sig) => {
|
|
666
|
+
log(`\n${sig} — shutting down.`);
|
|
667
|
+
await close();
|
|
668
|
+
process.exit(0);
|
|
669
|
+
};
|
|
670
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
671
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
149
672
|
}
|
|
150
673
|
|
|
151
674
|
async function main() {
|
|
@@ -153,13 +676,31 @@ async function main() {
|
|
|
153
676
|
const opts = parseArgs(argv);
|
|
154
677
|
const cmd = opts._.shift();
|
|
155
678
|
|
|
679
|
+
if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
|
|
156
680
|
if (opts.help || !cmd) { process.stdout.write(HELP); return; }
|
|
157
681
|
switch (cmd) {
|
|
158
682
|
case 'index': return cmdIndex(opts);
|
|
683
|
+
case 'stats': return cmdStats(opts);
|
|
684
|
+
case 'check':
|
|
685
|
+
case 'doctor': return cmdCheck(opts);
|
|
159
686
|
case 'search': return cmdSearch(opts);
|
|
687
|
+
case 'serve': return cmdServe(opts);
|
|
160
688
|
case 'models': return cmdModels();
|
|
161
689
|
default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
|
|
162
690
|
}
|
|
163
691
|
}
|
|
164
692
|
|
|
165
|
-
|
|
693
|
+
// Run only when executed directly (`node bin/cli.mjs`, `mdss`), never when
|
|
694
|
+
// imported by tests or library consumers (issue #29).
|
|
695
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
696
|
+
main().catch(e => die(e.message));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Exported for the CLI harness tests (issue #29): the pure argument/resolution
|
|
700
|
+
// functions are unit-tested directly; die() is asserted via a mocked
|
|
701
|
+
// process.exit so a bad flag cannot kill the test runner.
|
|
702
|
+
export {
|
|
703
|
+
parseArgs, nextInt, nextValue,
|
|
704
|
+
resolveDb, resolveIndexDir, resolveCache, resolveOffline,
|
|
705
|
+
die, main, HELP, VERSION,
|
|
706
|
+
};
|