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/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
+ }