@polycode-projects/seonix 0.6.0 → 0.7.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 +280 -4
- package/bin/cli.mjs +313 -17
- package/package.json +2 -1
- package/src/ask.mjs +14 -0
- package/src/browser.mjs +16 -7
- package/src/chat.mjs +79 -14
- package/src/codegraph.mjs +7 -0
- package/src/config.mjs +7 -1
- package/src/extract.mjs +405 -48
- package/src/graph-format.mjs +156 -0
- package/src/graph-ops.mjs +92 -0
- package/src/manifest.mjs +148 -0
- package/src/server.mjs +11 -0
- package/src/sessions.mjs +7 -2
- package/src/source.mjs +34 -4
- package/src/store.mjs +282 -0
- package/src/summary.mjs +241 -0
- package/src/telemetry.mjs +90 -0
- package/src/timeline.mjs +12 -4
- package/src/toml-config.mjs +183 -0
- package/src/uuid.mjs +16 -0
- package/src/viz.mjs +72 -12
- package/src/walk.mjs +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// seonix.toml loader (pure library — no cli wiring here).
|
|
2
|
+
//
|
|
3
|
+
// The MCP server's runtime config still lives in config.mjs (`loadConfig`, the
|
|
4
|
+
// SEONIX_GRAPH_FILE knob) and stays byte-identical. This module is the *product*
|
|
5
|
+
// config surface: an optional `seonix.toml` at a repo/estate root that a consumer
|
|
6
|
+
// checks in to steer indexing and scoring. Absent file = shipped defaults (today's
|
|
7
|
+
// behaviour, byte-for-byte).
|
|
8
|
+
//
|
|
9
|
+
// Three pure entry points, consumed later by the cli:
|
|
10
|
+
// loadTomlConfig(rootDir) → raw parsed TOML, or null when absent
|
|
11
|
+
// normalizeConfig(raw,{configDir}) → canonical sparse shape (present keys only)
|
|
12
|
+
// mergeEffective({args,toml,defaults}) → {effective, sources} (arg>toml>default)
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { parse } from "smol-toml";
|
|
17
|
+
|
|
18
|
+
export const CONFIG_FILE = "seonix.toml";
|
|
19
|
+
|
|
20
|
+
/** Read `<rootDir>/seonix.toml` and return the raw parsed table.
|
|
21
|
+
* - Absent file → `null` (the "today" signal: shipped defaults, byte-identical).
|
|
22
|
+
* - Present but unparseable → throws a clear error naming the file + parse cause
|
|
23
|
+
* (a `secret_exclude` misparse is security-relevant — never swallow it). */
|
|
24
|
+
export async function loadTomlConfig(rootDir) {
|
|
25
|
+
const file = join(rootDir, CONFIG_FILE);
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = await readFile(file, "utf8");
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err && err.code === "ENOENT") return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return parse(text);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`Invalid ${CONFIG_FILE} at ${file}: ${err && err.message ? err.message : err}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve the `repositories` value → absolute paths.
|
|
41
|
+
* - array → each entry resolved relative to `configDir`
|
|
42
|
+
* - string → path to a newline-delimited file (relative to `configDir`); each
|
|
43
|
+
* non-blank, non-`#` line resolved relative to `configDir`. */
|
|
44
|
+
async function resolveRepositories(value, configDir) {
|
|
45
|
+
let entries;
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
entries = value;
|
|
48
|
+
} else {
|
|
49
|
+
const file = resolve(configDir, String(value));
|
|
50
|
+
const text = await readFile(file, "utf8");
|
|
51
|
+
entries = text.split("\n");
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const raw of entries) {
|
|
55
|
+
const line = String(raw).trim();
|
|
56
|
+
if (!line || line.startsWith("#")) continue;
|
|
57
|
+
out.push(resolve(configDir, line));
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Recognized keys that parse and normalize but are not yet honoured by any
|
|
63
|
+
// consumer. Surfaced in `normalizeConfig().unwired` so the cli can warn once
|
|
64
|
+
// rather than silently ignore them.
|
|
65
|
+
const UNWIRED_KEYS = [
|
|
66
|
+
["index", "include_text"],
|
|
67
|
+
["index", "include_structure"],
|
|
68
|
+
["index", "respect_gitignore"],
|
|
69
|
+
["index", "markdown_sections"],
|
|
70
|
+
["index", "vue"],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Map a raw parsed TOML table → a canonical, sparse shape. Only keys actually
|
|
74
|
+
* present in `raw` appear (so a consumer can tell "unset" from "set to a value
|
|
75
|
+
* that equals the default" — the tri-state that `mergeEffective` relies on).
|
|
76
|
+
* `configDir` anchors every relative path. Async because `repositories` may name
|
|
77
|
+
* a newline-delimited file that has to be read. */
|
|
78
|
+
export async function normalizeConfig(raw, { configDir } = {}) {
|
|
79
|
+
const src = raw || {};
|
|
80
|
+
const dir = configDir || process.cwd();
|
|
81
|
+
const cfg = {};
|
|
82
|
+
|
|
83
|
+
if (src.repositories !== undefined) {
|
|
84
|
+
cfg.repositories = await resolveRepositories(src.repositories, dir);
|
|
85
|
+
}
|
|
86
|
+
if (src.out_root !== undefined) {
|
|
87
|
+
cfg.outRoot = resolve(dir, String(src.out_root));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const idx = src.index || {};
|
|
91
|
+
const index = {};
|
|
92
|
+
if (idx.languages !== undefined) index.languages = idx.languages;
|
|
93
|
+
if (idx.exclude !== undefined) index.exclude = idx.exclude;
|
|
94
|
+
if (idx.secret_exclude !== undefined) index.secretExclude = idx.secret_exclude;
|
|
95
|
+
if (idx.history_depth !== undefined) index.historyDepth = idx.history_depth;
|
|
96
|
+
if (idx.include_text !== undefined) index.includeText = idx.include_text;
|
|
97
|
+
if (idx.include_structure !== undefined) index.includeStructure = idx.include_structure;
|
|
98
|
+
if (idx.respect_gitignore !== undefined) index.respectGitignore = idx.respect_gitignore;
|
|
99
|
+
if (idx.markdown_sections !== undefined) index.markdownSections = idx.markdown_sections;
|
|
100
|
+
if (idx.vue !== undefined) index.vue = idx.vue;
|
|
101
|
+
if (Object.keys(index).length) cfg.index = index;
|
|
102
|
+
|
|
103
|
+
const t = src.tune || {};
|
|
104
|
+
const tune = {};
|
|
105
|
+
if (t.score_gap_k !== undefined) tune.scoreGapK = t.score_gap_k;
|
|
106
|
+
if (t.literal_mention !== undefined) tune.literalMention = t.literal_mention;
|
|
107
|
+
if (t.demote_non_prod !== undefined) tune.demoteNonProd = t.demote_non_prod;
|
|
108
|
+
if (t.call_adjacency !== undefined) tune.callAdjacency = t.call_adjacency;
|
|
109
|
+
if (t.impl_of_interface !== undefined) tune.implOfInterface = t.impl_of_interface;
|
|
110
|
+
if (t.beam_search !== undefined) tune.beamSearch = t.beam_search;
|
|
111
|
+
if (t.beam_width !== undefined) tune.beamWidth = t.beam_width;
|
|
112
|
+
if (t.embed_rank !== undefined) tune.embedRank = t.embed_rank;
|
|
113
|
+
if (t.prose_layers !== undefined) tune.proseLayers = t.prose_layers;
|
|
114
|
+
const exp = t.expansion || {};
|
|
115
|
+
const expansion = {};
|
|
116
|
+
if (exp.strategy !== undefined) expansion.strategy = exp.strategy;
|
|
117
|
+
if (exp.nodes !== undefined) expansion.nodes = exp.nodes;
|
|
118
|
+
if (exp.q !== undefined) expansion.q = exp.q;
|
|
119
|
+
if (exp.depth !== undefined) expansion.depth = exp.depth;
|
|
120
|
+
if (Object.keys(expansion).length) tune.expansion = expansion;
|
|
121
|
+
if (Object.keys(tune).length) cfg.tune = tune;
|
|
122
|
+
|
|
123
|
+
const tel = src.telemetry || {};
|
|
124
|
+
if (tel.enabled !== undefined) cfg.telemetry = { enabled: tel.enabled };
|
|
125
|
+
|
|
126
|
+
const unwired = [];
|
|
127
|
+
for (const [a, b] of UNWIRED_KEYS) {
|
|
128
|
+
if (src[a] && src[a][b] !== undefined) unwired.push(`${a}.${b}`);
|
|
129
|
+
}
|
|
130
|
+
cfg.unwired = unwired;
|
|
131
|
+
|
|
132
|
+
return cfg;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Flatten a nested object into a dotted-key map. Arrays and scalars are leaves
|
|
136
|
+
* (never recursed into); `undefined`/`null` values are dropped so that "present
|
|
137
|
+
* but false" survives while "absent" does not. */
|
|
138
|
+
function flatten(obj, prefix = "", out = {}) {
|
|
139
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
140
|
+
if (v === undefined || v === null) continue;
|
|
141
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
142
|
+
if (Array.isArray(v) || typeof v !== "object") out[key] = v;
|
|
143
|
+
else flatten(v, key, out);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Merge config sources by per-key precedence: explicit `args` > `toml` >
|
|
149
|
+
* `defaults`. Returns `{effective, sources}`, both keyed by the same sorted set
|
|
150
|
+
* of dotted keys (deterministic output). `sources[key]` is one of
|
|
151
|
+
* "arg" | "seonix.toml" | "default".
|
|
152
|
+
*
|
|
153
|
+
* Tri-state note: presence is tested with `in`, not truthiness, so a `toml`
|
|
154
|
+
* value of `false` (e.g. `literal_mention = false` disabling the shipped-ON
|
|
155
|
+
* default) is honoured, while an explicit arg still wins over it. */
|
|
156
|
+
export function mergeEffective({ args = {}, toml = null, defaults = {} } = {}) {
|
|
157
|
+
const dFlat = flatten(defaults);
|
|
158
|
+
// `unwired` is metadata, not a knob — keep it out of the effective config.
|
|
159
|
+
let tomlKnobs = null;
|
|
160
|
+
if (toml) {
|
|
161
|
+
const { unwired, ...rest } = toml;
|
|
162
|
+
tomlKnobs = rest;
|
|
163
|
+
}
|
|
164
|
+
const tFlat = tomlKnobs ? flatten(tomlKnobs) : {};
|
|
165
|
+
const aFlat = flatten(args);
|
|
166
|
+
|
|
167
|
+
const keys = [...new Set([...Object.keys(dFlat), ...Object.keys(tFlat), ...Object.keys(aFlat)])].sort();
|
|
168
|
+
const effective = {};
|
|
169
|
+
const sources = {};
|
|
170
|
+
for (const k of keys) {
|
|
171
|
+
if (k in aFlat) {
|
|
172
|
+
effective[k] = aFlat[k];
|
|
173
|
+
sources[k] = "arg";
|
|
174
|
+
} else if (k in tFlat) {
|
|
175
|
+
effective[k] = tFlat[k];
|
|
176
|
+
sources[k] = "seonix.toml";
|
|
177
|
+
} else {
|
|
178
|
+
effective[k] = dFlat[k];
|
|
179
|
+
sources[k] = "default";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { effective, sources };
|
|
183
|
+
}
|
package/src/uuid.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// uuid.mjs — RFC 9562 UUIDv7 (node:crypto ships v4 only). Lifted verbatim from
|
|
2
|
+
// chat.mjs so the chat session id, the telemetry invocation id, and the bench
|
|
3
|
+
// per-run stamp all share ONE time-sortable implementation (no second copy, no dep).
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/** UUID v7 (RFC 9562): 48-bit big-endian unix-ms timestamp, then version/variant
|
|
8
|
+
* bits over crypto-random tail — time-sortable, unlike crypto.randomUUID()'s v4. */
|
|
9
|
+
export function uuidv7(now = Date.now()) {
|
|
10
|
+
const b = randomBytes(16);
|
|
11
|
+
b.writeUIntBE(now, 0, 6); // 48-bit unix-ms timestamp, big-endian
|
|
12
|
+
b[6] = (b[6] & 0x0f) | 0x70; // version 7
|
|
13
|
+
b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
|
|
14
|
+
const h = b.toString("hex");
|
|
15
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
16
|
+
}
|
package/src/viz.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// `buildSubgraph` and `buildViewerData` are pure and unit-tested; the CLI adds graph
|
|
15
15
|
// loading, file I/O, and the zero-dependency node:http server.
|
|
16
16
|
|
|
17
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
18
18
|
import { execFile } from "node:child_process";
|
|
19
19
|
import { createServer } from "node:http";
|
|
20
20
|
import { createRequire } from "node:module";
|
|
@@ -28,6 +28,20 @@ import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents,
|
|
|
28
28
|
import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
|
|
29
29
|
import { winkBrowserBundle } from "./nlp-bundle.mjs";
|
|
30
30
|
|
|
31
|
+
// Portable single-file gate: above this serialized graph size, embedding the
|
|
32
|
+
// full raw graph inline yields an unopenable multi-hundred-MB HTML, so the
|
|
33
|
+
// portable branch writes the ask/graph payloads as sidecars instead. The env
|
|
34
|
+
// override SEONIX_VIZ_INLINE_MAX (bytes) lets an operator raise/lower it and
|
|
35
|
+
// doubles as the test hook; --force-inline forces embedding regardless.
|
|
36
|
+
export const INLINE_ASKDATA_MAX_BYTES = 32 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** The gate threshold in bytes: env override when it parses as a number
|
|
39
|
+
* (including 0, which forces the gate on), otherwise the 32 MiB default. */
|
|
40
|
+
function inlineAskDataMaxBytes(env = process.env) {
|
|
41
|
+
const parsed = parseInt(env.SEONIX_VIZ_INLINE_MAX ?? "", 10);
|
|
42
|
+
return Number.isNaN(parsed) ? INLINE_ASKDATA_MAX_BYTES : parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
46
|
const execFileP = promisify(execFile);
|
|
33
47
|
|
|
@@ -673,7 +687,15 @@ async function loadGraph(values) {
|
|
|
673
687
|
const config = loadConfig(values.graph ? { ...process.env, SEONIX_GRAPH_FILE: resolve(values.graph) } : process.env);
|
|
674
688
|
const raw = await source.fetchEntities(config);
|
|
675
689
|
const graph = parseEntities(raw);
|
|
676
|
-
|
|
690
|
+
// Serialized on-disk size drives the portable-branch large-graph gate (the real
|
|
691
|
+
// failure mode). 0 on any stat failure so a missing/unstattable file never gates.
|
|
692
|
+
let graphBytes = 0;
|
|
693
|
+
try {
|
|
694
|
+
graphBytes = (await stat(config.graphFile)).size;
|
|
695
|
+
} catch {
|
|
696
|
+
graphBytes = 0;
|
|
697
|
+
}
|
|
698
|
+
return { config, graph, raw, graphBytes };
|
|
677
699
|
}
|
|
678
700
|
|
|
679
701
|
function resolveFocus(graph, focusArg) {
|
|
@@ -731,10 +753,13 @@ export async function startVizServer({ values, cytoscape, askEngine = "", nlpBun
|
|
|
731
753
|
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
732
754
|
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()), nav });
|
|
733
755
|
};
|
|
756
|
+
const limit = Math.max(0, parseInt(values.limit, 10) || 0);
|
|
734
757
|
const buildTimelinePage = async () => {
|
|
735
758
|
const { raw } = await loadGraph(values);
|
|
759
|
+
const totalCommits = (raw.individuals || []).filter((i) => i.class === "Commit").length;
|
|
736
760
|
return renderTimelineHtml({
|
|
737
|
-
commits: extractTimeline(raw),
|
|
761
|
+
commits: extractTimeline(raw, { limit }),
|
|
762
|
+
totalCommits,
|
|
738
763
|
repoUrl,
|
|
739
764
|
repoRef: values.ref,
|
|
740
765
|
generatedAt: raw.generated_at || "",
|
|
@@ -831,12 +856,20 @@ export async function runVizCli(argv) {
|
|
|
831
856
|
"browser-data-out": { type: "string" },
|
|
832
857
|
"timeline-out": { type: "string" },
|
|
833
858
|
"graph-only": { type: "boolean", default: false },
|
|
859
|
+
// Commit-timeline cap: render only the newest N commits (0 = unlimited).
|
|
860
|
+
// Default 100 keeps an estate-scale timeline openable; the header notes the
|
|
861
|
+
// truncation ("newest N of M commits"). See src/timeline.mjs.
|
|
862
|
+
limit: { type: "string", default: "100" },
|
|
834
863
|
scope: { type: "string", default: "product" },
|
|
835
864
|
// Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
|
|
836
865
|
// Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
|
|
837
866
|
// Portable/serve mode: inlined (single-file) / served in-process. Off by default,
|
|
838
867
|
// so the local single-file stays lean and lemma-off exactly as before.
|
|
839
868
|
nlp: { type: "boolean", default: false },
|
|
869
|
+
// Portable branch only: embed the full graph inline even past the size gate
|
|
870
|
+
// (INLINE_ASKDATA_MAX_BYTES). Off by default — over-threshold graphs write
|
|
871
|
+
// sidecars instead of an unopenable multi-hundred-MB single file.
|
|
872
|
+
"force-inline": { type: "boolean", default: false },
|
|
840
873
|
},
|
|
841
874
|
allowPositionals: false,
|
|
842
875
|
});
|
|
@@ -849,7 +882,8 @@ export async function runVizCli(argv) {
|
|
|
849
882
|
await startVizServer({ values, cytoscape, askEngine, nlpBundle, log: (s) => process.stderr.write(s) });
|
|
850
883
|
return; // keeps serving until Ctrl-C
|
|
851
884
|
}
|
|
852
|
-
const { config, graph, raw } = await loadGraph(values);
|
|
885
|
+
const { config, graph, raw, graphBytes } = await loadGraph(values);
|
|
886
|
+
const limit = Math.max(0, parseInt(values.limit, 10) || 0); // 0 = unlimited timeline
|
|
853
887
|
const focus = resolveFocus(graph, values.focus);
|
|
854
888
|
if (!focus) {
|
|
855
889
|
process.stderr.write(values.focus
|
|
@@ -888,16 +922,17 @@ export async function runVizCli(argv) {
|
|
|
888
922
|
siteNav: values["site-nav"],
|
|
889
923
|
nav: values["graph-only"] ? null : navFor(out),
|
|
890
924
|
});
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
925
|
+
// Split viewer: the page + its two sibling data files (display graph + full ask
|
|
926
|
+
// graph), the page pointing at them rather than embedding. Shared by --data-out
|
|
927
|
+
// (site build) and the portable large-graph gate below so the sidecar layout can
|
|
928
|
+
// never drift between them. `dataOut` is the display-graph sidecar path; the ask
|
|
929
|
+
// sidecar is always seonix-ask-data.json next to --out. Returns the resolved paths.
|
|
930
|
+
const writeSplitViewer = async (dataOut) => {
|
|
896
931
|
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
897
932
|
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
898
933
|
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
899
|
-
//
|
|
900
|
-
//
|
|
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.
|
|
901
936
|
let nlpPath = null;
|
|
902
937
|
if (nlpBundle) {
|
|
903
938
|
const nlpOut = resolve(dirname(out), "seonix-nlp.js");
|
|
@@ -911,11 +946,34 @@ export async function runVizCli(argv) {
|
|
|
911
946
|
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
912
947
|
nlpPath,
|
|
913
948
|
}));
|
|
949
|
+
return { dataOut, askDataOut, nlpPath };
|
|
950
|
+
};
|
|
951
|
+
if (values["data-out"]) {
|
|
952
|
+
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
953
|
+
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
954
|
+
// written next to the display data file rather than embedded in the page.
|
|
955
|
+
const { dataOut, askDataOut, nlpPath } = await writeSplitViewer(resolve(values["data-out"]));
|
|
914
956
|
process.stderr.write(
|
|
915
957
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
916
958
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}` +
|
|
917
959
|
`${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
|
|
918
960
|
);
|
|
961
|
+
} else if (graphBytes > inlineAskDataMaxBytes() && !values["force-inline"]) {
|
|
962
|
+
// portable mode, but the graph is too big to embed: an estate-scale full-graph
|
|
963
|
+
// inlined as a single file produces an unopenable multi-hundred-MB HTML. Write
|
|
964
|
+
// the same sidecars as the site build (next to --out) and point the page at
|
|
965
|
+
// them. file:// pages can't fetch siblings, so this needs `viz --serve`;
|
|
966
|
+
// --force-inline overrides and embeds anyway.
|
|
967
|
+
const { dataOut, askDataOut } = await writeSplitViewer(resolve(dirname(out), "seonix-graph-data.json"));
|
|
968
|
+
const mb = Math.round(graphBytes / (1024 * 1024));
|
|
969
|
+
process.stderr.write(
|
|
970
|
+
`seonix viz: large graph (${mb} MB > 32 MB) — data written as sidecars next to ${out}; ` +
|
|
971
|
+
"file:// pages cannot fetch siblings, open via `seonix viz --serve` (or pass --force-inline to embed anyway)\n",
|
|
972
|
+
);
|
|
973
|
+
process.stderr.write(
|
|
974
|
+
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
975
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
976
|
+
);
|
|
919
977
|
} else {
|
|
920
978
|
// portable mode: one self-contained file, both channels embedded (+ inline wink
|
|
921
979
|
// bundle when --nlp; otherwise lemma-off, no external fetch, exactly as before)
|
|
@@ -963,9 +1021,11 @@ export async function runVizCli(argv) {
|
|
|
963
1021
|
}
|
|
964
1022
|
}
|
|
965
1023
|
if (timelineOut) {
|
|
966
|
-
const commits = extractTimeline(raw);
|
|
1024
|
+
const commits = extractTimeline(raw, { limit });
|
|
1025
|
+
const totalCommits = (raw.individuals || []).filter((i) => i.class === "Commit").length;
|
|
967
1026
|
await writeFile(timelineOut, renderTimelineHtml({
|
|
968
1027
|
commits,
|
|
1028
|
+
totalCommits,
|
|
969
1029
|
repoUrl,
|
|
970
1030
|
repoRef: values.ref,
|
|
971
1031
|
generatedAt: raw.generated_at || "",
|
package/src/walk.mjs
CHANGED
|
Binary file
|