@polycode-projects/seonix 0.7.3 → 0.9.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 +24 -114
- package/bin/cli.mjs +12 -10
- package/package.json +3 -4
- package/src/ask-browser-entry.mjs +43 -0
- package/src/ask-browser.bundle.js +3040 -0
- package/src/browser.mjs +1 -0
- package/src/chat-shim.mjs +37 -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 +62 -8
- package/src/sessions.mjs +3 -3
- package/src/source.mjs +16 -3
- package/src/summary.mjs +7 -3
- package/src/tmct-provider.mjs +37 -0
- package/src/toml-config.mjs +13 -0
- package/src/uuid.mjs +1 -1
- package/src/viz.mjs +21 -86
- package/src/ask-nlp.mjs +0 -73
- package/src/ask-vocab.mjs +0 -687
- package/src/ask.mjs +0 -2419
- package/src/chat.mjs +0 -850
- package/src/nlp-bundle.mjs +0 -120
- package/src/prose-nlp.mjs +0 -52
package/src/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// seonix_ask (PLAN_MECHANICAL_CHAT.md, hot tool): a mechanical, zero-model-call NL query
|
|
14
14
|
// over the graph — collapses the search+describe+traversal composition loop an agent would
|
|
15
|
-
// otherwise hand-compose into one deterministic round-trip
|
|
15
|
+
// otherwise hand-compose into one deterministic round-trip (now tmct's ask engine).
|
|
16
16
|
|
|
17
17
|
import { readFile } from "node:fs/promises";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
renderMethodHistory,
|
|
52
52
|
renderClassHistory,
|
|
53
53
|
} from "./codegraph.mjs";
|
|
54
|
-
import {
|
|
54
|
+
import { makeSeonixProvider } from "./tmct-provider.mjs";
|
|
55
55
|
|
|
56
56
|
const SNIPPET_MAX_LINES = 200;
|
|
57
57
|
|
|
@@ -97,6 +97,7 @@ export const TOOLS = [
|
|
|
97
97
|
required: ["query"],
|
|
98
98
|
properties: {
|
|
99
99
|
query: { type: "string", description: "A free-text question, e.g. \"which functions explicitly couple to logging\"." },
|
|
100
|
+
strict: { type: "boolean", description: "Fail fast on an ambiguous query (error naming the candidates) instead of returning multiple interpretations, so you can re-ask tighter. Default false." },
|
|
100
101
|
},
|
|
101
102
|
},
|
|
102
103
|
},
|
|
@@ -260,7 +261,41 @@ export async function buildContextBundle(args, { config, source = defaultSource,
|
|
|
260
261
|
return { text: out.join("\n"), tier, topup };
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
// C3 (copilot-UX): per-tool accepted argument keys, so a typo'd/unknown key gets a clear error
|
|
265
|
+
// instead of the silent "X is required" the estate session hit. Deliberately generous (a superset of
|
|
266
|
+
// each tool's read keys) so a valid call is never rejected; unlisted tools skip the check.
|
|
267
|
+
const KNOWN_ARGS = {
|
|
268
|
+
seonix_context: ["symbol", "module", "depth", "min", "untuned", "max"],
|
|
269
|
+
seonix_context_more: ["symbol"],
|
|
270
|
+
seonix_describe: ["symbol"],
|
|
271
|
+
seonix_snippet: ["symbol"],
|
|
272
|
+
seonix_signature: ["symbol"],
|
|
273
|
+
seonix_impact: ["module", "depth"],
|
|
274
|
+
seonix_search: ["query", "kind", "decorator", "name", "page", "limit"],
|
|
275
|
+
seonix_members: ["class"],
|
|
276
|
+
seonix_subclasses: ["class"],
|
|
277
|
+
seonix_architecture: ["package"],
|
|
278
|
+
seonix_exports: ["module"],
|
|
279
|
+
seonix_untested: [],
|
|
280
|
+
seonix_ask: ["query", "strict"],
|
|
281
|
+
seonix_tests_for: ["symbol"], seonix_history: ["symbol"], seonix_callers: ["symbol"],
|
|
282
|
+
seonix_callees: ["symbol"], seonix_cochanges: ["symbol"], seonix_calls: ["symbol"],
|
|
283
|
+
seonix_file_history: ["symbol"], seonix_method_history: ["symbol"], seonix_class_history: ["symbol"],
|
|
284
|
+
};
|
|
285
|
+
// CLI/routing keys the cli.mjs entry always injects into `args` but no tool reads — never flagged.
|
|
286
|
+
const BENIGN_ARGS = new Set(["repo_path", "repo_paths", "out_root", "multi_root", "modules", "config", "ignores", "sync", "history_depth"]);
|
|
287
|
+
|
|
263
288
|
export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
|
|
289
|
+
const allowed = KNOWN_ARGS[name];
|
|
290
|
+
if (allowed && args && typeof args === "object" && !Array.isArray(args)) {
|
|
291
|
+
const unknown = Object.keys(args).filter((k) => !allowed.includes(k) && !BENIGN_ARGS.has(k));
|
|
292
|
+
if (unknown.length) {
|
|
293
|
+
throw new ToolError(
|
|
294
|
+
`unknown argument(s) for ${name}: ${unknown.join(", ")}` +
|
|
295
|
+
(allowed.length ? `. Valid: ${allowed.join(", ")}.` : " (this tool takes no arguments)."),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
264
299
|
if (name === "seonix_context") {
|
|
265
300
|
return (await buildContextBundle(args, { config, source })).text;
|
|
266
301
|
}
|
|
@@ -319,19 +354,28 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
319
354
|
if (name === "seonix_impact") {
|
|
320
355
|
const module = String(args?.module || "").trim();
|
|
321
356
|
if (!module) throw new ToolError("module is required");
|
|
357
|
+
// C1 (copilot-UX): optional `depth` bounds the reverse closure. Absent → renderImpact's default
|
|
358
|
+
// (full closure, maxDepth 8) → byte-unchanged. `depth:1` = direct dependents/importers only —
|
|
359
|
+
// the "who imports this, one hop" the copilot estate session asked for.
|
|
360
|
+
const depth = Number.isInteger(args?.depth) && args.depth > 0 ? args.depth : undefined;
|
|
322
361
|
const graph = await loadGraph(config, source);
|
|
323
362
|
const { match } = resolveOrThrow(graph, module, "module");
|
|
324
|
-
return renderImpact(graph, match);
|
|
363
|
+
return renderImpact(graph, match, depth ? { maxDepth: depth } : undefined);
|
|
325
364
|
}
|
|
326
365
|
if (name === "seonix_search") {
|
|
327
366
|
const query = String(args?.query || "").trim();
|
|
328
367
|
const kind = String(args?.kind || "").trim();
|
|
329
368
|
if (!query && !kind) throw new ToolError("query is required");
|
|
330
369
|
const graph = await loadGraph(config, source);
|
|
370
|
+
// C3 (copilot-UX): opt-in paging for large result sets. Absent → today's "top N" output.
|
|
371
|
+
const page = Number.isInteger(args?.page) && args.page > 0 ? args.page : 0;
|
|
372
|
+
const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : undefined;
|
|
331
373
|
return renderSearch(graph, query, {
|
|
332
374
|
kind,
|
|
333
375
|
decorator: String(args?.decorator || "").trim(),
|
|
334
376
|
name: String(args?.name || "").trim(),
|
|
377
|
+
...(page ? { page } : {}),
|
|
378
|
+
...(limit ? { limit } : {}),
|
|
335
379
|
});
|
|
336
380
|
}
|
|
337
381
|
if (name === "seonix_members") {
|
|
@@ -367,11 +411,21 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
367
411
|
const query = String(args?.query || "").trim();
|
|
368
412
|
if (!query) throw new ToolError("query is required");
|
|
369
413
|
const graph = await loadGraph(config, source);
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
414
|
+
// The NL engine is tmct's now (PLAN_CHAT_EXTRACTION.md, the inversion): resolve through tmct's
|
|
415
|
+
// Repository Interface over seonix's graph. The `---seonix_ask---` delimited envelope is preserved
|
|
416
|
+
// so every dispatchTool caller (this MCP handler, the CLI fallback) is unchanged.
|
|
417
|
+
const { content, tmct_ask } = makeSeonixProvider(graph, { sourceAccess: false }).ask(query).value;
|
|
418
|
+
// C2 (copilot-UX): `strict:true` fails fast on an ambiguous query — return an error naming the
|
|
419
|
+
// candidate interpretations so the agent re-asks tighter, instead of paging a multi-interpretation
|
|
420
|
+
// body. Default (strict absent/false) → today's envelope, byte-unchanged.
|
|
421
|
+
if (args?.strict === true && tmct_ask?.ambiguous === true) {
|
|
422
|
+
const cands = (tmct_ask.matches || []).map((m) => `${m.label}${m.module ? ` (${m.module})` : ""}`).join(", ");
|
|
423
|
+
throw new ToolError(
|
|
424
|
+
`ambiguous query "${query}" — ${(tmct_ask.matches || []).length} candidate interpretation(s)` +
|
|
425
|
+
`${cands ? `: ${cands}` : ""}. Re-ask with a more specific query.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return `${content}\n\n---seonix_ask---\n${JSON.stringify(tmct_ask, null, 2)}`;
|
|
375
429
|
}
|
|
376
430
|
if (
|
|
377
431
|
name === "seonix_tests_for" || name === "seonix_history" || name === "seonix_callers" ||
|
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
|
/**
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// tmct-provider.mjs — seonix as a tmct Repository-Interface provider.
|
|
2
|
+
//
|
|
3
|
+
// The inversion (PLAN_CHAT_EXTRACTION.md; tmct's PLAN_REPOSITORY_INTERFACE.md):
|
|
4
|
+
// tmct owns the natural-language interpreter, and seonix hands it graph truth
|
|
5
|
+
// through tmct's typed Repository Interface. seonix's parseEntities() result is
|
|
6
|
+
// byte-identical in shape to tmct's, so tmct's createGraphService consumes it
|
|
7
|
+
// directly. The only seonix-specific step is expanding the interned v2 on-disk
|
|
8
|
+
// graph — which parseEntities already does (via graph-format's expandGraphPayload),
|
|
9
|
+
// and tmct cannot read raw.
|
|
10
|
+
//
|
|
11
|
+
// Graph-only by default: sourceAccess=false, so snippet/context honestly answer
|
|
12
|
+
// miss(NO_SOURCE). A caller with a working tree passes sourceAccess (and, later,
|
|
13
|
+
// a source layer) to serve real bodies.
|
|
14
|
+
import { createGraphService } from "@polycode-projects/the-mechanical-code-talker/graph-service";
|
|
15
|
+
import { parseEntities } from "./codegraph.mjs";
|
|
16
|
+
import { fetchEntities } from "./source.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wrap an already-parsed seonix graph (a parseEntities() result) as a tmct
|
|
20
|
+
* Repository-Interface service object.
|
|
21
|
+
* @param {object} graph a parseEntities() result ({ individuals, byId, relations, … })
|
|
22
|
+
* @param {{sourceAccess?: boolean}} [opts]
|
|
23
|
+
*/
|
|
24
|
+
export function makeSeonixProvider(graph, { sourceAccess = false } = {}) {
|
|
25
|
+
return createGraphService(graph, { sourceAccess });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load a seonix graph from a config ({ graphFile } or a registered provider) and
|
|
30
|
+
* wrap it as a tmct service. parseEntities expands the interned v2 payload.
|
|
31
|
+
* @param {object} config a seonix config ({ graphFile })
|
|
32
|
+
* @param {{sourceAccess?: boolean}} [opts]
|
|
33
|
+
*/
|
|
34
|
+
export async function loadSeonixProvider(config, { sourceAccess = false } = {}) {
|
|
35
|
+
const graph = parseEntities(await fetchEntities(config));
|
|
36
|
+
return makeSeonixProvider(graph, { sourceAccess });
|
|
37
|
+
}
|
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,7 +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
|
-
|
|
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.
|
|
30
31
|
|
|
31
32
|
// Portable single-file gate: above this serialized graph size, embedding the
|
|
32
33
|
// full raw graph inline yields an unopenable multi-hundred-MB HTML, so the
|
|
@@ -189,17 +190,11 @@ export async function cytoscapeSource() {
|
|
|
189
190
|
* render logic. `export`/`import` lines stripped so the three files run as one
|
|
190
191
|
* plain <script> block; nothing here diverges from the node-tested source. */
|
|
191
192
|
async function askSource() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
// silently leaves in place — and unlike a stray declaration, a surviving `import`
|
|
198
|
-
// is a hard SyntaxError in a classic (non-module) inlined <script>, not a quiet bug.
|
|
199
|
-
const strip = (src) => src
|
|
200
|
-
.replace(/^import\s[\s\S]*?from\s+"[^"]+";\s*$/gm, "")
|
|
201
|
-
.replace(/^export (?=(function|const|async function))/gm, "");
|
|
202
|
-
return [strip(codegraph), strip(vocab), strip(ask)].join("\n");
|
|
193
|
+
// The panel's mechanical NL engine is tmct's, prebuilt by scripts/build-ask-bundle.mjs into an
|
|
194
|
+
// IIFE (src/ask-browser.bundle.js) that registers globalThis.ask / parseEntities. We inline it
|
|
195
|
+
// verbatim into the viewer page (PLAN_CHAT_EXTRACTION.md, the tmct inversion). It runs adapter-less
|
|
196
|
+
// in the browser, so the old ~4 MB wink lemma bundle is gone. Regenerate: `npm run build:ask-bundle`.
|
|
197
|
+
return readFile(join(here, "ask-browser.bundle.js"), "utf8");
|
|
203
198
|
}
|
|
204
199
|
|
|
205
200
|
/** JSON safe to sit inside a <script> block (no </script> or comment-open breakouts). */
|
|
@@ -218,24 +213,12 @@ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
|
218
213
|
* silently produce incomplete answers, which this project's honesty contract
|
|
219
214
|
* doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
|
|
220
215
|
* this is a fourth, for the same one-viewer-many-modes reason).
|
|
221
|
-
*
|
|
222
|
-
* The OPTIONAL wink-nlp lemma tier (ask-nlp.mjs's browser twin, from nlp-bundle.mjs)
|
|
223
|
-
* reaches the chat two ways: `nlpInline` inlines the ~4 MB bundle as its own <script>
|
|
224
|
-
* so it registers `window.__seonixNlp` at page load (portable `viz --nlp`); `nlpPath`
|
|
225
|
-
* points the page at a same-origin sibling asset the chat LAZY-loads on first ask
|
|
226
|
-
* (the SITE build). Neither is present in the default local single-file — that viewer
|
|
227
|
-
* stays lemma-off and fetches nothing, exactly as before, preserving its
|
|
228
|
-
* no-external-fetch guarantee. ask() picks up whatever `window.__seonixNlp` is set,
|
|
229
|
-
* or degrades to the adapter-less tiers when it's absent.
|
|
230
216
|
*/
|
|
231
|
-
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 } = {}) {
|
|
232
218
|
const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
|
|
233
219
|
const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
|
|
234
|
-
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {})
|
|
220
|
+
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
|
|
235
221
|
const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
|
|
236
|
-
// Inline bundle: escape any literal </script inside the wink model data so the
|
|
237
|
-
// block can't be closed early (harmless inside JS strings, impossible outside them).
|
|
238
|
-
const nlpEmbedded = nlpInline ? `<script>${String(nlpInline).replace(/<\/script/gi, "<\\/script")}</script>\n` : "";
|
|
239
222
|
return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
|
|
240
223
|
<!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
|
|
241
224
|
<style>
|
|
@@ -313,7 +296,7 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
313
296
|
</div>
|
|
314
297
|
${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
|
|
315
298
|
<script>${askEngine}</script>
|
|
316
|
-
|
|
299
|
+
<script>
|
|
317
300
|
// Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
|
|
318
301
|
const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
|
|
319
302
|
const $=id=>document.getElementById(id);
|
|
@@ -356,29 +339,6 @@ function loadAskData(){
|
|
|
356
339
|
})();
|
|
357
340
|
return _askGraphPromise;
|
|
358
341
|
}
|
|
359
|
-
// The OPTIONAL wink-nlp lemma adapter (window.__seonixNlp — see nlp-bundle.mjs).
|
|
360
|
-
// An inlined bundle registers it at page load; the site build ships it as a
|
|
361
|
-
// same-origin sibling this injects LAZILY on the first ask (never at boot), so the
|
|
362
|
-
// ~4 MB model is only paid for when the chat is actually used. With neither present
|
|
363
|
-
// (the default local single-file) this resolves to undefined and the chat runs
|
|
364
|
-
// adapter-less — ask() then falls back to its curated + fuzzy tiers, exactly as
|
|
365
|
-
// before, and NOTHING is fetched, keeping the no-external-fetch guarantee intact.
|
|
366
|
-
let _nlpPromise=null;
|
|
367
|
-
function loadNlp(){
|
|
368
|
-
if(window.__seonixNlp) return Promise.resolve(window.__seonixNlp);
|
|
369
|
-
if(_nlpPromise) return _nlpPromise;
|
|
370
|
-
const cfgEl=document.getElementById('seonix-cfg');
|
|
371
|
-
const url=cfgEl?JSON.parse(cfgEl.textContent).nlp:null;
|
|
372
|
-
if(!url) return Promise.resolve(undefined);
|
|
373
|
-
_nlpPromise=new Promise(res=>{
|
|
374
|
-
const s=document.createElement('script');
|
|
375
|
-
s.src=url;
|
|
376
|
-
s.onload=()=>res(window.__seonixNlp);
|
|
377
|
-
s.onerror=()=>res(undefined); // a failed asset degrades to lemma-off, never a broken chat
|
|
378
|
-
document.head.appendChild(s);
|
|
379
|
-
});
|
|
380
|
-
return _nlpPromise;
|
|
381
|
-
}
|
|
382
342
|
function init(G){
|
|
383
343
|
document.title='seon code-map — '+G.focusLabel;
|
|
384
344
|
$('focuslabel').textContent=G.focusLabel;
|
|
@@ -621,10 +581,9 @@ function init(G){
|
|
|
621
581
|
return;
|
|
622
582
|
}
|
|
623
583
|
out.textContent='thinking…';
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
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});
|
|
628
587
|
const total=(seonix_ask.matches||[]).length;
|
|
629
588
|
const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
|
|
630
589
|
out.classList.toggle('miss',!!seonix_ask.miss);
|
|
@@ -714,12 +673,10 @@ function resolveFocus(graph, focusArg) {
|
|
|
714
673
|
* code browser rides along at `/code-browser.html` (alias `/browser`) with a live
|
|
715
674
|
* temporal payload — the local equivalent of the site's browser section.
|
|
716
675
|
*/
|
|
717
|
-
export async function startVizServer({ values, cytoscape, askEngine = "",
|
|
676
|
+
export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
|
|
718
677
|
const port = Math.max(0, parseInt(values.port, 10) || 0);
|
|
719
678
|
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
720
|
-
|
|
721
|
-
// is served in-process at /seonix-nlp.js and the viewer lazy-loads it on first ask.
|
|
722
|
-
const viewer = renderViewerHtml({ cytoscape, askEngine, nlpPath: nlpBundle ? "/seonix-nlp.js" : null });
|
|
679
|
+
const viewer = renderViewerHtml({ cytoscape, askEngine });
|
|
723
680
|
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
724
681
|
// nav = the routes this server itself serves; injected into every payload/page
|
|
725
682
|
const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
|
|
@@ -820,9 +777,6 @@ export async function startVizServer({ values, cytoscape, askEngine = "", nlpBun
|
|
|
820
777
|
res.writeHead(500, { "content-type": "application/json" });
|
|
821
778
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
822
779
|
}
|
|
823
|
-
} else if (path === "/seonix-nlp.js" && nlpBundle) {
|
|
824
|
-
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
825
|
-
res.end(nlpBundle);
|
|
826
780
|
} else {
|
|
827
781
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
828
782
|
res.end("not found");
|
|
@@ -861,11 +815,6 @@ export async function runVizCli(argv) {
|
|
|
861
815
|
// truncation ("newest N of M commits"). See src/timeline.mjs.
|
|
862
816
|
limit: { type: "string", default: "100" },
|
|
863
817
|
scope: { type: "string", default: "product" },
|
|
864
|
-
// Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
|
|
865
|
-
// Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
|
|
866
|
-
// Portable/serve mode: inlined (single-file) / served in-process. Off by default,
|
|
867
|
-
// so the local single-file stays lean and lemma-off exactly as before.
|
|
868
|
-
nlp: { type: "boolean", default: false },
|
|
869
818
|
// Portable branch only: embed the full graph inline even past the size gate
|
|
870
819
|
// (INLINE_ASKDATA_MAX_BYTES). Off by default — over-threshold graphs write
|
|
871
820
|
// sidecars instead of an unopenable multi-hundred-MB single file.
|
|
@@ -875,11 +824,8 @@ export async function runVizCli(argv) {
|
|
|
875
824
|
});
|
|
876
825
|
const cytoscape = await cytoscapeSource();
|
|
877
826
|
const askEngine = await askSource();
|
|
878
|
-
// Build the wink lemma bundle once, only when --nlp asked for it (~4 MB; skip the
|
|
879
|
-
// work entirely otherwise). Wired below as a sibling asset (site) or inline (portable).
|
|
880
|
-
const nlpBundle = values.nlp ? await winkBrowserBundle() : null;
|
|
881
827
|
if (values.serve) {
|
|
882
|
-
await startVizServer({ values, cytoscape, askEngine,
|
|
828
|
+
await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
|
|
883
829
|
return; // keeps serving until Ctrl-C
|
|
884
830
|
}
|
|
885
831
|
const { config, graph, raw, graphBytes } = await loadGraph(values);
|
|
@@ -931,32 +877,22 @@ export async function runVizCli(argv) {
|
|
|
931
877
|
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
932
878
|
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
933
879
|
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
934
|
-
// The wink lemma bundle rides the same sibling-asset convention as the data
|
|
935
|
-
// files — a same-origin ./seonix-nlp.js the page lazy-loads on first ask.
|
|
936
|
-
let nlpPath = null;
|
|
937
|
-
if (nlpBundle) {
|
|
938
|
-
const nlpOut = resolve(dirname(out), "seonix-nlp.js");
|
|
939
|
-
await writeFile(nlpOut, nlpBundle);
|
|
940
|
-
nlpPath = "./seonix-nlp.js";
|
|
941
|
-
}
|
|
942
880
|
await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
|
|
943
881
|
await writeFile(out, renderViewerHtml({
|
|
944
882
|
cytoscape, askEngine,
|
|
945
883
|
dataPath: rel === "seonix-graph-data.json" ? null : rel,
|
|
946
884
|
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
947
|
-
nlpPath,
|
|
948
885
|
}));
|
|
949
|
-
return { dataOut, askDataOut
|
|
886
|
+
return { dataOut, askDataOut };
|
|
950
887
|
};
|
|
951
888
|
if (values["data-out"]) {
|
|
952
889
|
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
953
890
|
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
954
891
|
// written next to the display data file rather than embedded in the page.
|
|
955
|
-
const { dataOut, askDataOut
|
|
892
|
+
const { dataOut, askDataOut } = await writeSplitViewer(resolve(values["data-out"]));
|
|
956
893
|
process.stderr.write(
|
|
957
894
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
958
|
-
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}
|
|
959
|
-
`${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
|
|
895
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
960
896
|
);
|
|
961
897
|
} else if (graphBytes > inlineAskDataMaxBytes() && !values["force-inline"]) {
|
|
962
898
|
// portable mode, but the graph is too big to embed: an estate-scale full-graph
|
|
@@ -975,9 +911,8 @@ export async function runVizCli(argv) {
|
|
|
975
911
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
976
912
|
);
|
|
977
913
|
} else {
|
|
978
|
-
// portable mode: one self-contained file, both channels embedded
|
|
979
|
-
|
|
980
|
-
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 }));
|
|
981
916
|
process.stderr.write(
|
|
982
917
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
983
918
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
|
package/src/ask-nlp.mjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
// ask-nlp.mjs — the OPTIONAL wink-nlp adapter behind ask.mjs's lemma/POS tier.
|
|
2
|
-
//
|
|
3
|
-
// BOUNDARY (hard, do not move): this file is Node-only and is NEVER inlined into
|
|
4
|
-
// the viewer bundle. viz.mjs's askSource() inlines codegraph.mjs + ask-vocab.mjs +
|
|
5
|
-
// ask.mjs ONLY and strips their import lines, so the portable single-file HTML has
|
|
6
|
-
// no wink, no model, and no `nlpAdapter` binding at all — ask.mjs reaches this
|
|
7
|
-
// module exclusively through a `typeof nlpAdapter === "function"` guard and
|
|
8
|
-
// degrades to adapter-less parsing (lemma/POS tiers off; the curated tables and
|
|
9
|
-
// the bounded edit-distance tier still work, browser and Node alike). Keeping the
|
|
10
|
-
// ~1MB CJS model out of the page is the point of the split.
|
|
11
|
-
//
|
|
12
|
-
// wink-nlp and wink-eng-lite-web-model are CJS — loaded via createRequire, the
|
|
13
|
-
// same Node-module-resolution approach viz.mjs uses to locate cytoscape (resolve
|
|
14
|
-
// through the module system, never a guessed path). The require happens lazily on
|
|
15
|
-
// first use and failure is cached as null: a checkout without the optional deps
|
|
16
|
-
// installed answers exactly like the browser bundle, it never throws.
|
|
17
|
-
|
|
18
|
-
import { createRequire } from "node:module";
|
|
19
|
-
|
|
20
|
-
let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
|
|
21
|
-
|
|
22
|
-
/** Lazily build the {lemma, posTags} adapter, or null when wink isn't loadable.
|
|
23
|
-
* Deterministic: wink-nlp's tagger/lemmatiser is a fixed model with no sampling,
|
|
24
|
-
* so the same input always yields the same tags/lemmas across processes. */
|
|
25
|
-
export function nlpAdapter() {
|
|
26
|
-
if (cached !== undefined) return cached;
|
|
27
|
-
try {
|
|
28
|
-
const require = createRequire(import.meta.url);
|
|
29
|
-
const winkNLP = require("wink-nlp");
|
|
30
|
-
const model = require("wink-eng-lite-web-model");
|
|
31
|
-
const nlp = winkNLP(model);
|
|
32
|
-
const its = nlp.its;
|
|
33
|
-
cached = {
|
|
34
|
-
/** Lowercase lemma of a single token ("imported" -> "import"); the word
|
|
35
|
-
* itself when wink has nothing better (unknown words come back as-is). */
|
|
36
|
-
lemma(word) {
|
|
37
|
-
const w = String(word || "");
|
|
38
|
-
try {
|
|
39
|
-
const out = nlp.readDoc(w).tokens().out(its.lemma);
|
|
40
|
-
return String(out[0] || w).toLowerCase();
|
|
41
|
-
} catch {
|
|
42
|
-
return w.toLowerCase();
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
/** UPOS tags aligned to the CALLER's word array. wink re-tokenizes (it
|
|
46
|
-
* splits "walk.mjs" into three tokens), so each input word is greedily
|
|
47
|
-
* matched to the run of wink tokens that spell it and takes its FIRST
|
|
48
|
-
* sub-token's tag; null per word on any surprise, never a throw. */
|
|
49
|
-
posTags(words) {
|
|
50
|
-
try {
|
|
51
|
-
const toks = nlp.readDoc(words.join(" ")).tokens();
|
|
52
|
-
const texts = toks.out();
|
|
53
|
-
const tags = toks.out(its.pos);
|
|
54
|
-
const out = [];
|
|
55
|
-
let k = 0;
|
|
56
|
-
for (const w of words) {
|
|
57
|
-
if (k >= texts.length) { out.push(null); continue; }
|
|
58
|
-
out.push(tags[k]);
|
|
59
|
-
let acc = texts[k];
|
|
60
|
-
k += 1;
|
|
61
|
-
while (acc.length < w.length && k < texts.length) { acc += texts[k]; k += 1; }
|
|
62
|
-
}
|
|
63
|
-
return out;
|
|
64
|
-
} catch {
|
|
65
|
-
return words.map(() => null);
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
} catch {
|
|
70
|
-
cached = null;
|
|
71
|
-
}
|
|
72
|
-
return cached;
|
|
73
|
-
}
|