@polycode-projects/seonix 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/viz.mjs CHANGED
@@ -26,6 +26,7 @@ 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
+ import { winkBrowserBundle } from "./nlp-bundle.mjs";
29
30
 
30
31
  const here = dirname(fileURLToPath(import.meta.url));
31
32
  const execFileP = promisify(execFile);
@@ -203,12 +204,24 @@ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
203
204
  * silently produce incomplete answers, which this project's honesty contract
204
205
  * doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
205
206
  * this is a fourth, for the same one-viewer-many-modes reason).
207
+ *
208
+ * The OPTIONAL wink-nlp lemma tier (ask-nlp.mjs's browser twin, from nlp-bundle.mjs)
209
+ * reaches the chat two ways: `nlpInline` inlines the ~4 MB bundle as its own <script>
210
+ * so it registers `window.__seonixNlp` at page load (portable `viz --nlp`); `nlpPath`
211
+ * points the page at a same-origin sibling asset the chat LAZY-loads on first ask
212
+ * (the SITE build). Neither is present in the default local single-file — that viewer
213
+ * stays lemma-off and fetches nothing, exactly as before, preserving its
214
+ * no-external-fetch guarantee. ask() picks up whatever `window.__seonixNlp` is set,
215
+ * or degrades to the adapter-less tiers when it's absent.
206
216
  */
207
- 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, nlpInline = null, nlpPath = null } = {}) {
208
218
  const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
209
219
  const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
210
- const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
220
+ const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}), ...(nlpPath ? { nlp: nlpPath } : {}) };
211
221
  const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
222
+ // Inline bundle: escape any literal </script inside the wink model data so the
223
+ // block can't be closed early (harmless inside JS strings, impossible outside them).
224
+ const nlpEmbedded = nlpInline ? `<script>${String(nlpInline).replace(/<\/script/gi, "<\\/script")}</script>\n` : "";
212
225
  return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
213
226
  <!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
214
227
  <style>
@@ -226,13 +239,20 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
226
239
  #depthval{min-width:1.2em;text-align:center;display:inline-block;cursor:help}
227
240
  #hub,#beam{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
228
241
  #legend{padding:4px 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px;flex:0 0 auto}
229
- #main{flex:1 1 auto;position:relative;min-height:0}
230
- #cy{position:absolute;top:0;left:0;right:300px;bottom:0}
231
- #side{position:absolute;top:0;right:0;width:300px;bottom:0;background:#16161e;border-left:1px solid #2a2e42;padding:14px;overflow:auto;box-sizing:border-box}
242
+ /* Graph on top (full width, generous height); the chat + node-detail ride a
243
+ full-width row BENEATH it (chat grows, detail a compact fixed column). On a
244
+ narrow screen #below stacks so the body never scrolls sideways. */
245
+ #main{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
246
+ #cy{flex:1 1 auto;position:relative;min-height:260px}
247
+ #below{flex:0 0 auto;display:flex;background:#16161e;border-top:1px solid #2a2e42;max-height:46vh;box-sizing:border-box}
248
+ #ask{flex:1 1 auto;min-width:0;padding:14px 16px;overflow:auto;box-sizing:border-box;display:flex;flex-direction:column}
249
+ #detail{flex:0 0 300px;padding:14px;overflow:auto;border-left:1px solid #2a2e42;box-sizing:border-box}
250
+ @media(max-width:720px){#below{flex-direction:column;max-height:58vh;overflow:auto}#detail{flex:0 0 auto;border-left:0;border-top:1px solid #2a2e42}}
232
251
  .lg{white-space:nowrap;cursor:pointer;user-select:none;color:#a9b1d6}.lg i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:middle}.lg input{vertical-align:middle;margin:0 3px 0 0}
233
252
  .lg .cnt{color:#565f89;margin-left:3px;font-size:11px;font-variant-numeric:tabular-nums}
234
253
  select{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;font:inherit}
235
- #side h3{margin:2px 0 8px;color:#c0caf5;font-size:15px;word-break:break-all}
254
+ #detail h3{margin:2px 0 8px;color:#c0caf5;font-size:15px;word-break:break-all}
255
+ #ask h4{margin:0 0 8px;color:#a9b1d6;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
236
256
  .badge{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border:1px solid #2a2e42;border-radius:10px;font-size:11px;color:#a9b1d6}
237
257
  .badge i{width:8px;height:8px;border-radius:50%;display:inline-block}
238
258
  #detail dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:10px 0;font-size:12px;color:#c0caf5}
@@ -243,11 +263,10 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
243
263
  .hint{color:#565f89;font-size:11px;margin-top:10px}
244
264
  .empty{color:#a9b1d6;font-size:12px;line-height:1.7}
245
265
  code{color:#9aa5ce;word-break:break-all}
246
- #ask{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid #2a2e42}
247
266
  .askrow{display:flex;gap:6px}
248
- #askq{flex:1;min-width:0;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:4px 8px;font:inherit}
267
+ #askq{flex:1;min-width:0;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:7px 10px;font:inherit;font-size:13px}
249
268
  #askq:focus{outline:none;border-color:#7aa2f7}
250
- .askresult{margin-top:8px;font-size:12px;line-height:1.6;color:#c0caf5}
269
+ .askresult{margin-top:10px;flex:1 1 auto;font-size:12.5px;line-height:1.6;color:#c0caf5;overflow:auto}
251
270
  .askresult.miss{color:#a9b1d6;font-style:italic}
252
271
  .askresult .askq-echo{color:#565f89;margin-bottom:4px;font-style:normal}
253
272
  </style></head><body>
@@ -269,8 +288,9 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
269
288
  <div id="legend"></div>
270
289
  <div id="main">
271
290
  <div id="cy"></div>
272
- <div id="side">
291
+ <div id="below">
273
292
  <div id="ask">
293
+ <h4>Ask the graph</h4>
274
294
  <div class="askrow"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "what calls this"'><button class="btn" id="asksubmit">ask</button></div>
275
295
  <div id="askresult" class="askresult"></div>
276
296
  </div>
@@ -279,7 +299,7 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
279
299
  </div>
280
300
  ${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
281
301
  <script>${askEngine}</script>
282
- <script>
302
+ ${nlpEmbedded}<script>
283
303
  // Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
284
304
  const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
285
305
  const $=id=>document.getElementById(id);
@@ -322,6 +342,29 @@ function loadAskData(){
322
342
  })();
323
343
  return _askGraphPromise;
324
344
  }
345
+ // The OPTIONAL wink-nlp lemma adapter (window.__seonixNlp — see nlp-bundle.mjs).
346
+ // An inlined bundle registers it at page load; the site build ships it as a
347
+ // same-origin sibling this injects LAZILY on the first ask (never at boot), so the
348
+ // ~4 MB model is only paid for when the chat is actually used. With neither present
349
+ // (the default local single-file) this resolves to undefined and the chat runs
350
+ // adapter-less — ask() then falls back to its curated + fuzzy tiers, exactly as
351
+ // before, and NOTHING is fetched, keeping the no-external-fetch guarantee intact.
352
+ let _nlpPromise=null;
353
+ function loadNlp(){
354
+ if(window.__seonixNlp) return Promise.resolve(window.__seonixNlp);
355
+ if(_nlpPromise) return _nlpPromise;
356
+ const cfgEl=document.getElementById('seonix-cfg');
357
+ const url=cfgEl?JSON.parse(cfgEl.textContent).nlp:null;
358
+ if(!url) return Promise.resolve(undefined);
359
+ _nlpPromise=new Promise(res=>{
360
+ const s=document.createElement('script');
361
+ s.src=url;
362
+ s.onload=()=>res(window.__seonixNlp);
363
+ s.onerror=()=>res(undefined); // a failed asset degrades to lemma-off, never a broken chat
364
+ document.head.appendChild(s);
365
+ });
366
+ return _nlpPromise;
367
+ }
325
368
  function init(G){
326
369
  document.title='seon code-map — '+G.focusLabel;
327
370
  $('focuslabel').textContent=G.focusLabel;
@@ -564,8 +607,10 @@ function init(G){
564
607
  return;
565
608
  }
566
609
  out.textContent='thinking…';
567
- loadAskData().then(askGraph=>{
568
- const {content,seonix_ask}=ask(askGraph,query,{contextId:selId});
610
+ // Load the graph and (if configured) the wink lemma adapter together, then run
611
+ // the mechanical ask. An undefined nlp makes ask() degrade to its adapter-less tiers.
612
+ Promise.all([loadAskData(),loadNlp()]).then(([askGraph,nlp])=>{
613
+ const {content,seonix_ask}=ask(askGraph,query,{contextId:selId,nlp});
569
614
  const total=(seonix_ask.matches||[]).length;
570
615
  const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
571
616
  out.classList.toggle('miss',!!seonix_ask.miss);
@@ -647,10 +692,12 @@ function resolveFocus(graph, focusArg) {
647
692
  * code browser rides along at `/code-browser.html` (alias `/browser`) with a live
648
693
  * temporal payload — the local equivalent of the site's browser section.
649
694
  */
650
- export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
695
+ export async function startVizServer({ values, cytoscape, askEngine = "", nlpBundle = null, log = () => {} }) {
651
696
  const port = Math.max(0, parseInt(values.port, 10) || 0);
652
697
  const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
653
- const viewer = renderViewerHtml({ cytoscape, askEngine });
698
+ // With --nlp the local live view gets the same lemma tier as the site: the bundle
699
+ // is served in-process at /seonix-nlp.js and the viewer lazy-loads it on first ask.
700
+ const viewer = renderViewerHtml({ cytoscape, askEngine, nlpPath: nlpBundle ? "/seonix-nlp.js" : null });
654
701
  const browserPage = await renderBrowserHtml({ cytoscape });
655
702
  // nav = the routes this server itself serves; injected into every payload/page
656
703
  const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
@@ -748,6 +795,9 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
748
795
  res.writeHead(500, { "content-type": "application/json" });
749
796
  res.end(JSON.stringify({ error: String(err.message || err) }));
750
797
  }
798
+ } else if (path === "/seonix-nlp.js" && nlpBundle) {
799
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
800
+ res.end(nlpBundle);
751
801
  } else {
752
802
  res.writeHead(404, { "content-type": "text/plain" });
753
803
  res.end("not found");
@@ -782,13 +832,21 @@ export async function runVizCli(argv) {
782
832
  "timeline-out": { type: "string" },
783
833
  "graph-only": { type: "boolean", default: false },
784
834
  scope: { type: "string", default: "product" },
835
+ // Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
836
+ // Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
837
+ // Portable/serve mode: inlined (single-file) / served in-process. Off by default,
838
+ // so the local single-file stays lean and lemma-off exactly as before.
839
+ nlp: { type: "boolean", default: false },
785
840
  },
786
841
  allowPositionals: false,
787
842
  });
788
843
  const cytoscape = await cytoscapeSource();
789
844
  const askEngine = await askSource();
845
+ // Build the wink lemma bundle once, only when --nlp asked for it (~4 MB; skip the
846
+ // work entirely otherwise). Wired below as a sibling asset (site) or inline (portable).
847
+ const nlpBundle = values.nlp ? await winkBrowserBundle() : null;
790
848
  if (values.serve) {
791
- await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
849
+ await startVizServer({ values, cytoscape, askEngine, nlpBundle, log: (s) => process.stderr.write(s) });
792
850
  return; // keeps serving until Ctrl-C
793
851
  }
794
852
  const { config, graph, raw } = await loadGraph(values);
@@ -838,19 +896,30 @@ export async function runVizCli(argv) {
838
896
  const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
839
897
  const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
840
898
  const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
899
+ // Site build: the wink lemma bundle rides the same sibling-asset convention as
900
+ // the data files — a same-origin ./seonix-nlp.js the page lazy-loads on first ask.
901
+ let nlpPath = null;
902
+ if (nlpBundle) {
903
+ const nlpOut = resolve(dirname(out), "seonix-nlp.js");
904
+ await writeFile(nlpOut, nlpBundle);
905
+ nlpPath = "./seonix-nlp.js";
906
+ }
841
907
  await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
842
908
  await writeFile(out, renderViewerHtml({
843
909
  cytoscape, askEngine,
844
910
  dataPath: rel === "seonix-graph-data.json" ? null : rel,
845
911
  askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
912
+ nlpPath,
846
913
  }));
847
914
  process.stderr.write(
848
915
  `seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
849
- `(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
916
+ `(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}` +
917
+ `${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
850
918
  );
851
919
  } else {
852
- // portable mode: one self-contained file, both channels embedded
853
- await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw }));
920
+ // portable mode: one self-contained file, both channels embedded (+ inline wink
921
+ // bundle when --nlp; otherwise lemma-off, no external fetch, exactly as before)
922
+ await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw, nlpInline: nlpBundle }));
854
923
  process.stderr.write(
855
924
  `seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
856
925
  `(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,