@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.
@@ -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
- return { config, graph, raw };
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
- if (values["data-out"]) {
892
- // site mode: viewer page + sibling data file (data updates don't touch the page).
893
- // The chat panel's full-graph channel rides the same sibling-file convention,
894
- // written next to the display data file rather than embedded in the page.
895
- const dataOut = resolve(values["data-out"]);
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
- // 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.
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