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/src/serve.mjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Long-running daemon mode (issue #12): keeps the parsed index AND the
|
|
4
|
+
* embedding extractor in memory across queries, so repeated searches skip
|
|
5
|
+
* the ~280 MB model load and the full vectors.json parse on every call.
|
|
6
|
+
*
|
|
7
|
+
* mdss serve --db ./docs [--port 8747] [--host 127.0.0.1] [--watch]
|
|
8
|
+
*
|
|
9
|
+
* HTTP API (no auth — binds to loopback 127.0.0.1 by default; use --host to
|
|
10
|
+
* opt into LAN exposure, issue #16):
|
|
11
|
+
* POST /search {"query": "...", "k": 6, "semanticOnly": false} → {results: [...]}
|
|
12
|
+
* GET /health → {ok, chunks, model, dim, built, watching}
|
|
13
|
+
* GET / → endpoint list
|
|
14
|
+
*
|
|
15
|
+
* --watch polls the markdown base for mtime changes (no new deps) and runs the
|
|
16
|
+
* incremental re-index (chunk-level cache) when something changed.
|
|
17
|
+
*/
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { buildIndex } from './indexer.mjs';
|
|
23
|
+
import { loadIndex, searchIndex } from './search.mjs';
|
|
24
|
+
import { walkMarkdown } from './core.mjs';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PORT = 8747;
|
|
27
|
+
const DEFAULT_HOST = '127.0.0.1'; // loopback by default — LAN exposure is opt-in via --host
|
|
28
|
+
const WATCH_INTERVAL_MS = 3000;
|
|
29
|
+
const WATCH_DELAY_MS = 1000; // quiet-period debounce before a change fires a re-index (issue #42)
|
|
30
|
+
const MAX_BODY_BYTES = 64 * 1024; // POST /search body cap (DoS guard, issue #16)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} ServeState
|
|
34
|
+
* @property {ReturnType<typeof loadIndex>} loaded - parsed index + resolved model
|
|
35
|
+
* @property {string} indexDir
|
|
36
|
+
* @property {string} cacheDir
|
|
37
|
+
* @property {boolean} offline
|
|
38
|
+
* @property {Function} embedFn
|
|
39
|
+
* @property {Function} [rerankFn] - rerank override (tests)
|
|
40
|
+
* @property {boolean} watching
|
|
41
|
+
* @property {number} [reindexCount] - watch loop: how many re-indexes fired (tests)
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create the HTTP server (not yet listening). The index is built if missing,
|
|
46
|
+
* then loaded once into memory; every /search reuses it (issue #2).
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.indexDir
|
|
49
|
+
* @param {string} opts.cacheDir
|
|
50
|
+
* @param {string} [opts.db] - markdown base; required for --watch / auto-build
|
|
51
|
+
* @param {string} [opts.modelName='e5-base']
|
|
52
|
+
* @param {string[]} [opts.ignore=[]]
|
|
53
|
+
* @param {boolean} [opts.offline=false]
|
|
54
|
+
* @param {boolean} [opts.watch=false]
|
|
55
|
+
* @param {number} [opts.watchInterval=WATCH_INTERVAL_MS]
|
|
56
|
+
* @param {number} [opts.watchDelay=WATCH_DELAY_MS] - quiet-period debounce (issue #42)
|
|
57
|
+
* @param {Function} [opts.embedFn] - embed override (tests)
|
|
58
|
+
* @param {Function} [opts.rerankFn] - rerank override (tests)
|
|
59
|
+
* @param {(msg:string)=>void} [opts.log]
|
|
60
|
+
* @returns {Promise<{server: import('node:http').Server, state: ServeState, close: ()=>Promise<void>}>}
|
|
61
|
+
*/
|
|
62
|
+
export async function createServe(opts) {
|
|
63
|
+
const {
|
|
64
|
+
indexDir, cacheDir, db, modelName = 'e5-base', ignore = [],
|
|
65
|
+
offline = false, watch = false, watchInterval = WATCH_INTERVAL_MS,
|
|
66
|
+
watchDelay = WATCH_DELAY_MS,
|
|
67
|
+
embedFn, rerankFn, log = () => {},
|
|
68
|
+
} = opts;
|
|
69
|
+
|
|
70
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
71
|
+
if (!fs.existsSync(path.join(indexDir, 'vectors.json'))) {
|
|
72
|
+
if (!db) throw new Error('serve: no index found and no --db given to build one');
|
|
73
|
+
log(`No index at ${indexDir} — building it from ${db}…`);
|
|
74
|
+
await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @type {ServeState} */
|
|
78
|
+
const state = {
|
|
79
|
+
loaded: loadIndex(indexDir),
|
|
80
|
+
indexDir, cacheDir, offline, embedFn, rerankFn, watching: watch,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
handleRequest(req, res, state).catch(e => {
|
|
85
|
+
json(res, 500, { error: e.message });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let stopped = false;
|
|
90
|
+
let timer = null;
|
|
91
|
+
|
|
92
|
+
const reload = async () => {
|
|
93
|
+
if (!db) return;
|
|
94
|
+
log('Change detected — re-indexing…');
|
|
95
|
+
// buildIndex acquires the index lock itself (issue #37). When a manual
|
|
96
|
+
// `mdss index` (or another mdss process) holds it, we get a "locked by PID…"
|
|
97
|
+
// error — that's NOT a failure: we just yield this cycle and let the next
|
|
98
|
+
// watch poll pick the change up once the other writer is done.
|
|
99
|
+
try {
|
|
100
|
+
await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (/(being written by pid|being written|lock)/i.test(String(e?.message || ''))) {
|
|
103
|
+
log(`re-index deferred: ${e.message}`);
|
|
104
|
+
return false; // politely yield — next poll retries
|
|
105
|
+
}
|
|
106
|
+
throw e; // real error → caller's .catch logs it
|
|
107
|
+
}
|
|
108
|
+
state.loaded = loadIndex(indexDir);
|
|
109
|
+
state.reindexCount = (state.reindexCount ?? 0) + 1;
|
|
110
|
+
log(`Re-indexed; ${state.loaded.index.chunks.length} chunks in memory.`);
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// --- watch scanning (issue #42) ---
|
|
115
|
+
// Detection is anchored to the CONTENT the indexer last wrote, never to the
|
|
116
|
+
// poll-to-poll mtime delta. The authoritative per-file md5 lives in
|
|
117
|
+
// `.hashes.json` (keys are POSIX-relative paths, values are md5 of the file
|
|
118
|
+
// bytes as read by buildIndex). We compare the live tree against THAT
|
|
119
|
+
// baseline, so three subclasses are handled uniformly:
|
|
120
|
+
// (a) no-op writes (touch/chmod, an editor rewriting identical bytes) —
|
|
121
|
+
// mtime moved but md5 still equals the indexed hash → filtered out;
|
|
122
|
+
// (b) coarse-mtime filesystems (FAT, older SMB) where two real edits share
|
|
123
|
+
// one mtime window — mtime is a *hint to skip hashing*, never proof of
|
|
124
|
+
// "unchanged"; the md5 compare still catches the edit;
|
|
125
|
+
// (c) new / deleted files — a rel path absent from / present-only-in the
|
|
126
|
+
// indexed set.
|
|
127
|
+
// A settle debounce (`watchDelay`, default 1s) then collapses a burst of
|
|
128
|
+
// successive saves (editor save → linter → save) into ONE re-index: we only
|
|
129
|
+
// reload once the tree's fingerprint has been stable for the quiet period.
|
|
130
|
+
const fileMd5 = (f) => crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
|
|
131
|
+
const relOf = (f) => path.relative(db, f).split(path.sep).join('/');
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Content fingerprint of the live tree: sorted `rel:md5` pairs. Two polls that
|
|
135
|
+
* see the same tree content produce the same fingerprint — this is the settle
|
|
136
|
+
* signal. Each file resolves through `hashAt`, so an unchanged (path, mtime)
|
|
137
|
+
* is served from the cache and only a file whose mtime moved pays a re-hash.
|
|
138
|
+
*/
|
|
139
|
+
const treeContentFingerprint = (tree) => {
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const [f, { m, rel }] of tree) {
|
|
142
|
+
let md5;
|
|
143
|
+
try { md5 = hashAt(f, m); } catch { md5 = 'unreadable'; }
|
|
144
|
+
parts.push(`${rel}:${md5}`);
|
|
145
|
+
}
|
|
146
|
+
return parts.sort().join('|');
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** rel path → md5 of the content the indexer last persisted ({} when unreadable). */
|
|
150
|
+
const readIndexedHashes = () => {
|
|
151
|
+
try { return JSON.parse(fs.readFileSync(path.join(indexDir, '.hashes.json'), 'utf8')); }
|
|
152
|
+
catch { return {}; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Live tree snapshot: abs path → { m, rel }. Files that vanish mid-scan are skipped. */
|
|
156
|
+
const scanTree = () => {
|
|
157
|
+
const tree = new Map();
|
|
158
|
+
if (!db) return tree;
|
|
159
|
+
for (const f of walkMarkdown(db, ignore)) {
|
|
160
|
+
try { tree.set(f, { m: fs.statSync(f).mtimeMs, rel: relOf(f) }); }
|
|
161
|
+
catch { /* file vanished mid-scan */ }
|
|
162
|
+
}
|
|
163
|
+
return tree;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// md5s are cached per (path, mtime) so steady-state polls stay cheap; the
|
|
167
|
+
// cache is only a memo of "hash of the content at that mtime" and is never
|
|
168
|
+
// trusted across an mtime bump.
|
|
169
|
+
const md5Cache = new Map(); // abs path → { mtime, md5 }
|
|
170
|
+
const hashAt = (f, m) => {
|
|
171
|
+
const hit = md5Cache.get(f);
|
|
172
|
+
if (hit && hit.mtime === m) return hit.md5;
|
|
173
|
+
const md5 = fileMd5(f);
|
|
174
|
+
md5Cache.set(f, { mtime: m, md5 });
|
|
175
|
+
return md5;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* rel paths whose CONTENT differs from the INDEXED baseline. mtime is only a
|
|
180
|
+
* pre-filter to skip hashing a file that is unchanged AND already confirmed:
|
|
181
|
+
* the hash cache tells us "content at this mtime", the index tells us "content
|
|
182
|
+
* that is indexed". A file is reported changed iff its *current* content hash
|
|
183
|
+
* differs from the hash the indexer last persisted.
|
|
184
|
+
*/
|
|
185
|
+
const contentChangedPaths = (tree, indexedHashes) => {
|
|
186
|
+
const out = [];
|
|
187
|
+
const liveRels = new Set();
|
|
188
|
+
for (const [f, { m, rel }] of tree) {
|
|
189
|
+
liveRels.add(rel);
|
|
190
|
+
if (!(rel in indexedHashes)) { out.push(rel); continue; } // new file
|
|
191
|
+
const cached = md5Cache.get(f);
|
|
192
|
+
// Fast path: we have already hashed THIS (path, mtime) and it matches the index.
|
|
193
|
+
if (cached && cached.mtime === m && cached.md5 === indexedHashes[rel]) continue;
|
|
194
|
+
let fresh;
|
|
195
|
+
try { fresh = hashAt(f, m); } catch { continue; } // unreadable mid-write → next poll
|
|
196
|
+
if (fresh !== indexedHashes[rel]) out.push(rel); // real content change
|
|
197
|
+
// no-op write (mtime moved, content identical to the index): fall through,
|
|
198
|
+
// the cache now confirms content-at-this-mtime matches the baseline, so the
|
|
199
|
+
// NEXT poll with the same mtime takes the fast path and stays quiet.
|
|
200
|
+
}
|
|
201
|
+
for (const rel of Object.keys(indexedHashes)) {
|
|
202
|
+
if (!liveRels.has(rel)) out.push(rel); // deleted file
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const watchLoop = async () => {
|
|
208
|
+
// The baseline is what the INDEX contains, not what the last poll saw.
|
|
209
|
+
let indexedHashes = readIndexedHashes();
|
|
210
|
+
// NOTE: NO cache pre-seeding. The md5 cache is a memo of «content at this
|
|
211
|
+
// (path, mtime)», and seeding it from the INDEXED hashes would stamp a file
|
|
212
|
+
// we never read with the indexed value under a CURRENT mtime — the same
|
|
213
|
+
// false-clean that masks a deferred edit (the source of the watch-loop bug
|
|
214
|
+
// this fixed, issue #37). Unseeded first polls hash everything ONCE, then
|
|
215
|
+
// steady-state polls hit the cache on unchanged (path, mtime).
|
|
216
|
+
|
|
217
|
+
let lastFingerprint = treeContentFingerprint(scanTree()); // content baseline
|
|
218
|
+
let lastChangeAt = 0; // timestamp of the most recent content movement
|
|
219
|
+
let pending = false; // tree differs from the index AND has stopped moving
|
|
220
|
+
while (!stopped) {
|
|
221
|
+
await new Promise(r => { timer = setTimeout(r, watchInterval); });
|
|
222
|
+
if (stopped) return;
|
|
223
|
+
const tree = scanTree();
|
|
224
|
+
const fingerprint = treeContentFingerprint(tree);
|
|
225
|
+
const changed = contentChangedPaths(tree, indexedHashes);
|
|
226
|
+
if (fingerprint !== lastFingerprint) {
|
|
227
|
+
// Content moved since the last poll: a save (or several) is in flight.
|
|
228
|
+
// (Re)start the quiet window; do NOT reload yet — the burst may not be
|
|
229
|
+
// done (editor save → linter → save). A no-op touch does NOT move the
|
|
230
|
+
// fingerprint, so it can't even enter here.
|
|
231
|
+
if (!pending) log(`watch: change in ${changed.length} file(s) — waiting ${watchDelay} ms for the tree to settle…`);
|
|
232
|
+
pending = true;
|
|
233
|
+
lastFingerprint = fingerprint;
|
|
234
|
+
lastChangeAt = Date.now();
|
|
235
|
+
} else if (pending && changed.length > 0 && Date.now() - lastChangeAt >= watchDelay) {
|
|
236
|
+
// Tree content stable for the whole quiet window AND still differs from
|
|
237
|
+
// the index → the burst settled on real new content. Re-index ONCE.
|
|
238
|
+
pending = false;
|
|
239
|
+
await reload().catch(e => log(`re-index failed: ${e.message}`));
|
|
240
|
+
// Whether or not the build ran (it may have been DEFERRED on another
|
|
241
|
+
// writer's lock, issue #37): re-read the baseline. When deferred the
|
|
242
|
+
// baseline is unchanged, so the tree STILL differs from it — and the
|
|
243
|
+
// no-motion re-check below re-opens the window next poll.
|
|
244
|
+
indexedHashes = readIndexedHashes();
|
|
245
|
+
// Re-seed ONLY files we have never hashed — never overwrite a fresh
|
|
246
|
+
// hash under an unchanged mtime with the STALE indexed value (it would
|
|
247
|
+
// mask the deferred edit, and the very defer we just made needs that
|
|
248
|
+
// fresh hash to survive into the next poll).
|
|
249
|
+
for (const [f, { m, rel }] of scanTree()) {
|
|
250
|
+
if (!md5Cache.has(f) && rel in indexedHashes) {
|
|
251
|
+
md5Cache.set(f, { mtime: m, md5: indexedHashes[rel] });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
lastFingerprint = treeContentFingerprint(scanTree());
|
|
255
|
+
} else if (!pending && changed.length > 0 && fingerprint === lastFingerprint) {
|
|
256
|
+
// No motion this poll, yet the tree still differs from the index —
|
|
257
|
+
// the fingerprint/advanced-lock cycle closed (release after a defer,
|
|
258
|
+
// issue #37) or a coarse-mtime edit landed without moving the hash
|
|
259
|
+
// baseline. Open the quiet window so the deferred edit lands.
|
|
260
|
+
pending = true;
|
|
261
|
+
lastChangeAt = Date.now();
|
|
262
|
+
log(`watch: tree differs from index (no motion) — settling then re-index…`);
|
|
263
|
+
} else if (pending && changed.length === 0 && Date.now() - lastChangeAt < watchDelay) {
|
|
264
|
+
// Fingerprint stable AND content already equals the index — happens when
|
|
265
|
+
// a no-op write raced us into pending. Nothing to do; drop the pend.
|
|
266
|
+
pending = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (watch) {
|
|
272
|
+
if (!db) throw new Error('serve --watch requires --db');
|
|
273
|
+
watchLoop();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const close = async () => {
|
|
277
|
+
stopped = true;
|
|
278
|
+
if (timer) clearTimeout(timer);
|
|
279
|
+
await /** @type {Promise<void>} */ (new Promise((resolve, reject) =>
|
|
280
|
+
server.close(err => err ? reject(err) : resolve())));
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return { server, state, close };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {import('node:http').IncomingMessage} req
|
|
288
|
+
* @param {import('node:http').ServerResponse} res
|
|
289
|
+
* @param {ServeState} state
|
|
290
|
+
*/
|
|
291
|
+
async function handleRequest(req, res, state) {
|
|
292
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
293
|
+
|
|
294
|
+
if (req.method === 'POST' && url.pathname === '/search') {
|
|
295
|
+
// DoS guard (issue #16): honor a declared Content-Length, cap the streamed
|
|
296
|
+
// body at MAX_BODY_BYTES with 413, and never buffer unbounded input.
|
|
297
|
+
const declared = req.headers['content-length'];
|
|
298
|
+
if (declared !== undefined) {
|
|
299
|
+
const n = Number(declared);
|
|
300
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_BODY_BYTES) {
|
|
301
|
+
json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
let body = '';
|
|
306
|
+
let tooLarge = false;
|
|
307
|
+
for await (const chunk of req) {
|
|
308
|
+
body += chunk;
|
|
309
|
+
if (body.length > MAX_BODY_BYTES) { tooLarge = true; break; }
|
|
310
|
+
}
|
|
311
|
+
if (tooLarge) {
|
|
312
|
+
json(res, 413, { error: `payload too large (limit ${MAX_BODY_BYTES} bytes)` });
|
|
313
|
+
req.resume(); // drain the rest so the connection is not left half-open
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
/** @type {Record<string, any>} */
|
|
317
|
+
let payload = {};
|
|
318
|
+
try { payload = body ? JSON.parse(body) : {}; }
|
|
319
|
+
catch (e) {
|
|
320
|
+
json(res, 400, { error: `invalid JSON body: ${e.message}` });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
|
|
324
|
+
if (!query) { json(res, 400, { error: 'missing "query" string in JSON body' }); return; }
|
|
325
|
+
const k = Number.isInteger(payload.k) && payload.k > 0 ? payload.k : 6;
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const results = await searchIndex({
|
|
329
|
+
loaded: state.loaded,
|
|
330
|
+
cacheDir: state.cacheDir,
|
|
331
|
+
query,
|
|
332
|
+
k,
|
|
333
|
+
semanticOnly: !!payload.semanticOnly,
|
|
334
|
+
rerank: !!payload.rerank,
|
|
335
|
+
offline: state.offline,
|
|
336
|
+
embedFn: state.embedFn,
|
|
337
|
+
rerankFn: state.rerankFn,
|
|
338
|
+
tag: payload.tag,
|
|
339
|
+
project: payload.project,
|
|
340
|
+
type: payload.type,
|
|
341
|
+
status: payload.status,
|
|
342
|
+
canonicalOnly: payload.canonicalOnly,
|
|
343
|
+
custom: payload.custom,
|
|
344
|
+
explain: !!payload.explain,
|
|
345
|
+
maxPerFile: payload.maxPerFile || payload.maxPerDoc,
|
|
346
|
+
});
|
|
347
|
+
json(res, 200, { query, k, count: results.length, results });
|
|
348
|
+
} catch (e) {
|
|
349
|
+
json(res, 500, { error: e.message });
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
355
|
+
json(res, 200, {
|
|
356
|
+
ok: true,
|
|
357
|
+
chunks: state.loaded.index.chunks.length,
|
|
358
|
+
model: state.loaded.model.id,
|
|
359
|
+
dim: state.loaded.index.dim || state.loaded.model.dim || 0,
|
|
360
|
+
built: state.loaded.index.built || null,
|
|
361
|
+
watching: state.watching,
|
|
362
|
+
indexDir: state.indexDir,
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/help')) {
|
|
368
|
+
json(res, 200, {
|
|
369
|
+
name: 'mdss serve',
|
|
370
|
+
endpoints: [
|
|
371
|
+
{ method: 'POST', path: '/search', body: '{ "query": "...", "k": 6, "semanticOnly": false, "rerank": false }' },
|
|
372
|
+
{ method: 'GET', path: '/health' },
|
|
373
|
+
],
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
json(res, 404, { error: `not found: ${req.method} ${url.pathname}` });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {import('node:http').ServerResponse} res @param {number} status @param {object} data */
|
|
382
|
+
function json(res, status, data) {
|
|
383
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
384
|
+
res.end(JSON.stringify(data));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export { DEFAULT_PORT, DEFAULT_HOST, MAX_BODY_BYTES, WATCH_INTERVAL_MS };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Wikilink and backlink relationship extraction and resolution (issue #61).
|
|
4
|
+
* Supports Obsidian wikilinks [[note]], [[note#heading|alias]], and relative Markdown links.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} RawLink
|
|
9
|
+
* @property {'wikilink'|'markdown'} type
|
|
10
|
+
* @property {string} target
|
|
11
|
+
* @property {string} [anchor]
|
|
12
|
+
* @property {string} [label]
|
|
13
|
+
* @property {string} raw
|
|
14
|
+
* @property {number} line
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} ResolvedLink
|
|
19
|
+
* @property {string} raw
|
|
20
|
+
* @property {string} target
|
|
21
|
+
* @property {string} [resolvedFile]
|
|
22
|
+
* @property {'resolved'|'broken'|'ambiguous'} status
|
|
23
|
+
* @property {number} line
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract wikilinks and relative Markdown links from Markdown content.
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @returns {RawLink[]}
|
|
30
|
+
*/
|
|
31
|
+
export function extractLinks(text) {
|
|
32
|
+
if (!text) return [];
|
|
33
|
+
const lines = text.split(/\r?\n/);
|
|
34
|
+
const links = [];
|
|
35
|
+
|
|
36
|
+
// Match [[target#anchor|label]] or [[target|label]] or [[target]]
|
|
37
|
+
const wikiRegex = /\[\[([^\]\|#]+)(?:#([^\]\|]+))?(?:\|([^\]]+))?\]\]/g;
|
|
38
|
+
// Match relative Markdown links [label](./path/to/file.md) or [label](../path.md)
|
|
39
|
+
const mdLinkRegex = /\[([^\]]+)\]\(([^)]+\.md)(?:#([^)]+))?\)/g;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const lineNum = i + 1;
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
|
|
45
|
+
let match;
|
|
46
|
+
wikiRegex.lastIndex = 0;
|
|
47
|
+
while ((match = wikiRegex.exec(line)) !== null) {
|
|
48
|
+
links.push({
|
|
49
|
+
type: 'wikilink',
|
|
50
|
+
target: match[1].trim(),
|
|
51
|
+
anchor: match[2]?.trim(),
|
|
52
|
+
label: match[3]?.trim(),
|
|
53
|
+
raw: match[0],
|
|
54
|
+
line: lineNum,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mdLinkRegex.lastIndex = 0;
|
|
59
|
+
while ((match = mdLinkRegex.exec(line)) !== null) {
|
|
60
|
+
const rawTarget = match[2].trim();
|
|
61
|
+
if (!rawTarget.startsWith('http://') && !rawTarget.startsWith('https://')) {
|
|
62
|
+
links.push({
|
|
63
|
+
type: 'markdown',
|
|
64
|
+
target: rawTarget,
|
|
65
|
+
anchor: match[3]?.trim(),
|
|
66
|
+
label: match[1]?.trim(),
|
|
67
|
+
raw: match[0],
|
|
68
|
+
line: lineNum,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return links;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve extracted links against a map of documents (keyed by relative path/file).
|
|
79
|
+
* @param {RawLink[]} rawLinks
|
|
80
|
+
* @param {string} currentFile
|
|
81
|
+
* @param {Array<{file:string, title?:string, meta?:import('./frontmatter.mjs').DocumentMetadata}>} docs
|
|
82
|
+
* @returns {ResolvedLink[]}
|
|
83
|
+
*/
|
|
84
|
+
export function resolveLinks(rawLinks, currentFile, docs) {
|
|
85
|
+
if (!rawLinks || rawLinks.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
// Build lookups: titleMap, aliasMap, pathMap
|
|
88
|
+
const pathMap = new Map();
|
|
89
|
+
const titleMap = new Map();
|
|
90
|
+
const aliasMap = new Map();
|
|
91
|
+
|
|
92
|
+
for (const doc of docs) {
|
|
93
|
+
const relFile = doc.file;
|
|
94
|
+
pathMap.set(relFile.toLowerCase(), relFile);
|
|
95
|
+
const basename = relFile.replace(/\.md$/i, '').split('/').pop()?.toLowerCase();
|
|
96
|
+
if (basename && !pathMap.has(basename)) {
|
|
97
|
+
pathMap.set(basename, relFile);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (doc.title) {
|
|
101
|
+
const titleKey = doc.title.toLowerCase();
|
|
102
|
+
const existing = titleMap.get(titleKey) || [];
|
|
103
|
+
existing.push(relFile);
|
|
104
|
+
titleMap.set(titleKey, existing);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (doc.meta?.aliases) {
|
|
108
|
+
for (const alias of doc.meta.aliases) {
|
|
109
|
+
const aliasKey = alias.toLowerCase();
|
|
110
|
+
const existing = aliasMap.get(aliasKey) || [];
|
|
111
|
+
existing.push(relFile);
|
|
112
|
+
aliasMap.set(aliasKey, existing);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return rawLinks.map(link => {
|
|
118
|
+
const targetKey = link.target.toLowerCase().replace(/\.md$/i, '');
|
|
119
|
+
let matches = [];
|
|
120
|
+
|
|
121
|
+
if (pathMap.has(targetKey)) {
|
|
122
|
+
matches.push(pathMap.get(targetKey));
|
|
123
|
+
} else if (titleMap.has(targetKey)) {
|
|
124
|
+
matches = titleMap.get(targetKey);
|
|
125
|
+
} else if (aliasMap.has(targetKey)) {
|
|
126
|
+
matches = aliasMap.get(targetKey);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
matches = [...new Set(matches)];
|
|
130
|
+
|
|
131
|
+
if (matches.length === 1) {
|
|
132
|
+
return {
|
|
133
|
+
raw: link.raw,
|
|
134
|
+
target: link.target,
|
|
135
|
+
resolvedFile: matches[0],
|
|
136
|
+
status: 'resolved',
|
|
137
|
+
line: link.line,
|
|
138
|
+
};
|
|
139
|
+
} else if (matches.length > 1) {
|
|
140
|
+
return {
|
|
141
|
+
raw: link.raw,
|
|
142
|
+
target: link.target,
|
|
143
|
+
status: 'ambiguous',
|
|
144
|
+
line: link.line,
|
|
145
|
+
};
|
|
146
|
+
} else {
|
|
147
|
+
return {
|
|
148
|
+
raw: link.raw,
|
|
149
|
+
target: link.target,
|
|
150
|
+
status: 'broken',
|
|
151
|
+
line: link.line,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build graph of outgoing links and backlinks across documents.
|
|
159
|
+
* @param {Array<{file:string, title?:string, text:string, meta?:import('./frontmatter.mjs').DocumentMetadata}>} docs
|
|
160
|
+
*/
|
|
161
|
+
export function buildRelationshipGraph(docs) {
|
|
162
|
+
const outgoing = new Map();
|
|
163
|
+
const backlinks = new Map();
|
|
164
|
+
|
|
165
|
+
for (const doc of docs) {
|
|
166
|
+
if (!outgoing.has(doc.file)) outgoing.set(doc.file, []);
|
|
167
|
+
if (!backlinks.has(doc.file)) backlinks.set(doc.file, []);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const doc of docs) {
|
|
171
|
+
const rawLinks = extractLinks(doc.text);
|
|
172
|
+
const resolved = resolveLinks(rawLinks, doc.file, docs);
|
|
173
|
+
|
|
174
|
+
const docOutgoing = outgoing.get(doc.file) || [];
|
|
175
|
+
for (const r of resolved) {
|
|
176
|
+
if (r.status === 'resolved' && r.resolvedFile && r.resolvedFile !== doc.file) {
|
|
177
|
+
docOutgoing.push({ file: r.resolvedFile, raw: r.raw, line: r.line });
|
|
178
|
+
const targetBacklinks = backlinks.get(r.resolvedFile) || [];
|
|
179
|
+
targetBacklinks.push({ file: doc.file, raw: r.raw, line: r.line });
|
|
180
|
+
backlinks.set(r.resolvedFile, targetBacklinks);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
outgoing.set(doc.file, docOutgoing);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { outgoing, backlinks };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Traverse graph to find related notes.
|
|
191
|
+
* @param {{outgoing: Map<string, Array<{file:string}>>, backlinks: Map<string, Array<{file:string}>>}} graph
|
|
192
|
+
* @param {string} targetFile
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
* @param {'both'|'outgoing'|'backlinks'} [opts.direction='both']
|
|
195
|
+
* @param {number} [opts.depth=1]
|
|
196
|
+
* @returns {Array<{file:string, distance:number, direction:string}>}
|
|
197
|
+
*/
|
|
198
|
+
export function getRelatedNotes(graph, targetFile, opts = {}) {
|
|
199
|
+
const direction = opts.direction || 'both';
|
|
200
|
+
const maxDepth = opts.depth || 1;
|
|
201
|
+
|
|
202
|
+
const visited = new Set([targetFile]);
|
|
203
|
+
const results = [];
|
|
204
|
+
|
|
205
|
+
const queue = [{ file: targetFile, depth: 0 }];
|
|
206
|
+
|
|
207
|
+
while (queue.length > 0) {
|
|
208
|
+
const curr = queue.shift();
|
|
209
|
+
if (!curr) continue;
|
|
210
|
+
|
|
211
|
+
if (curr.depth >= maxDepth) continue;
|
|
212
|
+
|
|
213
|
+
const nextNodes = [];
|
|
214
|
+
|
|
215
|
+
if (direction === 'both' || direction === 'outgoing') {
|
|
216
|
+
const outList = graph.outgoing.get(curr.file) || [];
|
|
217
|
+
for (const item of outList) {
|
|
218
|
+
if (!visited.has(item.file)) {
|
|
219
|
+
visited.add(item.file);
|
|
220
|
+
results.push({ file: item.file, distance: curr.depth + 1, direction: 'outgoing' });
|
|
221
|
+
nextNodes.push({ file: item.file, depth: curr.depth + 1 });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (direction === 'both' || direction === 'backlinks') {
|
|
227
|
+
const backList = graph.backlinks.get(curr.file) || [];
|
|
228
|
+
for (const item of backList) {
|
|
229
|
+
if (!visited.has(item.file)) {
|
|
230
|
+
visited.add(item.file);
|
|
231
|
+
results.push({ file: item.file, distance: curr.depth + 1, direction: 'backlink' });
|
|
232
|
+
nextNodes.push({ file: item.file, depth: curr.depth + 1 });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
queue.push(...nextNodes);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return results;
|
|
241
|
+
}
|