markdown-lsp 1.0.0 → 1.2.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 +134 -4
- package/dist/ai/cache.d.ts +3 -0
- package/dist/ai/cache.d.ts.map +1 -0
- package/dist/ai/cache.js +27 -0
- package/dist/ai/cache.js.map +1 -0
- package/dist/ai/config.d.ts +6 -0
- package/dist/ai/config.d.ts.map +1 -1
- package/dist/ai/config.js +16 -3
- package/dist/ai/config.js.map +1 -1
- package/dist/ai/embeddings.d.ts +9 -2
- package/dist/ai/embeddings.d.ts.map +1 -1
- package/dist/ai/embeddings.js +48 -10
- package/dist/ai/embeddings.js.map +1 -1
- package/dist/ai/gateway.d.ts +3 -0
- package/dist/ai/gateway.d.ts.map +1 -1
- package/dist/ai/gateway.js +17 -8
- package/dist/ai/gateway.js.map +1 -1
- package/dist/cli.js +795 -6
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import { buildGraph } from "./graph.js";
|
|
3
4
|
import { listPages, searchText, searchTextRanked, searchSymbols, searchPaths, } from "./bridge/index.js";
|
|
4
5
|
const USAGE = `
|
|
5
|
-
markdown-lsp v1.
|
|
6
|
+
markdown-lsp v1.2.0 — CLI for querying Markdown documentation graphs
|
|
6
7
|
|
|
7
8
|
USAGE
|
|
8
9
|
markdown-lsp <subcommand> [options]
|
|
@@ -37,6 +38,26 @@ SUBCOMMANDS
|
|
|
37
38
|
get-section <docs-dir> <page> <anchor>
|
|
38
39
|
Retrieve a section by its anchor slug.
|
|
39
40
|
|
|
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>]
|
|
44
|
+
Export the doc link graph. Default format: json (nodes/edges).
|
|
45
|
+
Use --format html for a self-contained interactive D3 visualisation.
|
|
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)
|
|
55
|
+
|
|
56
|
+
semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
|
|
57
|
+
AI-powered semantic search using embeddings. Requires OPENROUTER_API_KEY
|
|
58
|
+
(or AI_GATEWAY_API_KEY). Results are cached in .markdown-lsp-cache/.
|
|
59
|
+
Default embedding model: openai/text-embedding-3-small
|
|
60
|
+
|
|
40
61
|
lsp [--stdio]
|
|
41
62
|
serve [--stdio]
|
|
42
63
|
Start the LSP stdio server (for editor integration).
|
|
@@ -54,9 +75,45 @@ OUTPUT
|
|
|
54
75
|
over stdio — it does NOT print JSON.
|
|
55
76
|
|
|
56
77
|
EXAMPLES
|
|
78
|
+
# Overview of all pages
|
|
57
79
|
markdown-lsp workspace-outline ./docs
|
|
58
|
-
|
|
80
|
+
|
|
81
|
+
# Heading outline of one page
|
|
59
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
|
|
95
|
+
markdown-lsp graph ./docs --format json --pretty
|
|
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)
|
|
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
|
|
60
117
|
markdown-lsp lsp --stdio
|
|
61
118
|
`.trim();
|
|
62
119
|
function die(msg) {
|
|
@@ -66,7 +123,606 @@ function die(msg) {
|
|
|
66
123
|
function out(value, pretty) {
|
|
67
124
|
process.stdout.write((pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) + "\n");
|
|
68
125
|
}
|
|
69
|
-
|
|
126
|
+
function buildGraphExport(raw) {
|
|
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`);
|
|
248
|
+
}
|
|
249
|
+
// ── Graph renderers ───────────────────────────────────────────────────────────
|
|
250
|
+
function renderGraphJson(data, pretty) {
|
|
251
|
+
return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
252
|
+
}
|
|
253
|
+
function renderGraphDot(data) {
|
|
254
|
+
const lines = ['digraph G {', ' rankdir=LR;'];
|
|
255
|
+
for (const n of data.nodes) {
|
|
256
|
+
const label = n.title.replace(/"/g, '\\"');
|
|
257
|
+
const id = n.id.replace(/"/g, '\\"');
|
|
258
|
+
lines.push(` "${id}" [label="${label}"];`);
|
|
259
|
+
}
|
|
260
|
+
for (const e of data.edges) {
|
|
261
|
+
const src = e.source.replace(/"/g, '\\"');
|
|
262
|
+
const tgt = e.target.replace(/"/g, '\\"');
|
|
263
|
+
lines.push(` "${src}" -> "${tgt}";`);
|
|
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
|
+
}
|
|
270
|
+
lines.push('}');
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
function renderGraphMermaid(data) {
|
|
274
|
+
const lines = ['graph TD'];
|
|
275
|
+
for (const e of data.edges) {
|
|
276
|
+
const src = e.source.replace(/"/g, '\\"');
|
|
277
|
+
const tgt = e.target.replace(/"/g, '\\"');
|
|
278
|
+
lines.push(` "${src}" --> "${tgt}"`);
|
|
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
|
+
}
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
function renderGraphHtml(data, docsDir, hasSemantic) {
|
|
288
|
+
const title = docsDir.split('/').filter(Boolean).pop() ?? docsDir;
|
|
289
|
+
const jsonData = JSON.stringify(data);
|
|
290
|
+
return `<!DOCTYPE html>
|
|
291
|
+
<html lang="en">
|
|
292
|
+
<head>
|
|
293
|
+
<meta charset="utf-8">
|
|
294
|
+
<title>Doc Graph — ${title}</title>
|
|
295
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
|
296
|
+
<style>
|
|
297
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
298
|
+
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e2e8f0; overflow: hidden; }
|
|
299
|
+
#toolbar { position: fixed; top: 0; left: 0; right: 0; height: 44px; background: #1a1d27;
|
|
300
|
+
border-bottom: 1px solid #2d3148; display: flex; align-items: center; padding: 0 16px;
|
|
301
|
+
z-index: 10; gap: 16px; font-size: 13px; }
|
|
302
|
+
#toolbar h1 { font-size: 14px; font-weight: 600; color: #a5b4fc; }
|
|
303
|
+
#toolbar span { color: #64748b; }
|
|
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; }
|
|
312
|
+
#tooltip { position: fixed; pointer-events: none; background: #1e2130; border: 1px solid #3b4168;
|
|
313
|
+
border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
|
|
314
|
+
max-width: 280px; z-index: 20; display: none; }
|
|
315
|
+
#tooltip .title { font-weight: 600; color: #c7d2fe; margin-bottom: 2px; }
|
|
316
|
+
#tooltip .path { color: #64748b; font-size: 11px; }
|
|
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; }
|
|
343
|
+
.node circle { cursor: pointer; transition: r 0.15s; }
|
|
344
|
+
.node text { pointer-events: none; font-size: 10px; fill: #cbd5e1; }
|
|
345
|
+
.link { stroke: #3b4168; stroke-opacity: 0.6; fill: none; }
|
|
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; }
|
|
351
|
+
.node.highlighted circle { stroke: #a5b4fc !important; stroke-width: 2px !important; }
|
|
352
|
+
.node.selected circle { stroke: #f59e0b !important; stroke-width: 2.5px !important; }
|
|
353
|
+
</style>
|
|
354
|
+
</head>
|
|
355
|
+
<body>
|
|
356
|
+
<div id="toolbar">
|
|
357
|
+
<h1>Doc Graph — ${title}</h1>
|
|
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">✕</button>
|
|
378
|
+
</div>
|
|
379
|
+
<div id="panel-body"></div>
|
|
380
|
+
</div>
|
|
381
|
+
<div id="tooltip"></div>
|
|
382
|
+
<script>
|
|
383
|
+
const DATA = ${jsonData};
|
|
384
|
+
const HAS_SEMANTIC = ${hasSemantic};
|
|
385
|
+
|
|
386
|
+
// Node map for fast lookup
|
|
387
|
+
const nodeById = new Map(DATA.nodes.map(n => [n.id, n]));
|
|
388
|
+
|
|
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()); }
|
|
393
|
+
for (const e of DATA.edges) {
|
|
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);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const linkCount = DATA.edges.length;
|
|
403
|
+
const semCount = DATA.semanticEdges.length;
|
|
404
|
+
document.getElementById('stats').textContent =
|
|
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');
|
|
414
|
+
|
|
415
|
+
const g = svg.append('g');
|
|
416
|
+
|
|
417
|
+
// Zoom + click-on-background
|
|
418
|
+
svg.call(d3.zoom()
|
|
419
|
+
.scaleExtent([0.05, 4])
|
|
420
|
+
.on('zoom', (event) => g.attr('transform', event.transform)));
|
|
421
|
+
|
|
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
|
|
449
|
+
const linkSel = g.append('g').selectAll('line')
|
|
450
|
+
.data(DATA.edges)
|
|
451
|
+
.join('line')
|
|
452
|
+
.attr('class','link')
|
|
453
|
+
.attr('marker-end','url(#arrow)');
|
|
454
|
+
|
|
455
|
+
// Color by first path segment
|
|
456
|
+
const color = d3.scaleOrdinal(d3.schemeTableau10);
|
|
457
|
+
const segColor = id => color(id.split('/')[0] ?? '');
|
|
458
|
+
|
|
459
|
+
let selectedNodeId = null;
|
|
460
|
+
|
|
461
|
+
// Nodes
|
|
462
|
+
const nodeSel = g.append('g').selectAll('g')
|
|
463
|
+
.data(DATA.nodes)
|
|
464
|
+
.join('g')
|
|
465
|
+
.attr('class','node')
|
|
466
|
+
.call(d3.drag()
|
|
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; }));
|
|
470
|
+
|
|
471
|
+
nodeSel.append('circle')
|
|
472
|
+
.attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))))
|
|
473
|
+
.attr('fill', d => segColor(d.id))
|
|
474
|
+
.attr('fill-opacity', 0.85)
|
|
475
|
+
.attr('stroke', d => segColor(d.id))
|
|
476
|
+
.attr('stroke-width', 1);
|
|
477
|
+
|
|
478
|
+
nodeSel.append('text')
|
|
479
|
+
.attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))) + 3)
|
|
480
|
+
.attr('dy', '0.35em')
|
|
481
|
+
.text(d => d.title.length > 24 ? d.title.slice(0,22) + '\\u2026' : d.title);
|
|
482
|
+
|
|
483
|
+
// Tooltip
|
|
484
|
+
const tooltip = document.getElementById('tooltip');
|
|
485
|
+
nodeSel
|
|
486
|
+
.on('mouseover', (ev,d) => {
|
|
487
|
+
tooltip.style.display = 'block';
|
|
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);
|
|
493
|
+
})
|
|
494
|
+
.on('mousemove', ev => {
|
|
495
|
+
tooltip.style.left = (ev.clientX+14)+'px';
|
|
496
|
+
tooltip.style.top = (ev.clientY+14)+'px';
|
|
497
|
+
})
|
|
498
|
+
.on('mouseout', () => {
|
|
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
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
function esc(s) {
|
|
516
|
+
return String(s)
|
|
517
|
+
.replace(/&/g,'&').replace(/</g,'<')
|
|
518
|
+
.replace(/>/g,'>').replace(/"/g,'"');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function highlightNode(d) {
|
|
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);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function clearHighlight() {
|
|
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;
|
|
690
|
+
}
|
|
691
|
+
|
|
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
|
+
|
|
705
|
+
const sim = d3.forceSimulation(DATA.nodes)
|
|
706
|
+
.force('link', d3.forceLink(allSimEdges).id(d => d.id)
|
|
707
|
+
.distance(80).strength(e => e.kind === 'semantic' ? 0.2 : 0.5))
|
|
708
|
+
.force('charge', d3.forceManyBody().strength(-180))
|
|
709
|
+
.force('center', d3.forceCenter(wrapW()/2, wrapH()/2))
|
|
710
|
+
.force('collide', d3.forceCollide(18))
|
|
711
|
+
.on('tick', () => {
|
|
712
|
+
linkSel
|
|
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+')');
|
|
719
|
+
});
|
|
720
|
+
</script>
|
|
721
|
+
</body>
|
|
722
|
+
</html>`;
|
|
723
|
+
}
|
|
724
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
725
|
+
async function startLspServer() {
|
|
70
726
|
await import("./lsp.js");
|
|
71
727
|
}
|
|
72
728
|
async function main() {
|
|
@@ -82,7 +738,7 @@ async function main() {
|
|
|
82
738
|
process.exit(0);
|
|
83
739
|
}
|
|
84
740
|
// Old LSP invocation style — keep working so editor configs don't break.
|
|
85
|
-
await
|
|
741
|
+
await startLspServer();
|
|
86
742
|
return;
|
|
87
743
|
}
|
|
88
744
|
const subcommand = argv[0];
|
|
@@ -94,7 +750,7 @@ async function main() {
|
|
|
94
750
|
}
|
|
95
751
|
// LSP subcommand
|
|
96
752
|
if (subcommand === "lsp" || subcommand === "serve") {
|
|
97
|
-
await
|
|
753
|
+
await startLspServer();
|
|
98
754
|
return;
|
|
99
755
|
}
|
|
100
756
|
// Parse global flags from the remaining args
|
|
@@ -253,13 +909,146 @@ async function main() {
|
|
|
253
909
|
out(section, pretty);
|
|
254
910
|
break;
|
|
255
911
|
}
|
|
912
|
+
case "graph": {
|
|
913
|
+
const { values, positionals } = parseArgs({
|
|
914
|
+
args: filteredRest,
|
|
915
|
+
options: {
|
|
916
|
+
format: { type: "string" },
|
|
917
|
+
out: { type: "string" },
|
|
918
|
+
semantic: { type: "boolean" },
|
|
919
|
+
"sim-threshold": { type: "string" },
|
|
920
|
+
"sim-top-k": { type: "string" },
|
|
921
|
+
model: { type: "string" },
|
|
922
|
+
},
|
|
923
|
+
allowPositionals: true,
|
|
924
|
+
});
|
|
925
|
+
const docsDir = positionals[0] ?? die("graph requires <docs-dir>");
|
|
926
|
+
const format = (values.format ?? "json");
|
|
927
|
+
const semantic = values.semantic ?? false;
|
|
928
|
+
const simThreshold = values["sim-threshold"] !== undefined
|
|
929
|
+
? parseFloat(values["sim-threshold"])
|
|
930
|
+
: 0.75;
|
|
931
|
+
const simTopK = values["sim-top-k"] !== undefined
|
|
932
|
+
? parseInt(values["sim-top-k"], 10)
|
|
933
|
+
: 5;
|
|
934
|
+
const modelOverride = values.model;
|
|
935
|
+
const graph = buildGraph(docsDir);
|
|
936
|
+
const raw = graph.toJSON();
|
|
937
|
+
const data = buildGraphExport(raw);
|
|
938
|
+
if (semantic) {
|
|
939
|
+
try {
|
|
940
|
+
await addSemanticEdges(data, raw.pages, modelOverride, simThreshold, simTopK);
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
944
|
+
if (msg.includes("OPENROUTER_API_KEY") ||
|
|
945
|
+
msg.includes("AI_GATEWAY_API_KEY") ||
|
|
946
|
+
msg.includes("assertApiKey") ||
|
|
947
|
+
msg.includes("API key") ||
|
|
948
|
+
msg.includes("api key")) {
|
|
949
|
+
process.stderr.write("Error: --semantic requires an API key.\n" +
|
|
950
|
+
" Set OPENROUTER_API_KEY=<key> (OpenRouter — recommended)\n" +
|
|
951
|
+
" or AI_GATEWAY_API_KEY=<key> (Vercel AI Gateway)\n\n" +
|
|
952
|
+
" Default model: openai/text-embedding-3-small\n" +
|
|
953
|
+
" To override: --model openai/text-embedding-3-small\n" +
|
|
954
|
+
" If the model name is rejected try: --model text-embedding-3-small (no prefix)\n");
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
if (msg.toLowerCase().includes("model") ||
|
|
958
|
+
msg.includes("404") ||
|
|
959
|
+
msg.includes("not found")) {
|
|
960
|
+
process.stderr.write("Error computing semantic embeddings: " + msg + "\n" +
|
|
961
|
+
" Tip: try --model text-embedding-3-small (no openai/ prefix)\n" +
|
|
962
|
+
" or --model openai/text-embedding-3-small (with prefix for OpenRouter)\n");
|
|
963
|
+
process.exit(1);
|
|
964
|
+
}
|
|
965
|
+
throw err;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
let result;
|
|
969
|
+
switch (format) {
|
|
970
|
+
case "json":
|
|
971
|
+
result = renderGraphJson(data, pretty);
|
|
972
|
+
break;
|
|
973
|
+
case "dot":
|
|
974
|
+
result = renderGraphDot(data);
|
|
975
|
+
break;
|
|
976
|
+
case "mermaid":
|
|
977
|
+
result = renderGraphMermaid(data);
|
|
978
|
+
break;
|
|
979
|
+
case "html":
|
|
980
|
+
result = renderGraphHtml(data, docsDir, semantic && data.semanticEdges.length > 0);
|
|
981
|
+
break;
|
|
982
|
+
default:
|
|
983
|
+
die(`Unknown format: ${format}. Use json, dot, mermaid, or html.`);
|
|
984
|
+
}
|
|
985
|
+
if (values.out) {
|
|
986
|
+
fs.writeFileSync(values.out, result, "utf8");
|
|
987
|
+
process.stderr.write(`Graph written to ${values.out}\n`);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
process.stdout.write(result + "\n");
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
case "semantic-search": {
|
|
995
|
+
const { values, positionals } = parseArgs({
|
|
996
|
+
args: filteredRest,
|
|
997
|
+
options: {
|
|
998
|
+
limit: { type: "string" },
|
|
999
|
+
model: { type: "string" },
|
|
1000
|
+
},
|
|
1001
|
+
allowPositionals: true,
|
|
1002
|
+
});
|
|
1003
|
+
const docsDir = positionals[0] ?? die("semantic-search requires <docs-dir>");
|
|
1004
|
+
const query = positionals[1] ?? die("semantic-search requires <query>");
|
|
1005
|
+
const limit = values.limit !== undefined ? parseInt(values.limit, 10) : 10;
|
|
1006
|
+
const modelOverride = values.model;
|
|
1007
|
+
// Check API key before doing any work
|
|
1008
|
+
const { assertApiKey } = await import("./ai/config.js");
|
|
1009
|
+
assertApiKey();
|
|
1010
|
+
const { embedTexts, embedOne } = await import("./ai/embeddings.js");
|
|
1011
|
+
const graph = buildGraph(docsDir);
|
|
1012
|
+
const pages = graph.pages;
|
|
1013
|
+
// Build embedding texts (title + first 2000 chars of content per page)
|
|
1014
|
+
const pageTexts = pages.map((p) => {
|
|
1015
|
+
const titlePart = p.title ? p.title + "\n\n" : "";
|
|
1016
|
+
return titlePart + p.content.slice(0, 2000);
|
|
1017
|
+
});
|
|
1018
|
+
// Embed all pages (cached) and the query
|
|
1019
|
+
const [pageEmbeddings, queryVec] = await Promise.all([
|
|
1020
|
+
embedTexts(pageTexts, modelOverride, true),
|
|
1021
|
+
embedOne(query, modelOverride),
|
|
1022
|
+
]);
|
|
1023
|
+
// Score each page
|
|
1024
|
+
const scored = pages.map((p, i) => {
|
|
1025
|
+
const vec = pageEmbeddings.vectors[i];
|
|
1026
|
+
if (!vec)
|
|
1027
|
+
return { page: p, score: 0 };
|
|
1028
|
+
return { page: p, score: cosineSim(queryVec, vec) };
|
|
1029
|
+
});
|
|
1030
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1031
|
+
const topN = scored.slice(0, limit);
|
|
1032
|
+
const results = topN.map(({ page, score }) => {
|
|
1033
|
+
const snippet = page.content.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
1034
|
+
return {
|
|
1035
|
+
pagePath: page.path,
|
|
1036
|
+
pageTitle: page.title ?? page.path,
|
|
1037
|
+
score: Math.round(score * 10000) / 10000,
|
|
1038
|
+
snippet: snippet.length < page.content.length ? snippet + "…" : snippet,
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
out(results, pretty);
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
256
1044
|
default:
|
|
257
1045
|
process.stderr.write(`Unknown subcommand: ${subcommand}\n\n${USAGE}\n`);
|
|
258
1046
|
process.exit(1);
|
|
259
1047
|
}
|
|
260
1048
|
}
|
|
261
1049
|
main().catch((err) => {
|
|
262
|
-
process.stderr.write("[markdown-lsp] Fatal error: " +
|
|
1050
|
+
process.stderr.write("[markdown-lsp] Fatal error: " +
|
|
1051
|
+
(err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
|
|
263
1052
|
process.exit(1);
|
|
264
1053
|
});
|
|
265
1054
|
//# sourceMappingURL=cli.js.map
|