markdown-lsp 1.1.0 → 1.2.1

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/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import { buildGraph } from "./graph.js";
4
4
  import { listPages, searchText, searchTextRanked, searchSymbols, searchPaths, } from "./bridge/index.js";
5
5
  const USAGE = `
6
- markdown-lsp v1.1.0 — CLI for querying Markdown documentation graphs
6
+ markdown-lsp v1.2.1 — CLI for querying Markdown documentation graphs
7
7
 
8
8
  USAGE
9
9
  markdown-lsp <subcommand> [options]
@@ -39,9 +39,19 @@ SUBCOMMANDS
39
39
  Retrieve a section by its anchor slug.
40
40
 
41
41
  graph <docs-dir> [--format json|dot|mermaid|html] [--out <file>]
42
+ [--semantic] [--sim-threshold <0-1>] [--sim-top-k <n>]
43
+ [--model <embedding-model>]
42
44
  Export the doc link graph. Default format: json (nodes/edges).
43
45
  Use --format html for a self-contained interactive D3 visualisation.
44
46
  Use --out <file> to write to disk instead of stdout.
47
+ Add --semantic to overlay AI-powered similarity edges (requires
48
+ OPENROUTER_API_KEY or AI_GATEWAY_API_KEY). Both link edges and
49
+ semantic edges are shown in the HTML graph with checkboxes to
50
+ toggle each type. Clicking a node opens a side-panel with full
51
+ info and highlights all its connections.
52
+ --sim-threshold Minimum cosine similarity for a semantic edge (default: 0.75)
53
+ --sim-top-k Maximum semantic neighbours per node (default: 5)
54
+ --model Embedding model override (default: openai/text-embedding-3-small)
45
55
 
46
56
  semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
47
57
  AI-powered semantic search using embeddings. Requires OPENROUTER_API_KEY
@@ -65,12 +75,45 @@ OUTPUT
65
75
  over stdio — it does NOT print JSON.
66
76
 
67
77
  EXAMPLES
78
+ # Overview of all pages
68
79
  markdown-lsp workspace-outline ./docs
69
- markdown-lsp search-text ./docs "getting started" --pretty
80
+
81
+ # Heading outline of one page
70
82
  markdown-lsp outline ./docs introduction.md
83
+
84
+ # Full-text search (natural-language, ranked)
85
+ markdown-lsp search-text ./docs "getting started"
86
+ markdown-lsp search-text ./docs "webhook signing" --mode verbatim --limit 5
87
+
88
+ # Fuzzy heading search
89
+ markdown-lsp search-symbols ./docs "webhook" --limit 10
90
+
91
+ # Find pages by filename glob
92
+ markdown-lsp search-paths ./docs "ai/*.md"
93
+
94
+ # Link graph
71
95
  markdown-lsp graph ./docs --format json --pretty
72
96
  markdown-lsp graph ./docs --format html --out graph.html
97
+ markdown-lsp graph ./docs --format dot | dot -Tsvg > graph.svg
98
+ markdown-lsp graph ./docs --format mermaid
99
+
100
+ # Build embeddings + interactive semantic graph in one command
101
+ markdown-lsp graph ./docs --format html --semantic --out graph.html
102
+ markdown-lsp graph ./docs --format html --semantic --sim-threshold 0.75 --sim-top-k 5 --out graph.html
103
+
104
+ # Backlinks / outgoing links
105
+ markdown-lsp links-to ./docs quick-start.md
106
+ markdown-lsp links-from ./docs README.md
107
+
108
+ # Resolve / read
109
+ markdown-lsp resolve-link ./docs README.md "Getting Started"
110
+ markdown-lsp get-section ./docs overview.md "quick-links"
111
+
112
+ # Semantic search (AI)
73
113
  markdown-lsp semantic-search ./docs "how to set up webhooks" --limit 5
114
+ markdown-lsp semantic-search ./docs "authentication" --model openai/text-embedding-3-small
115
+
116
+ # LSP server
74
117
  markdown-lsp lsp --stdio
75
118
  `.trim();
76
119
  function die(msg) {
@@ -80,43 +123,150 @@ function die(msg) {
80
123
  function out(value, pretty) {
81
124
  process.stdout.write((pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) + "\n");
82
125
  }
83
- async function startLsp() {
84
- await import("./lsp.js");
85
- }
86
126
  function buildGraphExport(raw) {
87
- const nodes = raw.pages.map((p) => ({
88
- id: p.path,
89
- title: p.title ?? p.path,
90
- charCount: p.content.length,
91
- sectionsCount: p.sections.length,
92
- }));
93
- const edges = raw.links
94
- .filter((l) => l.toResolvedPath !== null)
95
- .map((l) => ({
96
- source: l.fromPath,
97
- target: l.toResolvedPath,
98
- kind: l.kind,
99
- ...(l.textAtLink ? { label: l.textAtLink } : {}),
100
- }));
101
- return { nodes, edges, unresolvedCount: raw.unresolved.length };
127
+ // Build node map with full side-panel data
128
+ const nodeMap = new Map();
129
+ for (const p of raw.pages) {
130
+ nodeMap.set(p.path, {
131
+ id: p.path,
132
+ title: p.title ?? p.path,
133
+ charCount: p.content.length,
134
+ sectionsCount: p.sections.length,
135
+ sections: p.sections.map((s) => ({
136
+ anchor: s.anchor,
137
+ headingPath: s.headingPath,
138
+ level: s.level,
139
+ })),
140
+ outgoing: [],
141
+ incoming: [],
142
+ topSimilar: [],
143
+ });
144
+ }
145
+ const edges = [];
146
+ for (const l of raw.links) {
147
+ if (l.toResolvedPath === null)
148
+ continue;
149
+ edges.push({
150
+ source: l.fromPath,
151
+ target: l.toResolvedPath,
152
+ kind: l.kind,
153
+ ...(l.textAtLink ? { label: l.textAtLink } : {}),
154
+ });
155
+ nodeMap.get(l.fromPath)?.outgoing.push({
156
+ target: l.toResolvedPath,
157
+ label: l.textAtLink,
158
+ kind: l.kind,
159
+ });
160
+ nodeMap.get(l.toResolvedPath)?.incoming.push({
161
+ source: l.fromPath,
162
+ label: l.textAtLink,
163
+ kind: l.kind,
164
+ });
165
+ }
166
+ return {
167
+ nodes: Array.from(nodeMap.values()),
168
+ edges,
169
+ semanticEdges: [],
170
+ unresolvedCount: raw.unresolved.length,
171
+ };
172
+ }
173
+ // ── Cosine similarity (no deps) ───────────────────────────────────────────────
174
+ function cosineSim(a, b) {
175
+ let dot = 0, na = 0, nb = 0;
176
+ for (let i = 0; i < a.length; i++) {
177
+ dot += a[i] * b[i];
178
+ na += a[i] * a[i];
179
+ nb += b[i] * b[i];
180
+ }
181
+ if (na === 0 || nb === 0)
182
+ return 0;
183
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
184
+ }
185
+ // ── Semantic edge computation ─────────────────────────────────────────────────
186
+ async function addSemanticEdges(data, pages, modelOverride, simThreshold, simTopK) {
187
+ const { assertApiKey } = await import("./ai/config.js");
188
+ assertApiKey();
189
+ const { embedTexts } = await import("./ai/embeddings.js");
190
+ const pageTexts = pages.map((p) => {
191
+ const titlePart = p.title ? p.title + "\n\n" : "";
192
+ return titlePart + p.content.slice(0, 2000);
193
+ });
194
+ process.stderr.write(`[markdown-lsp] Embedding ${pages.length} pages` +
195
+ ` (model: ${modelOverride ?? "openai/text-embedding-3-small"})...\n`);
196
+ const { vectors, tokensUsed } = await embedTexts(pageTexts, modelOverride, true);
197
+ if (tokensUsed > 0) {
198
+ process.stderr.write(`[markdown-lsp] Embeddings computed (${tokensUsed} tokens used).\n`);
199
+ }
200
+ else {
201
+ process.stderr.write(`[markdown-lsp] Embeddings loaded from cache (0 API tokens).\n`);
202
+ }
203
+ const semanticEdges = [];
204
+ for (let i = 0; i < pages.length; i++) {
205
+ const vecI = vectors[i];
206
+ if (!vecI)
207
+ continue;
208
+ // Compute similarity to all other pages
209
+ const sims = [];
210
+ for (let j = 0; j < pages.length; j++) {
211
+ if (j === i)
212
+ continue;
213
+ const vecJ = vectors[j];
214
+ if (!vecJ)
215
+ continue;
216
+ const score = cosineSim(vecI, vecJ);
217
+ if (score >= simThreshold) {
218
+ sims.push({ j, score });
219
+ }
220
+ }
221
+ // topSimilar for side panel (no i<j constraint, full ranking)
222
+ const topSimilar = [...sims]
223
+ .sort((a, b) => b.score - a.score)
224
+ .slice(0, simTopK)
225
+ .map(({ j, score }) => ({
226
+ path: pages[j].path,
227
+ title: pages[j].title,
228
+ score: Math.round(score * 10000) / 10000,
229
+ }));
230
+ const node = data.nodes.find((n) => n.id === pages[i].path);
231
+ if (node)
232
+ node.topSimilar = topSimilar;
233
+ // Semantic edges: de-dup using i<j to avoid duplicates
234
+ for (const { j, score } of sims) {
235
+ if (j > i) {
236
+ semanticEdges.push({
237
+ source: pages[i].path,
238
+ target: pages[j].path,
239
+ score: Math.round(score * 10000) / 10000,
240
+ kind: "semantic",
241
+ });
242
+ }
243
+ }
244
+ }
245
+ data.semanticEdges = semanticEdges;
246
+ process.stderr.write(`[markdown-lsp] ${semanticEdges.length} semantic edges` +
247
+ ` (threshold=${simThreshold}, topK=${simTopK}).\n`);
102
248
  }
249
+ // ── Graph renderers ───────────────────────────────────────────────────────────
103
250
  function renderGraphJson(data, pretty) {
104
251
  return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
105
252
  }
106
253
  function renderGraphDot(data) {
107
254
  const lines = ['digraph G {', ' rankdir=LR;'];
108
- // Declare nodes with labels
109
255
  for (const n of data.nodes) {
110
256
  const label = n.title.replace(/"/g, '\\"');
111
257
  const id = n.id.replace(/"/g, '\\"');
112
258
  lines.push(` "${id}" [label="${label}"];`);
113
259
  }
114
- // Edges
115
260
  for (const e of data.edges) {
116
261
  const src = e.source.replace(/"/g, '\\"');
117
262
  const tgt = e.target.replace(/"/g, '\\"');
118
263
  lines.push(` "${src}" -> "${tgt}";`);
119
264
  }
265
+ for (const e of data.semanticEdges) {
266
+ const src = e.source.replace(/"/g, '\\"');
267
+ const tgt = e.target.replace(/"/g, '\\"');
268
+ lines.push(` "${src}" -> "${tgt}" [style=dashed color="#f59e0b" label="${e.score}"];`);
269
+ }
120
270
  lines.push('}');
121
271
  return lines.join('\n');
122
272
  }
@@ -127,9 +277,14 @@ function renderGraphMermaid(data) {
127
277
  const tgt = e.target.replace(/"/g, '\\"');
128
278
  lines.push(` "${src}" --> "${tgt}"`);
129
279
  }
280
+ for (const e of data.semanticEdges) {
281
+ const src = e.source.replace(/"/g, '\\"');
282
+ const tgt = e.target.replace(/"/g, '\\"');
283
+ lines.push(` "${src}" -. ${e.score} .-> "${tgt}"`);
284
+ }
130
285
  return lines.join('\n');
131
286
  }
132
- function renderGraphHtml(data, docsDir) {
287
+ function renderGraphHtml(data, docsDir, hasSemantic) {
133
288
  const title = docsDir.split('/').filter(Boolean).pop() ?? docsDir;
134
289
  const jsonData = JSON.stringify(data);
135
290
  return `<!DOCTYPE html>
@@ -143,167 +298,429 @@ function renderGraphHtml(data, docsDir) {
143
298
  body { font-family: system-ui, sans-serif; background: #0f1117; color: #e2e8f0; overflow: hidden; }
144
299
  #toolbar { position: fixed; top: 0; left: 0; right: 0; height: 44px; background: #1a1d27;
145
300
  border-bottom: 1px solid #2d3148; display: flex; align-items: center; padding: 0 16px;
146
- z-index: 10; gap: 12px; font-size: 13px; }
301
+ z-index: 10; gap: 16px; font-size: 13px; }
147
302
  #toolbar h1 { font-size: 14px; font-weight: 600; color: #a5b4fc; }
148
303
  #toolbar span { color: #64748b; }
149
- #graph { position: fixed; top: 44px; left: 0; right: 0; bottom: 0; }
304
+ .toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; font-size: 12px; }
305
+ .toggle-label input[type=checkbox] { accent-color: #a5b4fc; width: 14px; height: 14px; cursor: pointer; }
306
+ .toggle-links { color: #a5b4fc; }
307
+ .toggle-semantic { color: #f59e0b; }
308
+ .toggle-label.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
309
+ #graph-wrap { position: fixed; top: 44px; left: 0; bottom: 0; right: 0; transition: right 0.25s; }
310
+ #graph-wrap.panel-open { right: 320px; }
311
+ svg#graph { width: 100%; height: 100%; display: block; }
150
312
  #tooltip { position: fixed; pointer-events: none; background: #1e2130; border: 1px solid #3b4168;
151
313
  border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
152
314
  max-width: 280px; z-index: 20; display: none; }
153
315
  #tooltip .title { font-weight: 600; color: #c7d2fe; margin-bottom: 2px; }
154
316
  #tooltip .path { color: #64748b; font-size: 11px; }
155
317
  #tooltip .stats { color: #94a3b8; font-size: 11px; margin-top: 4px; }
318
+ #panel { position: fixed; top: 44px; right: 0; width: 320px; bottom: 0;
319
+ background: #1a1d27; border-left: 1px solid #2d3148; z-index: 15;
320
+ overflow-y: auto; display: none; }
321
+ #panel.open { display: block; }
322
+ #panel-header { padding: 14px 16px 10px; border-bottom: 1px solid #2d3148;
323
+ display: flex; align-items: flex-start; gap: 8px; }
324
+ #panel-close { background: none; border: none; color: #64748b; cursor: pointer;
325
+ font-size: 18px; line-height: 1; padding: 2px; flex-shrink: 0; margin-left: auto; }
326
+ #panel-close:hover { color: #e2e8f0; }
327
+ #panel-title { font-weight: 700; font-size: 14px; color: #c7d2fe; margin-bottom: 2px; word-break: break-all; }
328
+ #panel-path { font-size: 11px; color: #64748b; word-break: break-all; }
329
+ #panel-stats { font-size: 11px; color: #94a3b8; margin-top: 4px; }
330
+ #panel-body { padding: 12px 16px; }
331
+ .panel-section { margin-bottom: 16px; }
332
+ .panel-section h3 { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase;
333
+ letter-spacing: 0.05em; margin-bottom: 8px; }
334
+ .panel-section ul { list-style: none; }
335
+ .panel-section ul li { font-size: 12px; color: #94a3b8; padding: 3px 0; border-bottom: 1px solid #1e2130; }
336
+ .panel-section ul li:last-child { border-bottom: none; }
337
+ .panel-section ul li a { color: #a5b4fc; text-decoration: none; cursor: pointer; }
338
+ .panel-section ul li a:hover { text-decoration: underline; }
339
+ .panel-section ul li .score { font-size: 11px; color: #64748b; margin-left: 6px; }
340
+ .panel-section ul li .kind { font-size: 10px; color: #475569; margin-left: 4px; background: #1e2130;
341
+ padding: 1px 4px; border-radius: 3px; }
342
+ .panel-section .empty { color: #475569; font-size: 12px; font-style: italic; }
156
343
  .node circle { cursor: pointer; transition: r 0.15s; }
157
344
  .node text { pointer-events: none; font-size: 10px; fill: #cbd5e1; }
158
345
  .link { stroke: #3b4168; stroke-opacity: 0.6; fill: none; }
159
- .link.highlighted { stroke: #a5b4fc; stroke-opacity: 1; }
160
- .node.dimmed circle { opacity: 0.25; }
161
- .node.dimmed text { opacity: 0.15; }
346
+ .link.highlighted { stroke: #a5b4fc; stroke-opacity: 1; stroke-width: 1.5; }
347
+ .link-semantic { stroke: #f59e0b; stroke-opacity: 0.35; stroke-dasharray: 5,3; fill: none; }
348
+ .link-semantic.highlighted { stroke-opacity: 0.9; stroke-dasharray: none; }
349
+ .node.dimmed circle { opacity: 0.12; }
350
+ .node.dimmed text { opacity: 0.06; }
162
351
  .node.highlighted circle { stroke: #a5b4fc !important; stroke-width: 2px !important; }
352
+ .node.selected circle { stroke: #f59e0b !important; stroke-width: 2.5px !important; }
163
353
  </style>
164
354
  </head>
165
355
  <body>
166
356
  <div id="toolbar">
167
357
  <h1>Doc Graph — ${title}</h1>
168
358
  <span id="stats"></span>
359
+ <label class="toggle-label toggle-links" title="Toggle link edges (solid lines)">
360
+ <input type="checkbox" id="chk-links" checked> Links
361
+ </label>
362
+ <label class="toggle-label toggle-semantic${hasSemantic ? '' : ' disabled'}"
363
+ title="${hasSemantic ? 'Toggle semantic similarity edges (dashed lines)' : 'Run graph --semantic to enable'}">
364
+ <input type="checkbox" id="chk-semantic"${hasSemantic ? ' checked' : ' disabled'}> Semantic
365
+ </label>
366
+ </div>
367
+ <div id="graph-wrap">
368
+ <svg id="graph"></svg>
369
+ </div>
370
+ <div id="panel">
371
+ <div id="panel-header">
372
+ <div>
373
+ <div id="panel-title"></div>
374
+ <div id="panel-path"></div>
375
+ <div id="panel-stats"></div>
376
+ </div>
377
+ <button id="panel-close" title="Close panel">&#x2715;</button>
378
+ </div>
379
+ <div id="panel-body"></div>
169
380
  </div>
170
381
  <div id="tooltip"></div>
171
- <svg id="graph"></svg>
172
382
  <script>
173
383
  const DATA = ${jsonData};
384
+ const HAS_SEMANTIC = ${hasSemantic};
174
385
 
175
- const width = window.innerWidth;
176
- const height = window.innerHeight - 44;
177
- const svg = d3.select('#graph')
178
- .attr('width', width)
179
- .attr('height', height);
386
+ // Node map for fast lookup
387
+ const nodeById = new Map(DATA.nodes.map(n => [n.id, n]));
180
388
 
181
- // Build adjacency for highlight
182
- const adjacency = new Map();
183
- for (const n of DATA.nodes) adjacency.set(n.id, new Set());
389
+ // Build adjacency maps for highlight
390
+ const adjLink = new Map();
391
+ const adjSemantic = new Map();
392
+ for (const n of DATA.nodes) { adjLink.set(n.id, new Set()); adjSemantic.set(n.id, new Set()); }
184
393
  for (const e of DATA.edges) {
185
- if (adjacency.has(e.source)) adjacency.get(e.source).add(e.target);
186
- if (adjacency.has(e.target)) adjacency.get(e.target).add(e.source);
394
+ if (adjLink.has(e.source)) adjLink.get(e.source).add(e.target);
395
+ if (adjLink.has(e.target)) adjLink.get(e.target).add(e.source);
396
+ }
397
+ for (const e of DATA.semanticEdges) {
398
+ if (adjSemantic.has(e.source)) adjSemantic.get(e.source).add(e.target);
399
+ if (adjSemantic.has(e.target)) adjSemantic.get(e.target).add(e.source);
187
400
  }
188
401
 
402
+ const linkCount = DATA.edges.length;
403
+ const semCount = DATA.semanticEdges.length;
189
404
  document.getElementById('stats').textContent =
190
- DATA.nodes.length + ' pages · ' + DATA.edges.length + ' links · ' +
191
- DATA.unresolvedCount + ' unresolved';
405
+ DATA.nodes.length + ' pages · ' + linkCount + ' links' +
406
+ (semCount > 0 ? ' · ' + semCount + ' semantic' : '') +
407
+ ' · ' + DATA.unresolvedCount + ' unresolved';
408
+
409
+ const graphWrap = document.getElementById('graph-wrap');
410
+ function wrapW() { return graphWrap.offsetWidth || window.innerWidth; }
411
+ function wrapH() { return graphWrap.offsetHeight || (window.innerHeight - 44); }
412
+
413
+ const svg = d3.select('svg#graph');
192
414
 
193
415
  const g = svg.append('g');
194
416
 
195
- // Zoom
417
+ // Zoom + click-on-background
196
418
  svg.call(d3.zoom()
197
419
  .scaleExtent([0.05, 4])
198
420
  .on('zoom', (event) => g.attr('transform', event.transform)));
199
421
 
200
- // Links
422
+ svg.on('click', (event) => {
423
+ if (event.target.tagName === 'svg' || event.target.tagName === 'rect') {
424
+ clearHighlight();
425
+ closePanel();
426
+ }
427
+ });
428
+
429
+ // Arrow markers
430
+ const defs = svg.append('defs');
431
+ defs.append('marker').attr('id','arrow')
432
+ .attr('viewBox','0 -4 8 8').attr('refX',14).attr('refY',0)
433
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
434
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#3b4168');
435
+ defs.append('marker').attr('id','arrow-sem')
436
+ .attr('viewBox','0 -4 8 8').attr('refX',14).attr('refY',0)
437
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
438
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#f59e0b');
439
+
440
+ // Semantic edges (behind links)
441
+ const semanticLinkSel = g.append('g').selectAll('line')
442
+ .data(DATA.semanticEdges)
443
+ .join('line')
444
+ .attr('class','link-semantic')
445
+ .attr('stroke-width', d => Math.max(0.8, d.score * 2.5))
446
+ .attr('marker-end','url(#arrow-sem)');
447
+
448
+ // Link edges
201
449
  const linkSel = g.append('g').selectAll('line')
202
450
  .data(DATA.edges)
203
451
  .join('line')
204
- .attr('class', 'link')
205
- .attr('marker-end', 'url(#arrow)');
206
-
207
- // Arrow marker
208
- svg.append('defs').append('marker')
209
- .attr('id', 'arrow')
210
- .attr('viewBox', '0 -4 8 8')
211
- .attr('refX', 14)
212
- .attr('refY', 0)
213
- .attr('markerWidth', 6)
214
- .attr('markerHeight', 6)
215
- .attr('orient', 'auto')
216
- .append('path')
217
- .attr('d', 'M0,-4L8,0L0,4')
218
- .attr('fill', '#3b4168');
219
-
220
- // Color scale by first path segment
452
+ .attr('class','link')
453
+ .attr('marker-end','url(#arrow)');
454
+
455
+ // Color by first path segment
221
456
  const color = d3.scaleOrdinal(d3.schemeTableau10);
222
- const segColor = (id) => color(id.split('/')[0] ?? '');
457
+ const segColor = id => color(id.split('/')[0] ?? '');
458
+
459
+ let selectedNodeId = null;
223
460
 
224
461
  // Nodes
225
462
  const nodeSel = g.append('g').selectAll('g')
226
463
  .data(DATA.nodes)
227
464
  .join('g')
228
- .attr('class', 'node')
465
+ .attr('class','node')
229
466
  .call(d3.drag()
230
- .on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
231
- .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
232
- .on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
467
+ .on('start',(ev,d)=>{ if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
468
+ .on('drag',(ev,d)=>{ d.fx=ev.x; d.fy=ev.y; })
469
+ .on('end',(ev,d)=>{ if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
233
470
 
234
471
  nodeSel.append('circle')
235
- .attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount / 120))))
472
+ .attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))))
236
473
  .attr('fill', d => segColor(d.id))
237
474
  .attr('fill-opacity', 0.85)
238
475
  .attr('stroke', d => segColor(d.id))
239
476
  .attr('stroke-width', 1);
240
477
 
241
478
  nodeSel.append('text')
242
- .attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount / 120))) + 3)
479
+ .attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))) + 3)
243
480
  .attr('dy', '0.35em')
244
- .text(d => d.title.length > 24 ? d.title.slice(0, 22) + '' : d.title);
481
+ .text(d => d.title.length > 24 ? d.title.slice(0,22) + '\\u2026' : d.title);
245
482
 
246
483
  // Tooltip
247
484
  const tooltip = document.getElementById('tooltip');
248
485
  nodeSel
249
- .on('mouseover', (event, d) => {
486
+ .on('mouseover', (ev,d) => {
250
487
  tooltip.style.display = 'block';
251
- tooltip.innerHTML = '<div class="title">' + d.title + '</div>' +
252
- '<div class="path">' + d.id + '</div>' +
253
- '<div class="stats">' + d.charCount.toLocaleString() + ' chars · ' + d.sectionsCount + ' sections</div>';
254
- highlightNode(d);
488
+ tooltip.innerHTML =
489
+ '<div class="title">'+esc(d.title)+'</div>'+
490
+ '<div class="path">'+esc(d.id)+'</div>'+
491
+ '<div class="stats">'+d.charCount.toLocaleString()+' chars · '+d.sectionsCount+' sections</div>';
492
+ if (!selectedNodeId) highlightNode(d);
255
493
  })
256
- .on('mousemove', (event) => {
257
- tooltip.style.left = (event.clientX + 14) + 'px';
258
- tooltip.style.top = (event.clientY + 14) + 'px';
494
+ .on('mousemove', ev => {
495
+ tooltip.style.left = (ev.clientX+14)+'px';
496
+ tooltip.style.top = (ev.clientY+14)+'px';
259
497
  })
260
498
  .on('mouseout', () => {
261
- tooltip.style.display = 'none';
262
- clearHighlight();
499
+ tooltip.style.display='none';
500
+ if (!selectedNodeId) clearHighlight();
501
+ })
502
+ .on('click', (ev,d) => {
503
+ ev.stopPropagation();
504
+ if (selectedNodeId === d.id) {
505
+ selectedNodeId = null;
506
+ clearHighlight();
507
+ closePanel();
508
+ } else {
509
+ selectedNodeId = d.id;
510
+ highlightNode(d);
511
+ openPanel(d);
512
+ }
263
513
  });
264
514
 
515
+ function esc(s) {
516
+ return String(s)
517
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
518
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
519
+ }
520
+
265
521
  function highlightNode(d) {
266
- const neighbors = adjacency.get(d.id) ?? new Set();
267
- nodeSel.classed('dimmed', n => n.id !== d.id && !neighbors.has(n.id));
268
- nodeSel.classed('highlighted', n => n.id === d.id);
269
- linkSel.classed('highlighted', e => e.source.id === d.id || e.target.id === d.id);
522
+ const ln = adjLink.get(d.id) ?? new Set();
523
+ const sn = adjSemantic.get(d.id) ?? new Set();
524
+ const all = new Set([...ln, ...sn]);
525
+ nodeSel
526
+ .classed('dimmed', n => n.id !== d.id && !all.has(n.id))
527
+ .classed('highlighted',n => n.id !== d.id && all.has(n.id))
528
+ .classed('selected', n => n.id === d.id);
529
+ linkSel.classed('highlighted',
530
+ e => e.source.id === d.id || e.target.id === d.id);
531
+ semanticLinkSel.classed('highlighted',
532
+ e => e.source.id === d.id || e.target.id === d.id);
270
533
  }
271
534
 
272
535
  function clearHighlight() {
273
- nodeSel.classed('dimmed', false).classed('highlighted', false);
274
- linkSel.classed('highlighted', false);
536
+ selectedNodeId = null;
537
+ nodeSel.classed('dimmed',false).classed('highlighted',false).classed('selected',false);
538
+ linkSel.classed('highlighted',false);
539
+ semanticLinkSel.classed('highlighted',false);
540
+ }
541
+
542
+ // ── Side panel ────────────────────────────────────────────────────────────────
543
+
544
+ const panel = document.getElementById('panel');
545
+ const panelTitle = document.getElementById('panel-title');
546
+ const panelPath = document.getElementById('panel-path');
547
+ const panelStats = document.getElementById('panel-stats');
548
+ const panelBody = document.getElementById('panel-body');
549
+
550
+ document.getElementById('panel-close').addEventListener('click', () => {
551
+ clearHighlight(); closePanel();
552
+ });
553
+
554
+ function openPanel(d) {
555
+ panelTitle.textContent = d.title;
556
+ panelPath.textContent = d.id;
557
+ panelStats.textContent = d.charCount.toLocaleString()+' chars · '+d.sectionsCount+' sections';
558
+ panelBody.innerHTML = '';
559
+
560
+ // Sections
561
+ if (d.sections && d.sections.length > 0) {
562
+ const sec = ps('Sections ('+d.sections.length+')');
563
+ const ul = document.createElement('ul');
564
+ const shown = d.sections.slice(0,10);
565
+ for (const s of shown) {
566
+ const li = document.createElement('li');
567
+ const label = s.headingPath.length > 0
568
+ ? s.headingPath[s.headingPath.length-1]
569
+ : (s.anchor ?? '(section)');
570
+ li.innerHTML = esc(label)+(s.anchor ? ' <span class="kind">#'+esc(s.anchor)+'</span>' : '');
571
+ ul.appendChild(li);
572
+ }
573
+ if (d.sections.length > 10) {
574
+ const li = document.createElement('li');
575
+ li.className = 'empty';
576
+ li.textContent = '… and '+(d.sections.length-10)+' more';
577
+ ul.appendChild(li);
578
+ }
579
+ sec.appendChild(ul); panelBody.appendChild(sec);
580
+ }
581
+
582
+ // Outgoing links
583
+ {
584
+ const out = d.outgoing || [];
585
+ const sec = ps('Links from this page'+(out.length ? ' ('+out.length+')' : ''));
586
+ if (out.length > 0) {
587
+ const ul = document.createElement('ul');
588
+ for (const o of out.slice(0,15)) {
589
+ const li = document.createElement('li');
590
+ const a = document.createElement('a');
591
+ a.textContent = (o.label && o.label !== o.target) ? o.label : o.target;
592
+ a.title = o.target;
593
+ a.addEventListener('click', ()=>focusNode(o.target));
594
+ li.appendChild(a);
595
+ const sp = document.createElement('span');
596
+ sp.className = 'kind'; sp.textContent = o.kind;
597
+ li.appendChild(sp);
598
+ ul.appendChild(li);
599
+ }
600
+ if (out.length > 15) {
601
+ const li = document.createElement('li');
602
+ li.className = 'empty';
603
+ li.textContent = '… and '+(out.length-15)+' more';
604
+ ul.appendChild(li);
605
+ }
606
+ sec.appendChild(ul);
607
+ } else {
608
+ sec.appendChild(empty('None'));
609
+ }
610
+ panelBody.appendChild(sec);
611
+ }
612
+
613
+ // Incoming links
614
+ {
615
+ const inc = d.incoming || [];
616
+ const sec = ps('Pages linking here'+(inc.length ? ' ('+inc.length+')' : ''));
617
+ if (inc.length > 0) {
618
+ const ul = document.createElement('ul');
619
+ for (const i of inc.slice(0,15)) {
620
+ const li = document.createElement('li');
621
+ const a = document.createElement('a');
622
+ a.textContent = i.source; a.title = i.source;
623
+ a.addEventListener('click', ()=>focusNode(i.source));
624
+ li.appendChild(a);
625
+ ul.appendChild(li);
626
+ }
627
+ if (inc.length > 15) {
628
+ const li = document.createElement('li');
629
+ li.className = 'empty';
630
+ li.textContent = '… and '+(inc.length-15)+' more';
631
+ ul.appendChild(li);
632
+ }
633
+ sec.appendChild(ul);
634
+ } else {
635
+ sec.appendChild(empty('None (orphan page)'));
636
+ }
637
+ panelBody.appendChild(sec);
638
+ }
639
+
640
+ // Semantic similar
641
+ if (HAS_SEMANTIC) {
642
+ const sim2 = d.topSimilar || [];
643
+ const sec = ps('Semantically similar'+(sim2.length ? ' ('+sim2.length+')' : ''));
644
+ if (sim2.length > 0) {
645
+ const ul = document.createElement('ul');
646
+ for (const s of sim2) {
647
+ const li = document.createElement('li');
648
+ const a = document.createElement('a');
649
+ a.textContent = s.title ?? s.path; a.title = s.path;
650
+ a.addEventListener('click', ()=>focusNode(s.path));
651
+ li.appendChild(a);
652
+ const sp = document.createElement('span');
653
+ sp.className = 'score'; sp.textContent = s.score.toFixed(3);
654
+ li.appendChild(sp);
655
+ ul.appendChild(li);
656
+ }
657
+ sec.appendChild(ul);
658
+ } else {
659
+ sec.appendChild(empty('No pages above threshold'));
660
+ }
661
+ panelBody.appendChild(sec);
662
+ }
663
+
664
+ panel.classList.add('open');
665
+ graphWrap.classList.add('panel-open');
666
+ if (sim) sim.force('center', d3.forceCenter(wrapW()/2, wrapH()/2)).alpha(0.1).restart();
667
+ }
668
+
669
+ function closePanel() {
670
+ panel.classList.remove('open');
671
+ graphWrap.classList.remove('panel-open');
672
+ if (sim) sim.force('center', d3.forceCenter(wrapW()/2, wrapH()/2)).alpha(0.1).restart();
673
+ }
674
+
675
+ function focusNode(nodeId) {
676
+ const node = DATA.nodes.find(n => n.id === nodeId);
677
+ if (!node) return;
678
+ selectedNodeId = nodeId;
679
+ highlightNode(node);
680
+ openPanel(node);
681
+ }
682
+
683
+ function ps(heading) {
684
+ const div = document.createElement('div'); div.className = 'panel-section';
685
+ const h3 = document.createElement('h3'); h3.textContent = heading;
686
+ div.appendChild(h3); return div;
687
+ }
688
+ function empty(text) {
689
+ const p = document.createElement('p'); p.className = 'empty'; p.textContent = text; return p;
275
690
  }
276
691
 
277
- // Force simulation
692
+ // ── Checkbox toggles ──────────────────────────────────────────────────────────
693
+
694
+ document.getElementById('chk-links').addEventListener('change', ev => {
695
+ linkSel.style('display', ev.target.checked ? null : 'none');
696
+ });
697
+ document.getElementById('chk-semantic').addEventListener('change', ev => {
698
+ semanticLinkSel.style('display', ev.target.checked ? null : 'none');
699
+ });
700
+
701
+ // ── Force simulation ──────────────────────────────────────────────────────────
702
+
703
+ const allSimEdges = [...DATA.edges, ...DATA.semanticEdges];
704
+
278
705
  const sim = d3.forceSimulation(DATA.nodes)
279
- .force('link', d3.forceLink(DATA.edges).id(d => d.id).distance(80).strength(0.5))
706
+ .force('link', d3.forceLink(allSimEdges).id(d => d.id)
707
+ .distance(80).strength(e => e.kind === 'semantic' ? 0.2 : 0.5))
280
708
  .force('charge', d3.forceManyBody().strength(-180))
281
- .force('center', d3.forceCenter(width / 2, height / 2))
709
+ .force('center', d3.forceCenter(wrapW()/2, wrapH()/2))
282
710
  .force('collide', d3.forceCollide(18))
283
711
  .on('tick', () => {
284
712
  linkSel
285
- .attr('x1', d => d.source.x)
286
- .attr('y1', d => d.source.y)
287
- .attr('x2', d => d.target.x)
288
- .attr('y2', d => d.target.y);
289
- nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
713
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
714
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
715
+ semanticLinkSel
716
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
717
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
718
+ nodeSel.attr('transform', d => 'translate('+d.x+','+d.y+')');
290
719
  });
291
720
  </script>
292
721
  </body>
293
722
  </html>`;
294
723
  }
295
- // ── Cosine similarity (no deps) ───────────────────────────────────────────────
296
- function cosineSim(a, b) {
297
- let dot = 0, na = 0, nb = 0;
298
- for (let i = 0; i < a.length; i++) {
299
- dot += a[i] * b[i];
300
- na += a[i] * a[i];
301
- nb += b[i] * b[i];
302
- }
303
- if (na === 0 || nb === 0)
304
- return 0;
305
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
306
- }
307
724
  // ── Main ──────────────────────────────────────────────────────────────────────
308
725
  async function startLspServer() {
309
726
  await import("./lsp.js");
@@ -331,11 +748,239 @@ async function main() {
331
748
  process.stdout.write(USAGE + "\n");
332
749
  process.exit(0);
333
750
  }
334
- // LSP subcommand
751
+ // LSP subcommand — check for --help/-h before starting the server
335
752
  if (subcommand === "lsp" || subcommand === "serve") {
753
+ if (rest.includes("--help") || rest.includes("-h")) {
754
+ process.stdout.write(`lsp [--stdio]\nserve [--stdio]\n\n Start the LSP stdio server for editor integration.\n\n Options:\n --stdio Use stdio transport (default and recommended)\n\n Note: back-compat — --stdio | --node-ipc | --socket=<n> as first arg\n also starts the LSP server.\n\n Examples:\n markdown-lsp lsp --stdio\n markdown-lsp serve --stdio\n`);
755
+ process.exit(0);
756
+ }
336
757
  await startLspServer();
337
758
  return;
338
759
  }
760
+ // Per-subcommand help strings
761
+ const SUB_USAGE = {
762
+ "workspace-outline": `
763
+ workspace-outline <docs-dir> [--prefix <p>] [--limit <n>]
764
+
765
+ List all pages in the workspace with metadata.
766
+
767
+ Arguments:
768
+ <docs-dir> Path to the documentation directory
769
+
770
+ Options:
771
+ --prefix <p> Filter pages whose path starts with <p>
772
+ --limit <n> Return at most <n> results
773
+ --pretty Pretty-print JSON output
774
+
775
+ Examples:
776
+ markdown-lsp workspace-outline ./docs
777
+ markdown-lsp workspace-outline ./docs --prefix api/ --limit 20
778
+ `.trim(),
779
+ "outline": `
780
+ outline <docs-dir> <page>
781
+
782
+ Show the heading outline of a single page.
783
+
784
+ Arguments:
785
+ <docs-dir> Path to the documentation directory
786
+ <page> Relative path to the page (e.g. introduction.md)
787
+
788
+ Options:
789
+ --pretty Pretty-print JSON output
790
+
791
+ Examples:
792
+ markdown-lsp outline ./docs introduction.md
793
+ `.trim(),
794
+ "search-text": `
795
+ search-text <docs-dir> <query> [--mode ranked|verbatim] [--regex]
796
+ [--case-sensitive] [--prefix <p>] [--limit <n>] [--context <n>]
797
+
798
+ Full-text search across all pages.
799
+
800
+ Arguments:
801
+ <docs-dir> Path to the documentation directory
802
+ <query> Search query (natural language or pattern)
803
+
804
+ Options:
805
+ --mode ranked Natural-language ranked search (default)
806
+ --mode verbatim Exact substring search
807
+ --regex Treat query as a regular expression
808
+ --case-sensitive Case-sensitive matching
809
+ --prefix <p> Filter pages whose path starts with <p>
810
+ --limit <n> Return at most <n> results
811
+ --context <n> Characters of context around each match
812
+ --pretty Pretty-print JSON output
813
+
814
+ Examples:
815
+ markdown-lsp search-text ./docs "getting started"
816
+ markdown-lsp search-text ./docs "webhook signing" --mode verbatim --limit 5
817
+ markdown-lsp search-text ./docs "auth.*token" --regex
818
+ `.trim(),
819
+ "search-symbols": `
820
+ search-symbols <docs-dir> <query> [--limit <n>]
821
+
822
+ Fuzzy subsequence search across all headings.
823
+
824
+ Arguments:
825
+ <docs-dir> Path to the documentation directory
826
+ <query> Heading search query
827
+
828
+ Options:
829
+ --limit <n> Return at most <n> results
830
+ --pretty Pretty-print JSON output
831
+
832
+ Examples:
833
+ markdown-lsp search-symbols ./docs "webhook" --limit 10
834
+ `.trim(),
835
+ "search-paths": `
836
+ search-paths <docs-dir> <glob>
837
+
838
+ List pages whose paths match a glob pattern (*, **, ?).
839
+
840
+ Arguments:
841
+ <docs-dir> Path to the documentation directory
842
+ <glob> Glob pattern to match against page paths
843
+
844
+ Options:
845
+ --pretty Pretty-print JSON output
846
+
847
+ Examples:
848
+ markdown-lsp search-paths ./docs "ai/*.md"
849
+ markdown-lsp search-paths ./docs "**/*auth*"
850
+ `.trim(),
851
+ "links-to": `
852
+ links-to <docs-dir> <page>
853
+
854
+ Show all pages that link to <page>.
855
+
856
+ Arguments:
857
+ <docs-dir> Path to the documentation directory
858
+ <page> Relative path to the target page
859
+
860
+ Options:
861
+ --pretty Pretty-print JSON output
862
+
863
+ Examples:
864
+ markdown-lsp links-to ./docs quick-start.md
865
+ `.trim(),
866
+ "links-from": `
867
+ links-from <docs-dir> <page>
868
+
869
+ Show all links that originate from <page>.
870
+
871
+ Arguments:
872
+ <docs-dir> Path to the documentation directory
873
+ <page> Relative path to the source page
874
+
875
+ Options:
876
+ --pretty Pretty-print JSON output
877
+
878
+ Examples:
879
+ markdown-lsp links-from ./docs README.md
880
+ `.trim(),
881
+ "resolve-link": `
882
+ resolve-link <docs-dir> <from-page> <link-text>
883
+
884
+ Resolve a specific link text from a given page.
885
+
886
+ Arguments:
887
+ <docs-dir> Path to the documentation directory
888
+ <from-page> Relative path to the source page
889
+ <link-text> Exact link text to resolve
890
+
891
+ Options:
892
+ --pretty Pretty-print JSON output
893
+
894
+ Examples:
895
+ markdown-lsp resolve-link ./docs README.md "Getting Started"
896
+ `.trim(),
897
+ "get-section": `
898
+ get-section <docs-dir> <page> <anchor>
899
+
900
+ Retrieve a section by its anchor slug.
901
+
902
+ Arguments:
903
+ <docs-dir> Path to the documentation directory
904
+ <page> Relative path to the page
905
+ <anchor> Section anchor slug (e.g. "quick-links")
906
+
907
+ Options:
908
+ --pretty Pretty-print JSON output
909
+
910
+ Examples:
911
+ markdown-lsp get-section ./docs overview.md "quick-links"
912
+ `.trim(),
913
+ "graph": `
914
+ graph <docs-dir> [--format json|dot|mermaid|html] [--out <file>]
915
+ [--semantic] [--sim-threshold <0-1>] [--sim-top-k <n>]
916
+ [--model <embedding-model>]
917
+
918
+ Export the documentation link graph.
919
+
920
+ Arguments:
921
+ <docs-dir> Path to the documentation directory
922
+
923
+ Options:
924
+ --format json JSON output with nodes/edges (default)
925
+ --format dot Graphviz DOT format
926
+ --format mermaid Mermaid diagram format
927
+ --format html Self-contained interactive D3 visualisation
928
+ --out <file> Write output to file instead of stdout
929
+ --semantic Overlay AI-powered similarity edges
930
+ --sim-threshold <0-1> Minimum cosine similarity (default: 0.75)
931
+ --sim-top-k <n> Max semantic neighbours per node (default: 5)
932
+ --model <model> Embedding model override
933
+ --pretty Pretty-print JSON output
934
+
935
+ Note: --semantic requires OPENROUTER_API_KEY or AI_GATEWAY_API_KEY.
936
+
937
+ Examples:
938
+ markdown-lsp graph ./docs --format json --pretty
939
+ markdown-lsp graph ./docs --format html --out graph.html
940
+ markdown-lsp graph ./docs --format dot | dot -Tsvg > graph.svg
941
+ markdown-lsp graph ./docs --format html --semantic --out graph.html
942
+ `.trim(),
943
+ "semantic-search": `
944
+ semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
945
+
946
+ AI-powered semantic search using embeddings.
947
+
948
+ Arguments:
949
+ <docs-dir> Path to the documentation directory
950
+ <query> Natural-language search query
951
+
952
+ Options:
953
+ --limit <n> Return at most <n> results (default: 10)
954
+ --model <m> Embedding model override
955
+ --pretty Pretty-print JSON output
956
+
957
+ Note: requires OPENROUTER_API_KEY or AI_GATEWAY_API_KEY.
958
+ Default embedding model: openai/text-embedding-3-small.
959
+ Results are cached in .markdown-lsp-cache/.
960
+
961
+ Examples:
962
+ markdown-lsp semantic-search ./docs "how to set up webhooks" --limit 5
963
+ markdown-lsp semantic-search ./docs "authentication" --model openai/text-embedding-3-small
964
+ `.trim(),
965
+ "lsp": `
966
+ lsp [--stdio]
967
+ serve [--stdio]
968
+
969
+ Start the LSP stdio server for editor integration.
970
+
971
+ Options:
972
+ --stdio Use stdio transport (default and recommended)
973
+
974
+ Note: back-compat — --stdio | --node-ipc | --socket=<n> as first arg
975
+ also starts the LSP server.
976
+
977
+ Examples:
978
+ markdown-lsp lsp --stdio
979
+ markdown-lsp serve --stdio
980
+ `.trim(),
981
+ };
982
+ // Alias: "serve" shares lsp help
983
+ SUB_USAGE["serve"] = SUB_USAGE["lsp"];
339
984
  // Parse global flags from the remaining args
340
985
  let pretty = false;
341
986
  const filteredRest = [];
@@ -345,6 +990,17 @@ async function main() {
345
990
  else
346
991
  filteredRest.push(arg);
347
992
  }
993
+ // Per-subcommand --help / -h: intercept before parseArgs sees it
994
+ if (filteredRest.includes("--help") || filteredRest.includes("-h")) {
995
+ const subUsage = SUB_USAGE[subcommand];
996
+ if (subUsage) {
997
+ process.stdout.write(subUsage + "\n");
998
+ }
999
+ else {
1000
+ process.stdout.write(USAGE + "\n");
1001
+ }
1002
+ process.exit(0);
1003
+ }
348
1004
  switch (subcommand) {
349
1005
  case "workspace-outline": {
350
1006
  const { values, positionals } = parseArgs({
@@ -498,14 +1154,56 @@ async function main() {
498
1154
  options: {
499
1155
  format: { type: "string" },
500
1156
  out: { type: "string" },
1157
+ semantic: { type: "boolean" },
1158
+ "sim-threshold": { type: "string" },
1159
+ "sim-top-k": { type: "string" },
1160
+ model: { type: "string" },
501
1161
  },
502
1162
  allowPositionals: true,
503
1163
  });
504
1164
  const docsDir = positionals[0] ?? die("graph requires <docs-dir>");
505
1165
  const format = (values.format ?? "json");
1166
+ const semantic = values.semantic ?? false;
1167
+ const simThreshold = values["sim-threshold"] !== undefined
1168
+ ? parseFloat(values["sim-threshold"])
1169
+ : 0.75;
1170
+ const simTopK = values["sim-top-k"] !== undefined
1171
+ ? parseInt(values["sim-top-k"], 10)
1172
+ : 5;
1173
+ const modelOverride = values.model;
506
1174
  const graph = buildGraph(docsDir);
507
1175
  const raw = graph.toJSON();
508
1176
  const data = buildGraphExport(raw);
1177
+ if (semantic) {
1178
+ try {
1179
+ await addSemanticEdges(data, raw.pages, modelOverride, simThreshold, simTopK);
1180
+ }
1181
+ catch (err) {
1182
+ const msg = err instanceof Error ? err.message : String(err);
1183
+ if (msg.includes("OPENROUTER_API_KEY") ||
1184
+ msg.includes("AI_GATEWAY_API_KEY") ||
1185
+ msg.includes("assertApiKey") ||
1186
+ msg.includes("API key") ||
1187
+ msg.includes("api key")) {
1188
+ process.stderr.write("Error: --semantic requires an API key.\n" +
1189
+ " Set OPENROUTER_API_KEY=<key> (OpenRouter — recommended)\n" +
1190
+ " or AI_GATEWAY_API_KEY=<key> (Vercel AI Gateway)\n\n" +
1191
+ " Default model: openai/text-embedding-3-small\n" +
1192
+ " To override: --model openai/text-embedding-3-small\n" +
1193
+ " If the model name is rejected try: --model text-embedding-3-small (no prefix)\n");
1194
+ process.exit(1);
1195
+ }
1196
+ if (msg.toLowerCase().includes("model") ||
1197
+ msg.includes("404") ||
1198
+ msg.includes("not found")) {
1199
+ process.stderr.write("Error computing semantic embeddings: " + msg + "\n" +
1200
+ " Tip: try --model text-embedding-3-small (no openai/ prefix)\n" +
1201
+ " or --model openai/text-embedding-3-small (with prefix for OpenRouter)\n");
1202
+ process.exit(1);
1203
+ }
1204
+ throw err;
1205
+ }
1206
+ }
509
1207
  let result;
510
1208
  switch (format) {
511
1209
  case "json":
@@ -518,7 +1216,7 @@ async function main() {
518
1216
  result = renderGraphMermaid(data);
519
1217
  break;
520
1218
  case "html":
521
- result = renderGraphHtml(data, docsDir);
1219
+ result = renderGraphHtml(data, docsDir, semantic && data.semanticEdges.length > 0);
522
1220
  break;
523
1221
  default:
524
1222
  die(`Unknown format: ${format}. Use json, dot, mermaid, or html.`);
@@ -571,7 +1269,6 @@ async function main() {
571
1269
  scored.sort((a, b) => b.score - a.score);
572
1270
  const topN = scored.slice(0, limit);
573
1271
  const results = topN.map(({ page, score }) => {
574
- // Extract a short snippet (first 200 chars of content)
575
1272
  const snippet = page.content.slice(0, 200).replace(/\s+/g, " ").trim();
576
1273
  return {
577
1274
  pagePath: page.path,
@@ -589,7 +1286,8 @@ async function main() {
589
1286
  }
590
1287
  }
591
1288
  main().catch((err) => {
592
- process.stderr.write("[markdown-lsp] Fatal error: " + (err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
1289
+ process.stderr.write("[markdown-lsp] Fatal error: " +
1290
+ (err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
593
1291
  process.exit(1);
594
1292
  });
595
1293
  //# sourceMappingURL=cli.js.map