@polycode-projects/seonix 0.1.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/server.mjs ADDED
@@ -0,0 +1,389 @@
1
+ // seonix MCP server — a local, query-only stdio server over the deterministic
2
+ // typed code-graph artifact (<repo>/.seonix/graph.json). Adapted from the shipped
3
+ // marginalia seon-mcp server, but the graph source
4
+ // is a LOCAL file (src/source.mjs) and seonix_search is a LOCAL lexical lookup
5
+ // (no remote API, no LLM, no model calls anywhere).
6
+ //
7
+ // Tools (all query-only, bounded output): seonix_search, seonix_describe, seonix_snippet,
8
+ // seonix_impact, plus the §9 read-replacing tools seonix_members, seonix_subclasses,
9
+ // seonix_architecture, seonix_tests_for, seonix_untested, seonix_history, seonix_callers,
10
+ // seonix_callees. Each answers one question in ONE compact call so the agent need not
11
+ // Read/Grep. Errors reach the caller as clean tool errors — message only, never a stack.
12
+
13
+ import { readFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ import { loadConfig, ToolError } from "./config.mjs";
19
+ import * as defaultSource from "./source.mjs";
20
+ import {
21
+ parseEntities,
22
+ resolveSymbol,
23
+ renderDescribe,
24
+ renderImpact,
25
+ renderSearch,
26
+ siteOf,
27
+ renderMembers,
28
+ renderSubclasses,
29
+ renderArchitecture,
30
+ renderTestsFor,
31
+ renderUntested,
32
+ renderHistory,
33
+ renderCallers,
34
+ renderCallees,
35
+ renderCochanges,
36
+ renderExports,
37
+ renderSignature,
38
+ contextPlan,
39
+ sizeBundle,
40
+ bundleMask,
41
+ trimBundleMask,
42
+ renderContextMore,
43
+ renderCalls,
44
+ callHint,
45
+ renderFileHistory,
46
+ renderMethodHistory,
47
+ renderClassHistory,
48
+ } from "./codegraph.mjs";
49
+
50
+ const SNIPPET_MAX_LINES = 200;
51
+
52
+ export const SERVER_INFO = { name: "seonix", version: "0.1.0" };
53
+
54
+ // C4 tiered tool surface: only the TWO hot tools are registered as MCP tools, so the
55
+ // agent's tool list stays tiny. Every COLD tool (describe/members/impact/history/…) is
56
+ // still served by dispatchTool below and is reachable via the CLI `cli <tool>` route +
57
+ // the generated <repo>/.seonix/TOOLS.md catalog (renderToolsCatalog).
58
+ export const TOOLS = [
59
+ {
60
+ name: "seonix_context",
61
+ // Lean resident schema (re-billed every turn): the minimum that still steers the agent to
62
+ // ONE call → write, not Read.
63
+ description:
64
+ "START HERE to add/modify code: ONE call returns a sized edit bundle (exemplar source, sibling signatures, registration, insertion region) — then write directly, don't Read.",
65
+ inputSchema: {
66
+ type: "object",
67
+ required: ["symbol"],
68
+ properties: {
69
+ symbol: { type: "string", description: "Module path (django/utils/text.py) or a sibling function/class name in it (lower)." },
70
+ depth: { type: "string", enum: ["min", "auto", "full"], default: "auto", description: "auto (sized to the task) | min (leanest) | full (every section)." },
71
+ },
72
+ },
73
+ },
74
+ {
75
+ name: "seonix_snippet",
76
+ description: "EXACT source of one function/class/Class.method by name (its line span only) + a one-line in-repo call hint. Prefer over Read for a single symbol.",
77
+ inputSchema: {
78
+ type: "object",
79
+ required: ["symbol"],
80
+ properties: {
81
+ symbol: { type: "string", description: "function/class name (slugify, Truncator), Class.method, or fn:<path>#name." },
82
+ },
83
+ },
84
+ },
85
+ ];
86
+
87
+ async function loadGraph(config, source) {
88
+ const payload = await source.fetchEntities(config);
89
+ const graph = parseEntities(payload);
90
+ if (!graph.individuals.length) {
91
+ throw new ToolError(
92
+ `the graph artifact at ${config.graphFile} has no entities — re-run ` +
93
+ "`seonix cli index_repository` for this repo (did the index step succeed?).",
94
+ );
95
+ }
96
+ return graph;
97
+ }
98
+
99
+ function resolveOrThrow(graph, symbol, what) {
100
+ const { match, candidates } = resolveSymbol(graph, symbol);
101
+ if (!match) {
102
+ throw new ToolError(
103
+ `no entity matching ${what} "${symbol}" in the code-map graph. ` +
104
+ "Try a repo-relative path (e.g. django/utils/text.py), a basename, or seonix_search for a fuzzy lookup.",
105
+ );
106
+ }
107
+ return { match, candidates };
108
+ }
109
+
110
+ /**
111
+ * Build the seonix_context "edit bundle" for a symbol and return { text, tier, topup }.
112
+ * Shared by the MCP seonix_context tool AND the no-MCP `cli digest` arm (cli.mjs), so both
113
+ * benefit from the size-adaptive sizing for free. `trim:true` (B2) renders a SECONDARY,
114
+ * signatures-only bundle (no bodies/tails) for related-but-not-primary digest modules.
115
+ *
116
+ * Section ORDER is cache-stable (B7): the content that is identical across runs (anchor /
117
+ * registration / exemplar / siblings / __all__ / insertion region) comes FIRST; the more
118
+ * variable, history-derived tails (covering tests, co-change) come LAST, so a stable prefix
119
+ * maximises prompt-cache reuse.
120
+ */
121
+ export async function buildContextBundle(args, { config, source = defaultSource, trim = false } = {}) {
122
+ const symbol = String(args?.symbol || "").trim();
123
+ if (!symbol) throw new ToolError("symbol is required");
124
+ const depth = String(args?.depth || "auto").trim().toLowerCase();
125
+ // Tuning-flag contract: `min` forces the LEANEST bundle (TINY mask, no top-up) regardless of
126
+ // exemplar length; `untuned` reproduces the pre-B010 escalation (tuning #1 bypassed). Neither
127
+ // → the tuned default (sizeBundle's anchor-gated escalation).
128
+ const min = Boolean(args?.min);
129
+ const untuned = Boolean(args?.untuned);
130
+ const graph = await loadGraph(config, source);
131
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
132
+ const plan = contextPlan(graph, match);
133
+ // #6/B1/B6: pick the section mask by depth — min forces TINY, full forces everything, auto
134
+ // runs the size classifier (lean TINY default + one-tier top-up when the edit needs it).
135
+ let tier;
136
+ let mask;
137
+ let topup = false;
138
+ if (min || depth === "min") { tier = "TINY"; mask = bundleMask("TINY"); }
139
+ else if (depth === "full") { tier = "FULL"; mask = bundleMask("FULL"); }
140
+ else ({ tier, mask, topup } = sizeBundle(plan, graph, { untuned }));
141
+ if (trim) mask = trimBundleMask(mask); // B2: secondary digest module → signatures + region only
142
+ const repoRoot = dirname(dirname(config.graphFile));
143
+ let lines = null;
144
+ if (plan.moduleLabel) {
145
+ try { lines = (await readFile(join(repoRoot, plan.moduleLabel), "utf8")).split("\n"); }
146
+ catch { lines = null; }
147
+ }
148
+ const lineAt = (n) => (lines && lines[n - 1] != null ? lines[n - 1].trim() : "");
149
+ const sliceBody = (start, end) => {
150
+ const e = Math.min(lines.length, Math.min(end, start + SNIPPET_MAX_LINES - 1));
151
+ return lines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n");
152
+ };
153
+ const out = [
154
+ `Edit context for ${plan.moduleLabel} [${tier}${trim ? " secondary" : ""}] — assembled from the typed graph + that file. ` +
155
+ "You do NOT need to Read it; write the new code directly after reviewing this.",
156
+ ];
157
+ // ---- cache-stable prefix (B7): identical across runs ----
158
+ if (mask.anchor && plan.anchor?.site && lines) {
159
+ const { start, end } = plan.anchor.site;
160
+ out.push(`\n## anchor: ${plan.anchor.label} (${plan.anchor.class}) @ ${plan.moduleLabel}:${start}-${end}`);
161
+ out.push(sliceBody(start, end));
162
+ if (plan.callHint) out.push(plan.callHint);
163
+ }
164
+ if (mask.registration && plan.globals.length) {
165
+ out.push(`\n## registration / module globals (replicate this pattern):`);
166
+ for (const g of plan.globals) out.push(` ${g.label} = ${g.value}${g.site ? ` [:${g.site.start}]` : ""}`);
167
+ }
168
+ if (mask.exemplar && plan.exemplar?.site && lines) {
169
+ const { start, end } = plan.exemplar.site;
170
+ const dec = plan.exemplar.decorators ? ` @${plan.exemplar.decorators}` : "";
171
+ out.push(`\n## closest example (full body) — copy this style: ${plan.exemplar.label} (${plan.exemplar.class})${dec} @ ${plan.moduleLabel}:${start}-${end}`);
172
+ out.push(sliceBody(start, end));
173
+ if (plan.callHint) out.push(plan.callHint);
174
+ }
175
+ if (mask.inlinedCallees && plan.calleeBodies.length && lines) {
176
+ let budget = 120; // INLINE_CALLEE_LOC
177
+ for (const cb of plan.calleeBodies) {
178
+ if (budget <= 0) break;
179
+ const start = cb.site.start;
180
+ const end = Math.min(cb.site.end, start + budget - 1);
181
+ const fromThisFile = cb.site.path === plan.moduleLabel;
182
+ const bodyLines = fromThisFile && lines
183
+ ? lines
184
+ : await readFile(join(repoRoot, cb.site.path), "utf8").then((t) => t.split("\n")).catch(() => null);
185
+ if (!bodyLines) continue;
186
+ const e = Math.min(bodyLines.length, end);
187
+ out.push(`\n## inlined callee body (depth-1 in-repo call): ${cb.label} @ ${cb.site.path}:${start}-${cb.site.end}`);
188
+ out.push(bodyLines.slice(start - 1, e).map((l, i) => `${start + i}\t${l}`).join("\n"));
189
+ budget -= (e - start + 1);
190
+ }
191
+ }
192
+ if (mask.classMembers && plan.classMembers && plan.classMembers.members.length) {
193
+ out.push(`\n## members of ${plan.classMembers.className} (the edit likely lives INSIDE this class — copy a member's shape, do not read the class body):`);
194
+ for (const m of plan.classMembers.members) {
195
+ const short = String(m.label).split(".").pop();
196
+ const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
197
+ const dec = m.decorators ? `@${m.decorators} ` : "";
198
+ const r = m.raises ? ` raises=${m.raises}` : "";
199
+ out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
200
+ }
201
+ }
202
+ if (mask.siblings && plan.siblings.length) {
203
+ out.push(`\n## sibling symbols to copy the style of (most relevant first; ${plan.siblings.length} total):`);
204
+ for (const s of plan.siblings.slice(0, plan.siblingCap)) {
205
+ const sig = s.site ? lineAt(s.site.start) : "";
206
+ const dec = s.decorators ? `@${s.decorators} ` : "";
207
+ const r = s.raises ? ` raises=${s.raises}` : "";
208
+ out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${sig}${r}`);
209
+ }
210
+ if (plan.siblings.length > plan.siblingCap) {
211
+ out.push(` …+${plan.siblings.length - plan.siblingCap} more (use seonix_search kind=function or seonix_snippet <name> for any of them)`);
212
+ }
213
+ }
214
+ if (mask.allExports && plan.allExports) {
215
+ out.push(`\n## module __all__ — this module curates its public API; ADD your new public symbol to this list so it is importable:\n ${plan.allExports}`);
216
+ }
217
+ if (mask.reexports && plan.exports && plan.exports.length) out.push(`\n## re-exported symbols (resolved __all__ → defining module): ${plan.exports.join(", ")}`);
218
+ // #2 + B4: the contiguous insertion region is part of the STABLE prefix — always present
219
+ // (even at TINY) so the agent never needs to Read the file to place the edit.
220
+ if (mask.insertionRegion && plan.insertionRegion && lines) {
221
+ const start = plan.insertionRegion.start;
222
+ const end = Math.min(lines.length, start + 40 - 1); // INSERTION_REGION_CAP
223
+ out.push(`\n## insertion region (write your new sibling here) — ${plan.moduleLabel}:${start}-${end}`);
224
+ out.push(lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join("\n"));
225
+ } else if (plan.insertion) {
226
+ out.push(`\n## insert the new sibling after line ~${plan.insertion} (end of the last top-level definition).`);
227
+ }
228
+ // ---- variable tail (B7): history-derived, kept LAST so the prefix stays cache-stable ----
229
+ if (mask.tests && plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
230
+ if (mask.cochange && plan.cochange && plan.cochange.length) {
231
+ out.push(`\n## usually changed together (consider editing these too): ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
232
+ }
233
+ out.push(`\nYou now have the snippet, the sibling style, the registration anchor and the tests. ` +
234
+ `Write the new code with Edit/Write — do NOT Read ${plan.moduleLabel}.`);
235
+ if (tier !== "FULL") {
236
+ out.push(`(bundle tier ${tier}; for any omitted sections run seonix_context_more {"symbol":"${symbol}"}, or seonix_context with depth="full".)`);
237
+ }
238
+ return { text: out.join("\n"), tier, topup };
239
+ }
240
+
241
+ export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
242
+ if (name === "seonix_context") {
243
+ return (await buildContextBundle(args, { config, source })).text;
244
+ }
245
+ if (name === "seonix_context_more") {
246
+ const symbol = String(args?.symbol || "").trim();
247
+ if (!symbol) throw new ToolError("symbol is required");
248
+ const graph = await loadGraph(config, source);
249
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
250
+ return renderContextMore(contextPlan(graph, match));
251
+ }
252
+ if (name === "seonix_describe") {
253
+ const symbol = String(args?.symbol || "").trim();
254
+ if (!symbol) throw new ToolError("symbol is required");
255
+ const graph = await loadGraph(config, source);
256
+ const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
257
+ return renderDescribe(graph, match, { candidates });
258
+ }
259
+ if (name === "seonix_snippet") {
260
+ const symbol = String(args?.symbol || "").trim();
261
+ if (!symbol) throw new ToolError("symbol is required");
262
+ const graph = await loadGraph(config, source);
263
+ const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
264
+ const site = siteOf(match);
265
+ if (!site) {
266
+ throw new ToolError(
267
+ `"${match.label}" (${match.class || "Entity"}) has no source span in the graph — ` +
268
+ "it is likely a module. Use seonix_describe for its contents, then seonix_snippet one of the functions/classes it defines.",
269
+ );
270
+ }
271
+ // repo root = the dir containing .seonix/ (graphFile = <repo>/.seonix/graph.json)
272
+ const repoRoot = dirname(dirname(config.graphFile));
273
+ const abs = join(repoRoot, site.path);
274
+ let text;
275
+ try { text = await readFile(abs, "utf8"); }
276
+ catch (e) { throw new ToolError(`could not read ${site.path} (${e?.code || e?.message || e})`); }
277
+ const lines = text.split("\n");
278
+ const start = Math.max(1, site.start);
279
+ let end = Math.min(lines.length, site.end);
280
+ let truncated = false;
281
+ if (end - start + 1 > SNIPPET_MAX_LINES) { end = start + SNIPPET_MAX_LINES - 1; truncated = true; }
282
+ const body = lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join("\n");
283
+ const span = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
284
+ const header = `${match.label} — ${match.class || "Entity"} @ ${site.path}:${span}`;
285
+ const note = truncated ? `\n… (truncated to ${SNIPPET_MAX_LINES} lines; full span ${span})` : "";
286
+ const cand = candidates.length ? `\n(other matches: ${candidates.map((c) => c.label).join(", ")})` : "";
287
+ const hint = callHint(graph, match); // #4: one-line "calls in-repo: …" so the agent sees in-repo deps inline
288
+ return `${header}\n${body}${note}${hint ? `\n${hint}` : ""}${cand}`;
289
+ }
290
+ if (name === "seonix_signature") {
291
+ const symbol = String(args?.symbol || "").trim();
292
+ if (!symbol) throw new ToolError("symbol is required");
293
+ const graph = await loadGraph(config, source);
294
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
295
+ return renderSignature(graph, match);
296
+ }
297
+ if (name === "seonix_impact") {
298
+ const module = String(args?.module || "").trim();
299
+ if (!module) throw new ToolError("module is required");
300
+ const graph = await loadGraph(config, source);
301
+ const { match } = resolveOrThrow(graph, module, "module");
302
+ return renderImpact(graph, match);
303
+ }
304
+ if (name === "seonix_search") {
305
+ const query = String(args?.query || "").trim();
306
+ const kind = String(args?.kind || "").trim();
307
+ if (!query && !kind) throw new ToolError("query is required");
308
+ const graph = await loadGraph(config, source);
309
+ return renderSearch(graph, query, {
310
+ kind,
311
+ decorator: String(args?.decorator || "").trim(),
312
+ name: String(args?.name || "").trim(),
313
+ });
314
+ }
315
+ if (name === "seonix_members") {
316
+ const symbol = String(args?.class || "").trim();
317
+ if (!symbol) throw new ToolError("class is required");
318
+ const graph = await loadGraph(config, source);
319
+ const { match } = resolveOrThrow(graph, symbol, "class");
320
+ return renderMembers(graph, match);
321
+ }
322
+ if (name === "seonix_subclasses") {
323
+ const symbol = String(args?.class || "").trim();
324
+ if (!symbol) throw new ToolError("class is required");
325
+ const graph = await loadGraph(config, source);
326
+ const { match } = resolveOrThrow(graph, symbol, "class");
327
+ return renderSubclasses(graph, match);
328
+ }
329
+ if (name === "seonix_architecture") {
330
+ const graph = await loadGraph(config, source);
331
+ return renderArchitecture(graph, { pkg: String(args?.package || "").trim() });
332
+ }
333
+ if (name === "seonix_exports") {
334
+ const module = String(args?.module || "").trim();
335
+ if (!module) throw new ToolError("module is required");
336
+ const graph = await loadGraph(config, source);
337
+ const { match } = resolveOrThrow(graph, module, "module");
338
+ return renderExports(graph, match);
339
+ }
340
+ if (name === "seonix_untested") {
341
+ const graph = await loadGraph(config, source);
342
+ return renderUntested(graph);
343
+ }
344
+ if (
345
+ name === "seonix_tests_for" || name === "seonix_history" || name === "seonix_callers" ||
346
+ name === "seonix_callees" || name === "seonix_cochanges" || name === "seonix_calls" ||
347
+ name === "seonix_file_history" || name === "seonix_method_history" || name === "seonix_class_history"
348
+ ) {
349
+ const symbol = String(args?.symbol || "").trim();
350
+ if (!symbol) throw new ToolError("symbol is required");
351
+ const graph = await loadGraph(config, source);
352
+ const { match } = resolveOrThrow(graph, symbol, "symbol");
353
+ if (name === "seonix_tests_for") return renderTestsFor(graph, match);
354
+ if (name === "seonix_history") return renderHistory(graph, match);
355
+ if (name === "seonix_callers") return renderCallers(graph, match);
356
+ if (name === "seonix_cochanges") return renderCochanges(graph, match);
357
+ if (name === "seonix_calls") return renderCalls(graph, match);
358
+ if (name === "seonix_file_history") return renderFileHistory(graph, match);
359
+ if (name === "seonix_method_history") return renderMethodHistory(graph, match);
360
+ if (name === "seonix_class_history") return renderClassHistory(graph, match);
361
+ return renderCallees(graph, match);
362
+ }
363
+ throw new ToolError(`unknown tool: ${name}`);
364
+ }
365
+
366
+ /** Build the MCP server (transport-agnostic — tests connect it in-memory). */
367
+ export function buildServer({ config = loadConfig(), source = defaultSource } = {}) {
368
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
369
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
370
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
371
+ const { name, arguments: args } = req.params;
372
+ try {
373
+ const text = await dispatchTool(name, args || {}, { config, source });
374
+ return { content: [{ type: "text", text }] };
375
+ } catch (e) {
376
+ const text = e instanceof ToolError ? e.message : `unexpected error: ${e?.message || e}`;
377
+ return { content: [{ type: "text", text }], isError: true };
378
+ }
379
+ });
380
+ return server;
381
+ }
382
+
383
+ export async function startServer() {
384
+ const config = loadConfig();
385
+ const server = buildServer({ config });
386
+ await server.connect(new StdioServerTransport());
387
+ // stdout belongs to the MCP transport; sign on via stderr.
388
+ process.stderr.write(`seonix: serving ${TOOLS.length} tools over ${config.graphFile}\n`);
389
+ }
package/src/source.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // Local graph source — the offline replacement for marginalia's HTTP/A2A `api`
2
+ // layer. The MCP server takes this as an injectable dependency (so tests can
3
+ // stub it); in production it reads the JSON artifact the deterministic indexer
4
+ // wrote to config.graphFile. No network, no model calls.
5
+
6
+ import { readFile } from "node:fs/promises";
7
+ import { ToolError } from "./config.mjs";
8
+
9
+ let cache = null; // { file, payload } — one artifact per process; cheap re-reads.
10
+
11
+ export function clearCache() {
12
+ cache = null;
13
+ }
14
+
15
+ /** Read + parse the local graph artifact. Cached per file for the process. */
16
+ export async function fetchEntities(config) {
17
+ if (cache && cache.file === config.graphFile) return cache.payload;
18
+ let text;
19
+ try {
20
+ text = await readFile(config.graphFile, "utf8");
21
+ } catch (e) {
22
+ throw new ToolError(
23
+ `no graph artifact at ${config.graphFile} — run \`seonix cli index_repository '{"repo_path":"<abs>"}'\` ` +
24
+ `for this repo first (the indexer writes <repo>/.seonix/graph.json). (${e?.code || e?.message || e})`,
25
+ );
26
+ }
27
+ let payload;
28
+ try {
29
+ payload = JSON.parse(text);
30
+ } catch {
31
+ throw new ToolError(`graph artifact ${config.graphFile} is not valid JSON`);
32
+ }
33
+ cache = { file: config.graphFile, payload };
34
+ return payload;
35
+ }
package/src/viz.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // viz.mjs — render a FOCUSED sub-graph of the typed code-map to a single, portable
2
+ // HTML file driven by Cytoscape.js. Deliberately NOT a whole-graph dump (that hangs
3
+ // the browser): we BFS an ego-network around a focus node to a small depth, stop
4
+ // expanding through high-degree hubs, and cap the node count. The HTML embeds the
5
+ // sub-graph + an inlined copy of Cytoscape, and offers controls to navigate
6
+ // (depth/hub sliders), change label verbosity, switch layout, and inspect nodes.
7
+ //
8
+ // `buildSubgraph` is pure and unit-tested; the CLI adds graph loading + file I/O.
9
+
10
+ import { readFile, writeFile } from "node:fs/promises";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { parseArgs } from "node:util";
14
+ import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.mjs";
15
+ import { loadConfig } from "./config.mjs";
16
+ import * as source from "./source.mjs";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const CLASS_COLOR = {
21
+ Module: "#5b9bd5",
22
+ Class: "#ffc000",
23
+ Function: "#70ad47",
24
+ Method: "#9ece6a",
25
+ Attribute: "#bb9af7",
26
+ GlobalVariable: "#e0af68",
27
+ Commit: "#8a8f98",
28
+ };
29
+
30
+ /** Build the adjacency + degree of every node across all typed relations. */
31
+ function adjacency(graph) {
32
+ const adj = new Map(); // id -> [{id, rel}]
33
+ const degree = new Map();
34
+ const bump = (a, b, rel) => {
35
+ if (!adj.has(a)) adj.set(a, []);
36
+ adj.get(a).push({ id: b, rel });
37
+ degree.set(a, (degree.get(a) || 0) + 1);
38
+ };
39
+ for (const g of graph.relations) {
40
+ const rel = relationKind(g) || g.predicate || "rel";
41
+ for (const e of g.edges) {
42
+ if (!e.subject || !e.object) continue;
43
+ bump(e.subject, e.object, rel);
44
+ bump(e.object, e.subject, rel);
45
+ }
46
+ }
47
+ return { adj, degree };
48
+ }
49
+
50
+ /**
51
+ * Focused ego-network around `focusId`: BFS to `depth`, not expanding through nodes
52
+ * whose total degree exceeds `hubDegree` (so hubs appear but don't drag in the world),
53
+ * capped at `maxNodes`. Pure — returns {nodes, edges, focusId, truncated}.
54
+ */
55
+ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNodes = 200 } = {}) {
56
+ if (!graph.byId.has(focusId)) return { nodes: [], edges: [], focusId, truncated: false };
57
+ const { adj, degree } = adjacency(graph);
58
+ const depthOf = new Map([[focusId, 0]]);
59
+ let frontier = [focusId];
60
+ let truncated = false;
61
+ for (let d = 1; d <= depth && frontier.length; d += 1) {
62
+ const next = [];
63
+ for (const id of frontier) {
64
+ // Stop expanding through a hub (but the hub itself is already included).
65
+ if (id !== focusId && (degree.get(id) || 0) > hubDegree) continue;
66
+ for (const { id: nb } of adj.get(id) || []) {
67
+ if (depthOf.has(nb)) continue;
68
+ if (depthOf.size >= maxNodes) { truncated = true; break; }
69
+ depthOf.set(nb, d);
70
+ next.push(nb);
71
+ }
72
+ if (depthOf.size >= maxNodes) { truncated = true; break; }
73
+ }
74
+ frontier = next;
75
+ }
76
+ const inSet = (id) => depthOf.has(id);
77
+ const nodes = [];
78
+ for (const [id, d] of depthOf) {
79
+ const ind = graph.byId.get(id);
80
+ const site = ind ? siteOf(ind) : null;
81
+ nodes.push({
82
+ id,
83
+ label: ind?.label || id,
84
+ cls: ind?.class || "Entity",
85
+ site: site ? `${site.path}:${site.end > site.start ? `${site.start}-${site.end}` : site.start}` : "",
86
+ depth: d,
87
+ degree: degree.get(id) || 0,
88
+ });
89
+ }
90
+ const seenEdge = new Set();
91
+ const edges = [];
92
+ for (const g of graph.relations) {
93
+ const rel = relationKind(g) || g.predicate || "rel";
94
+ for (const e of g.edges) {
95
+ if (!inSet(e.subject) || !inSet(e.object)) continue;
96
+ const key = `${e.subject}|${e.object}|${rel}`;
97
+ if (seenEdge.has(key)) continue;
98
+ seenEdge.add(key);
99
+ edges.push({ source: e.subject, target: e.object, rel });
100
+ }
101
+ }
102
+ return { nodes, edges, focusId, truncated };
103
+ }
104
+
105
+ /** Inline the Cytoscape dist so the HTML is one portable file (no sidecar, no CDN). */
106
+ async function cytoscapeSource() {
107
+ const candidates = [
108
+ join(here, "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
109
+ join(here, "..", "..", "..", "node_modules", "cytoscape", "dist", "cytoscape.min.js"),
110
+ ];
111
+ for (const p of candidates) {
112
+ try { return await readFile(p, "utf8"); } catch { /* try next */ }
113
+ }
114
+ throw new Error("cytoscape not installed — run `npm install` in packages/seonix");
115
+ }
116
+
117
+ export function renderHtml({ subgraph, focusLabel, cytoscape, colors = CLASS_COLOR }) {
118
+ const data = {
119
+ nodes: subgraph.nodes.map((n) => ({ data: { ...n, color: colors[n.cls] || "#8a8f98" } })),
120
+ edges: subgraph.edges.map((e, i) => ({ data: { id: `e${i}`, ...e } })),
121
+ focusId: subgraph.focusId,
122
+ maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
123
+ };
124
+ const legend = Object.entries(colors)
125
+ .map(([k, v]) => `<span class="lg"><i style="background:${v}"></i>${k}</span>`)
126
+ .join(" ");
127
+ return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map — ${focusLabel}</title>
128
+ <style>
129
+ html,body{margin:0;height:100%;font:13px system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
130
+ #bar{padding:8px 12px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
131
+ #bar label{color:#a9b1d6} #bar b{color:#7aa2f7}
132
+ #cy{position:absolute;top:auto;left:0;right:300px;bottom:0;height:calc(100% - 44px)}
133
+ #side{position:absolute;right:0;width:300px;bottom:0;height:calc(100% - 44px);background:#16161e;border-left:1px solid #2a2e42;padding:12px;overflow:auto}
134
+ .lg{margin-right:8px;white-space:nowrap}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}
135
+ input[type=range]{vertical-align:middle} select{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px}
136
+ h3{margin:4px 0;color:#7aa2f7} code{color:#9ece6a;word-break:break-all}
137
+ </style></head><body>
138
+ <div id="bar">
139
+ <span><b>seon</b> focus: <b id="focuslabel">${focusLabel}</b></span>
140
+ <label>depth <input id="depth" type="range" min="1" max="9" value="9"> <span id="depthv"></span></label>
141
+ <label>hide deg&gt; <input id="hub" type="range" min="2" max="200" value="200"> <span id="hubv"></span></label>
142
+ <label>labels <select id="verb"><option value="1">name</option><option value="0">none</option><option value="2">name+site</option></select></label>
143
+ <label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label>
144
+ <button id="fit">fit</button>
145
+ <span style="margin-left:auto">${legend}</span>
146
+ </div>
147
+ <div id="cy"></div>
148
+ <div id="side"><h3>click a node</h3><div id="detail">Nodes: ${data.nodes.length}, edges: ${data.edges.length}${subgraph.truncated ? " (capped)" : ""}.</div></div>
149
+ <script>${cytoscape}</script>
150
+ <script>
151
+ const G=${JSON.stringify(data)};
152
+ const cy=cytoscape({container:document.getElementById('cy'),elements:[...G.nodes,...G.edges],
153
+ style:[
154
+ {selector:'node',style:{'background-color':'data(color)','label':'data(label)','color':'#c0caf5','font-size':10,'text-wrap':'wrap','text-max-width':120,'width':18,'height':18}},
155
+ {selector:'node[id = "'+G.focusId+'"]',style:{'width':30,'height':30,'border-width':3,'border-color':'#f7768e'}},
156
+ {selector:'edge',style:{'label':'data(rel)','width':1,'line-color':'#3b4261','target-arrow-color':'#3b4261','target-arrow-shape':'triangle','curve-style':'bezier','font-size':8,'color':'#7a88cf','text-rotation':'autorotate'}}
157
+ ],
158
+ layout:{name:'cose',animate:false}});
159
+ const $=id=>document.getElementById(id);
160
+ function applyFilters(){
161
+ const d=+$('depth').value, h=+$('hub').value;
162
+ $('depthv').textContent=d; $('hubv').textContent=h;
163
+ cy.batch(()=>cy.nodes().forEach(n=>{
164
+ const vis = n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h);
165
+ n.style('display', vis?'element':'none');
166
+ }));
167
+ }
168
+ function applyLabels(){const v=$('verb').value;cy.nodes().forEach(n=>{
169
+ n.style('label', v==='0'?'':v==='2'?(n.data('label')+(n.data('site')?'\\n'+n.data('site'):'')):n.data('label'));});}
170
+ ['depth','hub'].forEach(id=>$(id).addEventListener('input',applyFilters));
171
+ $('verb').addEventListener('change',applyLabels);
172
+ $('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
173
+ $('fit').addEventListener('click',()=>cy.fit());
174
+ cy.on('tap','node',e=>{const d=e.target.data();
175
+ $('detail').innerHTML='<h3>'+d.label+'</h3>class: '+d.cls+'<br>depth: '+d.depth+' · degree: '+d.degree+
176
+ (d.site?'<br>site: <code>'+d.site+'</code>':'')+'<br>id: <code>'+d.id+'</code>';});
177
+ applyFilters();applyLabels();
178
+ </script></body></html>`;
179
+ }
180
+
181
+ export async function runVizCli(argv) {
182
+ const { values } = parseArgs({
183
+ args: argv,
184
+ options: {
185
+ focus: { type: "string" },
186
+ depth: { type: "string", default: "2" },
187
+ hub: { type: "string", default: "40" },
188
+ max: { type: "string", default: "200" },
189
+ out: { type: "string", default: "seon-graph.html" },
190
+ graph: { type: "string" },
191
+ },
192
+ allowPositionals: false,
193
+ });
194
+ if (!values.focus) {
195
+ process.stderr.write('seonix viz: --focus <symbol> is required (e.g. --focus Truncator --depth 2)\n');
196
+ process.exit(2);
197
+ }
198
+ const config = loadConfig(values.graph ? { ...process.env, SEONIX_GRAPH_FILE: resolve(values.graph) } : process.env);
199
+ const graph = parseEntities(await source.fetchEntities(config));
200
+ const { match } = resolveSymbol(graph, values.focus);
201
+ if (!match) {
202
+ process.stderr.write(`seonix viz: no entity matching "${values.focus}" in ${config.graphFile}\n`);
203
+ process.exit(1);
204
+ }
205
+ const subgraph = buildSubgraph(graph, {
206
+ focusId: match.id,
207
+ depth: Math.max(1, parseInt(values.depth, 10) || 2),
208
+ hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
209
+ maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
210
+ });
211
+ const html = renderHtml({ subgraph, focusLabel: match.label, cytoscape: await cytoscapeSource() });
212
+ const out = resolve(values.out);
213
+ await writeFile(out, html);
214
+ process.stderr.write(
215
+ `seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${match.label}" ` +
216
+ `(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
217
+ );
218
+ }