@polycode-projects/seonix 0.8.0 → 0.9.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/README.md +1 -1
- package/bin/cli.mjs +6 -1
- package/package.json +55 -7
- package/src/ask-browser.bundle.js +644 -35
- package/src/browser.mjs +1 -0
- package/src/codegraph.mjs +30 -17
- package/src/extract.mjs +33 -10
- package/src/interfaces.mjs +171 -0
- package/src/jsts_tsc.mjs +17 -1
- package/src/paths.mjs +31 -0
- package/src/prose.mjs +3 -3
- package/src/schema-docs.mjs +1 -1
- package/src/server.mjs +56 -2
- package/src/sessions.mjs +3 -3
- package/src/source.mjs +16 -3
- package/src/summary.mjs +7 -3
- package/src/toml-config.mjs +13 -0
- package/src/uuid.mjs +1 -1
- package/src/viz.mjs +16 -76
package/src/sessions.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// sessions.mjs — chat sessions as first-class temporal graph data, like commits.
|
|
2
2
|
//
|
|
3
3
|
// A `seonix chat` session leaves two artifacts under the target repo:
|
|
4
|
-
// .seonix/session-<uuidv7>.log — the human-readable transcript (chat
|
|
4
|
+
// .seonix/session-<uuidv7>.log — the human-readable transcript (the chat shim)
|
|
5
5
|
// .seonix/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
|
|
6
6
|
// {"type":"session", id, started, repo, seonixVersion} (header line)
|
|
7
7
|
// {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
|
|
8
8
|
// {"type":"end", ts} (clean close marker)
|
|
9
9
|
//
|
|
10
10
|
// From the sidecar the session enters the typed graph twice:
|
|
11
|
-
// - READ TIME (chat
|
|
11
|
+
// - READ TIME (the chat shim, per turn): appendSessionToGraph() upserts one `Session`
|
|
12
12
|
// individual (`session:<uuidv7>`) + `mgx:asksAbout` edges into graph.json —
|
|
13
13
|
// atomically (temp + rename), re-reading the file first so a re-index that
|
|
14
14
|
// happened mid-session is tolerated: edges whose targets vanished are dropped,
|
|
@@ -141,7 +141,7 @@ export function upsertSession(entities, record) {
|
|
|
141
141
|
return { kept: targets.length, dropped };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/** Read-time append (chat
|
|
144
|
+
/** Read-time append (the chat shim, once per turn): re-read graph.json FRESH (a re-index
|
|
145
145
|
* may have replaced it mid-session), upsert, write back atomically. Throws on a
|
|
146
146
|
* missing/invalid artifact — the caller treats the append as best-effort. */
|
|
147
147
|
export async function appendSessionToGraph(graphFile, record) {
|
package/src/source.mjs
CHANGED
|
@@ -19,12 +19,25 @@ export function clearCache() {
|
|
|
19
19
|
* source JSON can't be stat'd. node:sqlite is imported LAZILY here, so a gate-off load
|
|
20
20
|
* never touches sqlite (no ExperimentalWarning, no store.mjs sqlite import). */
|
|
21
21
|
async function fetchFromStore(config) {
|
|
22
|
+
const { readPayload, storeFileFor } = await import("./store.mjs");
|
|
23
|
+
const dbPath = config.storeFile || storeFileFor(config.graphFile);
|
|
22
24
|
let expectStat;
|
|
23
25
|
try { expectStat = await stat(config.graphFile); }
|
|
24
|
-
catch {
|
|
26
|
+
catch {
|
|
27
|
+
// graph.json is gone. If a built store IS on disk, the freshness check has nothing to verify
|
|
28
|
+
// against and the JSON path below would ENOENT with a generic "no graph artifact" message —
|
|
29
|
+
// confusing when a usable graph.db sits right there. Give the real diagnosis instead. (A1)
|
|
30
|
+
let hasStore = false;
|
|
31
|
+
try { await stat(dbPath); hasStore = true; } catch { /* no store either */ }
|
|
32
|
+
if (hasStore)
|
|
33
|
+
throw new ToolError(
|
|
34
|
+
`graph.db present but graph.json is gone (${config.graphFile}) — the store's freshness ` +
|
|
35
|
+
`check needs graph.json, so re-index this repo: ` +
|
|
36
|
+
`\`seonix cli index_repository '{"repo_path":"<abs>"}'\`.`,
|
|
37
|
+
);
|
|
38
|
+
return null; // no store either → let the JSON path emit its own "run index_repository" error
|
|
39
|
+
}
|
|
25
40
|
try {
|
|
26
|
-
const { readPayload, storeFileFor } = await import("./store.mjs");
|
|
27
|
-
const dbPath = config.storeFile || storeFileFor(config.graphFile);
|
|
28
41
|
return await readPayload(dbPath, { expectStat });
|
|
29
42
|
} catch (e) {
|
|
30
43
|
process.stderr.write(`seonix: sqlite store unavailable (${e?.name || e?.code || e?.message || e}); using graph.json\n`);
|
package/src/summary.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { writeFile, rename, mkdir } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { hubDemote } from "./paths.mjs";
|
|
18
19
|
|
|
19
20
|
// V8's hard maximum string length (chars). A graph.json larger than this can't be
|
|
20
21
|
// JSON.parse'd back into one string — the failure mode the wh estate hit at 80%.
|
|
@@ -69,10 +70,13 @@ export function hubModules(entities, { prefix = "", top = 5 } = {}) {
|
|
|
69
70
|
const rows = [];
|
|
70
71
|
for (const [id, deg] of degree) {
|
|
71
72
|
if (!id.startsWith(want)) continue;
|
|
72
|
-
|
|
73
|
+
const label = labelById.get(id) ?? id.slice(4);
|
|
74
|
+
// Sort weight down-weights test/non-prod hubs (0.8.1) so a shared test harness can't dominate
|
|
75
|
+
// SUMMARY.md's hub list (the wh estate symptom); the reported `degree` stays the raw value.
|
|
76
|
+
rows.push({ id, label, degree: deg, weight: deg * hubDemote(label) });
|
|
73
77
|
}
|
|
74
|
-
rows.sort((a, b) => (b.
|
|
75
|
-
return rows.slice(0, top);
|
|
78
|
+
rows.sort((a, b) => (b.weight - a.weight) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
79
|
+
return rows.slice(0, top).map(({ id, label, degree }) => ({ id, label, degree }));
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
package/src/toml-config.mjs
CHANGED
|
@@ -123,6 +123,19 @@ export async function normalizeConfig(raw, { configDir } = {}) {
|
|
|
123
123
|
const tel = src.telemetry || {};
|
|
124
124
|
if (tel.enabled !== undefined) cfg.telemetry = { enabled: tel.enabled };
|
|
125
125
|
|
|
126
|
+
// [interfaces] — un-typed interface (route↔handler) indexing (PLAN_UNTYPED_INTERFACES.md).
|
|
127
|
+
// OFF by default; enabled emits gated `serves` edges + Route individuals. The matcher knobs
|
|
128
|
+
// (frameworks/verbs/combine_controller_prefix/prefix_strip/match_threshold) are tunable.
|
|
129
|
+
const ifc = src.interfaces || {};
|
|
130
|
+
const interfaces = {};
|
|
131
|
+
if (ifc.enabled !== undefined) interfaces.enabled = ifc.enabled;
|
|
132
|
+
if (ifc.frameworks !== undefined) interfaces.frameworks = ifc.frameworks;
|
|
133
|
+
if (ifc.verbs !== undefined) interfaces.verbs = ifc.verbs;
|
|
134
|
+
if (ifc.combine_controller_prefix !== undefined) interfaces.combineControllerPrefix = ifc.combine_controller_prefix;
|
|
135
|
+
if (ifc.prefix_strip !== undefined) interfaces.prefixStrip = ifc.prefix_strip;
|
|
136
|
+
if (ifc.match_threshold !== undefined) interfaces.matchThreshold = ifc.match_threshold;
|
|
137
|
+
if (Object.keys(interfaces).length) cfg.interfaces = interfaces;
|
|
138
|
+
|
|
126
139
|
const unwired = [];
|
|
127
140
|
for (const [a, b] of UNWIRED_KEYS) {
|
|
128
141
|
if (src[a] && src[a][b] !== undefined) unwired.push(`${a}.${b}`);
|
package/src/uuid.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// uuid.mjs — RFC 9562 UUIDv7 (node:crypto ships v4 only). Lifted verbatim from
|
|
2
|
-
//
|
|
2
|
+
// the chat session id, the telemetry invocation id, and the bench
|
|
3
3
|
// per-run stamp all share ONE time-sortable implementation (no second copy, no dep).
|
|
4
4
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
package/src/viz.mjs
CHANGED
|
@@ -26,8 +26,8 @@ import { loadConfig } from "./config.mjs";
|
|
|
26
26
|
import * as source from "./source.mjs";
|
|
27
27
|
import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
|
|
28
28
|
import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
29
|
+
// The ask panel runs tmct's engine bundled for the browser (see askSource() below), adapter-less —
|
|
30
|
+
// there is no wink lemma tier or ~4 MB browser model.
|
|
31
31
|
|
|
32
32
|
// Portable single-file gate: above this serialized graph size, embedding the
|
|
33
33
|
// full raw graph inline yields an unopenable multi-hundred-MB HTML, so the
|
|
@@ -213,24 +213,12 @@ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
|
213
213
|
* silently produce incomplete answers, which this project's honesty contract
|
|
214
214
|
* doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
|
|
215
215
|
* this is a fourth, for the same one-viewer-many-modes reason).
|
|
216
|
-
*
|
|
217
|
-
* The OPTIONAL wink-nlp lemma tier (ask-nlp.mjs's browser twin, from nlp-bundle.mjs)
|
|
218
|
-
* reaches the chat two ways: `nlpInline` inlines the ~4 MB bundle as its own <script>
|
|
219
|
-
* so it registers `window.__seonixNlp` at page load (portable `viz --nlp`); `nlpPath`
|
|
220
|
-
* points the page at a same-origin sibling asset the chat LAZY-loads on first ask
|
|
221
|
-
* (the SITE build). Neither is present in the default local single-file — that viewer
|
|
222
|
-
* stays lemma-off and fetches nothing, exactly as before, preserving its
|
|
223
|
-
* no-external-fetch guarantee. ask() picks up whatever `window.__seonixNlp` is set,
|
|
224
|
-
* or degrades to the adapter-less tiers when it's absent.
|
|
225
216
|
*/
|
|
226
|
-
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null
|
|
217
|
+
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null } = {}) {
|
|
227
218
|
const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
|
|
228
219
|
const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
|
|
229
|
-
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {})
|
|
220
|
+
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
|
|
230
221
|
const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
|
|
231
|
-
// Inline bundle: escape any literal </script inside the wink model data so the
|
|
232
|
-
// block can't be closed early (harmless inside JS strings, impossible outside them).
|
|
233
|
-
const nlpEmbedded = nlpInline ? `<script>${String(nlpInline).replace(/<\/script/gi, "<\\/script")}</script>\n` : "";
|
|
234
222
|
return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
|
|
235
223
|
<!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
|
|
236
224
|
<style>
|
|
@@ -308,7 +296,7 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
308
296
|
</div>
|
|
309
297
|
${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
|
|
310
298
|
<script>${askEngine}</script>
|
|
311
|
-
|
|
299
|
+
<script>
|
|
312
300
|
// Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
|
|
313
301
|
const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
|
|
314
302
|
const $=id=>document.getElementById(id);
|
|
@@ -351,29 +339,6 @@ function loadAskData(){
|
|
|
351
339
|
})();
|
|
352
340
|
return _askGraphPromise;
|
|
353
341
|
}
|
|
354
|
-
// The OPTIONAL wink-nlp lemma adapter (window.__seonixNlp — see nlp-bundle.mjs).
|
|
355
|
-
// An inlined bundle registers it at page load; the site build ships it as a
|
|
356
|
-
// same-origin sibling this injects LAZILY on the first ask (never at boot), so the
|
|
357
|
-
// ~4 MB model is only paid for when the chat is actually used. With neither present
|
|
358
|
-
// (the default local single-file) this resolves to undefined and the chat runs
|
|
359
|
-
// adapter-less — ask() then falls back to its curated + fuzzy tiers, exactly as
|
|
360
|
-
// before, and NOTHING is fetched, keeping the no-external-fetch guarantee intact.
|
|
361
|
-
let _nlpPromise=null;
|
|
362
|
-
function loadNlp(){
|
|
363
|
-
if(window.__seonixNlp) return Promise.resolve(window.__seonixNlp);
|
|
364
|
-
if(_nlpPromise) return _nlpPromise;
|
|
365
|
-
const cfgEl=document.getElementById('seonix-cfg');
|
|
366
|
-
const url=cfgEl?JSON.parse(cfgEl.textContent).nlp:null;
|
|
367
|
-
if(!url) return Promise.resolve(undefined);
|
|
368
|
-
_nlpPromise=new Promise(res=>{
|
|
369
|
-
const s=document.createElement('script');
|
|
370
|
-
s.src=url;
|
|
371
|
-
s.onload=()=>res(window.__seonixNlp);
|
|
372
|
-
s.onerror=()=>res(undefined); // a failed asset degrades to lemma-off, never a broken chat
|
|
373
|
-
document.head.appendChild(s);
|
|
374
|
-
});
|
|
375
|
-
return _nlpPromise;
|
|
376
|
-
}
|
|
377
342
|
function init(G){
|
|
378
343
|
document.title='seon code-map — '+G.focusLabel;
|
|
379
344
|
$('focuslabel').textContent=G.focusLabel;
|
|
@@ -616,10 +581,9 @@ function init(G){
|
|
|
616
581
|
return;
|
|
617
582
|
}
|
|
618
583
|
out.textContent='thinking…';
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId,nlp});
|
|
584
|
+
// Lazy-load the graph, then run the mechanical ask over it.
|
|
585
|
+
loadAskData().then((askGraph)=>{
|
|
586
|
+
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId});
|
|
623
587
|
const total=(seonix_ask.matches||[]).length;
|
|
624
588
|
const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
|
|
625
589
|
out.classList.toggle('miss',!!seonix_ask.miss);
|
|
@@ -709,12 +673,10 @@ function resolveFocus(graph, focusArg) {
|
|
|
709
673
|
* code browser rides along at `/code-browser.html` (alias `/browser`) with a live
|
|
710
674
|
* temporal payload — the local equivalent of the site's browser section.
|
|
711
675
|
*/
|
|
712
|
-
export async function startVizServer({ values, cytoscape, askEngine = "",
|
|
676
|
+
export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
|
|
713
677
|
const port = Math.max(0, parseInt(values.port, 10) || 0);
|
|
714
678
|
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
715
|
-
|
|
716
|
-
// is served in-process at /seonix-nlp.js and the viewer lazy-loads it on first ask.
|
|
717
|
-
const viewer = renderViewerHtml({ cytoscape, askEngine, nlpPath: nlpBundle ? "/seonix-nlp.js" : null });
|
|
679
|
+
const viewer = renderViewerHtml({ cytoscape, askEngine });
|
|
718
680
|
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
719
681
|
// nav = the routes this server itself serves; injected into every payload/page
|
|
720
682
|
const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
|
|
@@ -815,9 +777,6 @@ export async function startVizServer({ values, cytoscape, askEngine = "", nlpBun
|
|
|
815
777
|
res.writeHead(500, { "content-type": "application/json" });
|
|
816
778
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
817
779
|
}
|
|
818
|
-
} else if (path === "/seonix-nlp.js" && nlpBundle) {
|
|
819
|
-
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
820
|
-
res.end(nlpBundle);
|
|
821
780
|
} else {
|
|
822
781
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
823
782
|
res.end("not found");
|
|
@@ -856,11 +815,6 @@ export async function runVizCli(argv) {
|
|
|
856
815
|
// truncation ("newest N of M commits"). See src/timeline.mjs.
|
|
857
816
|
limit: { type: "string", default: "100" },
|
|
858
817
|
scope: { type: "string", default: "product" },
|
|
859
|
-
// Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
|
|
860
|
-
// Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
|
|
861
|
-
// Portable/serve mode: inlined (single-file) / served in-process. Off by default,
|
|
862
|
-
// so the local single-file stays lean and lemma-off exactly as before.
|
|
863
|
-
nlp: { type: "boolean", default: false },
|
|
864
818
|
// Portable branch only: embed the full graph inline even past the size gate
|
|
865
819
|
// (INLINE_ASKDATA_MAX_BYTES). Off by default — over-threshold graphs write
|
|
866
820
|
// sidecars instead of an unopenable multi-hundred-MB single file.
|
|
@@ -870,11 +824,8 @@ export async function runVizCli(argv) {
|
|
|
870
824
|
});
|
|
871
825
|
const cytoscape = await cytoscapeSource();
|
|
872
826
|
const askEngine = await askSource();
|
|
873
|
-
// The wink lemma tier was removed in Stage 6 (the panel runs tmct's engine adapter-less), so there
|
|
874
|
-
// is never a bundle to wire. `--nlp` is accepted but inert; the downstream null-guards no-op.
|
|
875
|
-
const nlpBundle = null;
|
|
876
827
|
if (values.serve) {
|
|
877
|
-
await startVizServer({ values, cytoscape, askEngine,
|
|
828
|
+
await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
|
|
878
829
|
return; // keeps serving until Ctrl-C
|
|
879
830
|
}
|
|
880
831
|
const { config, graph, raw, graphBytes } = await loadGraph(values);
|
|
@@ -926,32 +877,22 @@ export async function runVizCli(argv) {
|
|
|
926
877
|
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
927
878
|
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
928
879
|
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
929
|
-
// The wink lemma bundle rides the same sibling-asset convention as the data
|
|
930
|
-
// files — a same-origin ./seonix-nlp.js the page lazy-loads on first ask.
|
|
931
|
-
let nlpPath = null;
|
|
932
|
-
if (nlpBundle) {
|
|
933
|
-
const nlpOut = resolve(dirname(out), "seonix-nlp.js");
|
|
934
|
-
await writeFile(nlpOut, nlpBundle);
|
|
935
|
-
nlpPath = "./seonix-nlp.js";
|
|
936
|
-
}
|
|
937
880
|
await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
|
|
938
881
|
await writeFile(out, renderViewerHtml({
|
|
939
882
|
cytoscape, askEngine,
|
|
940
883
|
dataPath: rel === "seonix-graph-data.json" ? null : rel,
|
|
941
884
|
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
942
|
-
nlpPath,
|
|
943
885
|
}));
|
|
944
|
-
return { dataOut, askDataOut
|
|
886
|
+
return { dataOut, askDataOut };
|
|
945
887
|
};
|
|
946
888
|
if (values["data-out"]) {
|
|
947
889
|
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
948
890
|
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
949
891
|
// written next to the display data file rather than embedded in the page.
|
|
950
|
-
const { dataOut, askDataOut
|
|
892
|
+
const { dataOut, askDataOut } = await writeSplitViewer(resolve(values["data-out"]));
|
|
951
893
|
process.stderr.write(
|
|
952
894
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
953
|
-
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}
|
|
954
|
-
`${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
|
|
895
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
955
896
|
);
|
|
956
897
|
} else if (graphBytes > inlineAskDataMaxBytes() && !values["force-inline"]) {
|
|
957
898
|
// portable mode, but the graph is too big to embed: an estate-scale full-graph
|
|
@@ -970,9 +911,8 @@ export async function runVizCli(argv) {
|
|
|
970
911
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
971
912
|
);
|
|
972
913
|
} else {
|
|
973
|
-
// portable mode: one self-contained file, both channels embedded
|
|
974
|
-
|
|
975
|
-
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw, nlpInline: nlpBundle }));
|
|
914
|
+
// portable mode: one self-contained file, both data channels embedded, no external fetch.
|
|
915
|
+
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw }));
|
|
976
916
|
process.stderr.write(
|
|
977
917
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
978
918
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
|