@polycode-projects/seonix 0.10.2 → 0.10.3

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/bin/cli.mjs CHANGED
@@ -3,6 +3,9 @@
3
3
  // codebase-memory-mcp so it drops into the same benchmark harness:
4
4
  //
5
5
  // seonix → stdio MCP server
6
+ // seonix ask "<question>" [--strict] → one-shot plain-English question, answer
7
+ // to stdout, then exit (no MCP, no model call — same engine as seonix_ask). The single-command
8
+ // equivalent of `claude --prompt`. `seonix --help` / `seonix cli --help` print full usage.
6
9
  // seonix cli index_repository '{"repo_path":"<abs>"}' → deterministic index
7
10
  // (honours <repo>/.seonixignore — gitignore-subset patterns; pass
8
11
  // `"ignores":false` in the JSON arg to index everything regardless)
@@ -35,6 +38,10 @@
35
38
  // seonix cli <toolName> '{…args}' → invoke ANY MCP tool via Bash (no MCP)
36
39
  // (e.g. seonix cli seonix_ask '{"query":"<free text>"}' — mechanical, no-LLM NL question
37
40
  // over the graph, PLAN_MECHANICAL_CHAT.md; no bespoke wiring needed, this fallback covers it)
41
+ // seonix cli seonix_ask "<free text>" → same, but the `seonix_` prefix on a bare
42
+ // tool name (ask/search/callers/…) auto-resolves, and a non-JSON payload auto-wraps into that
43
+ // tool's one primary argument — so `cli ask "what calls X"` and `cli seonix_search "text"` both
44
+ // work with no JSON quoting. `seonix cli --help` lists every tool + its example invocation.
38
45
  // seonix hook-augment → PreToolUse Grep/Glob augmenter (stdin→stdout)
39
46
  // seonix viz [--focus <sym>] [--depth N] [--out f.html] [--data-out f.json] [--repo-url <gitlab url> --ref main] [--site-nav] → render a focused sub-graph to HTML (one shared viewer; data embedded, or split out with --data-out). By default also writes code-browser.html + timeline.html next to --out with a working header nav; --graph-only suppresses the siblings
40
47
  // seonix viz --browser-out f.html [--browser-data-out f.json] [--timeline-out f.html] [--scope product|<prefixes>] → override the sibling artifact paths (Chronograph code browser: temporal scrub + two-cursor diff + narration + ghost-branch merges + keyboard nav; archive/PLAN_CODE_BROWSER.md)
@@ -50,12 +57,12 @@ import { existsSync } from "node:fs";
50
57
  import { readFile, writeFile } from "node:fs/promises";
51
58
  import { fileURLToPath } from "node:url";
52
59
  import { startServer } from "../src/server.mjs";
53
- import { dispatchTool, buildContextBundle } from "../src/server.mjs";
60
+ import { dispatchTool, buildContextBundle, TOOLS } from "../src/server.mjs";
54
61
  import { indexRepository, indexRepositories, discoverRepos, syncRepositories } from "../src/extract.mjs";
55
62
  import { renderSummaryMd } from "../src/summary.mjs";
56
63
  import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
57
64
  import * as source from "../src/source.mjs";
58
- import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
65
+ import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP, COLD_TOOLS } from "../src/codegraph.mjs";
59
66
  import { loadTomlConfig, normalizeConfig, mergeEffective, CONFIG_FILE } from "../src/toml-config.mjs";
60
67
  import { compileGlobs } from "../src/walk.mjs";
61
68
  import { createTelemetry, truncateStr, looksLikeMiss } from "../src/telemetry.mjs";
@@ -106,6 +113,102 @@ function defaultRepoPath(args, { mustExist = true } = {}) {
106
113
  return args;
107
114
  }
108
115
 
116
+ // Hot tools (server.mjs's TOOLS) carry an inputSchema but no example value, so their
117
+ // example-args + primary-arg-key aren't derivable the way COLD_TOOLS' are — hand-maintained
118
+ // here, right next to COLD_TOOLS' import, so a new hot tool is a one-line addition in both places.
119
+ const HOT_TOOL_EXAMPLES = {
120
+ seonix_context: { symbol: "django/utils/text.py" },
121
+ seonix_snippet: { symbol: "Truncator.chars" },
122
+ seonix_ask: { query: "which functions call Truncator.chars" },
123
+ };
124
+
125
+ /** Every seonix_* tool reachable via `cli <toolName>`: [name, description, exampleArgs]. */
126
+ function allToolEntries() {
127
+ const hot = TOOLS.map((t) => [t.name, t.description, HOT_TOOL_EXAMPLES[t.name] || {}]);
128
+ return [...hot, ...COLD_TOOLS];
129
+ }
130
+
131
+ /** The one argument key that carries a tool's free-text/name input, if it has exactly one
132
+ * obviously-primary key (the first key of its example-args object) — used to auto-wrap a
133
+ * bare non-JSON string into `{[key]: text}` so `cli seonix_ask 'how does X work'` works
134
+ * without JSON-quoting. Tools with no example args (e.g. seonix_untested) return null. */
135
+ function primaryArgKeyFor(name) {
136
+ const entry = allToolEntries().find(([n]) => n === name);
137
+ const keys = entry ? Object.keys(entry[2]) : [];
138
+ return keys[0] || null;
139
+ }
140
+
141
+ /** `ask` → `seonix_ask` when the bare name isn't itself a known tool but the seonix_-prefixed
142
+ * form is — the shape of mistake anyone typing this CLI for the first time makes, since every
143
+ * tool name in this project carries the prefix but nothing else about the CLI hints at it. */
144
+ function resolveToolName(sub) {
145
+ if (allToolEntries().some(([n]) => n === sub)) return sub;
146
+ const prefixed = `seonix_${sub}`;
147
+ if (allToolEntries().some(([n]) => n === prefixed)) return prefixed;
148
+ return sub;
149
+ }
150
+
151
+ /** Best-effort "did you mean" suggestion for a tool name that doesn't exist even after
152
+ * resolveToolName's prefix guess — substring/prefix match against the real catalog, so a
153
+ * typo or a half-remembered name gets pointed at the right place instead of a bare 404. */
154
+ function suggestToolName(sub) {
155
+ const names = allToolEntries().map(([n]) => n);
156
+ const needle = sub.replace(/^seonix_/, "").toLowerCase();
157
+ const hit = names.find((n) => n.replace(/^seonix_/, "").toLowerCase().includes(needle))
158
+ || names.find((n) => needle.includes(n.replace(/^seonix_/, "").toLowerCase()));
159
+ return hit || null;
160
+ }
161
+
162
+ const MANAGEMENT_COMMANDS = [
163
+ ["index_repository", "Build or rebuild the graph for a repo (or a multi-repo estate).", { repo_path: "/abs/path" }],
164
+ ["digest", "Architecture map + per-module edit bundles to stdout — the no-MCP arm.", { repo_path: "/abs/path", modules: ["app/lib/a.mjs"] }],
165
+ ["sync", "Incrementally re-index a multi_root estate (only changed repos).", { multi_root: "/abs/estate-dir" }],
166
+ ["seonix_locate", "Ranked <path>\\t<score> lines for a free-text query — no digest, just the ranking.", { query: "template filters" }],
167
+ ["browser_link", "NL-ish text → the code-browser query grammar → a self-tested URL.", { query: "classes that change with render" }],
168
+ ["store_rebuild", "Force-(re)build the opt-in SQLite store from the current graph.json.", { repo_path: "/abs/path" }],
169
+ ];
170
+
171
+ function printTopLevelUsage() {
172
+ process.stdout.write(
173
+ "seonix — deterministic, offline, zero-model-call code graph\n\n" +
174
+ "usage:\n" +
175
+ " seonix start the stdio MCP server\n" +
176
+ " seonix ask \"<question>\" one-shot plain-English question, answer to stdout, then exit\n" +
177
+ " (no MCP, no model call — same engine as seonix_ask; pass --strict\n" +
178
+ " to fail loudly on an ambiguous query instead of listing candidates)\n" +
179
+ " seonix chat [--repo <abs>] interactive chat session over the graph (/exit to leave)\n" +
180
+ " seonix cli <command> ['{json}'] run one command non-interactively — see `seonix cli --help`\n" +
181
+ " seonix viz [--focus <sym>] … render an HTML graph view (+ code browser + timeline)\n" +
182
+ " seonix init [--dotnet] [--force] seed a seonix.toml in the current directory\n" +
183
+ " seonix config --effective print the merged, resolved config for the current repo\n" +
184
+ " seonix hook-augment PreToolUse Grep/Glob augmenter (stdin → stdout)\n\n" +
185
+ "`--help` / `-h` works after any of the above (e.g. `seonix cli --help` lists every tool).\n",
186
+ );
187
+ }
188
+
189
+ function printToolCatalog() {
190
+ const lines = [
191
+ "seonix cli — every command, one JSON (or plain-text, for the tools below marked *) argument:\n",
192
+ " seonix cli index_repository ['{json}'] build/rebuild the graph (bare form indexes cwd)",
193
+ "",
194
+ "management commands:",
195
+ ];
196
+ for (const [name, desc, example] of MANAGEMENT_COMMANDS) {
197
+ lines.push(` ${name} — ${desc}`);
198
+ lines.push(` seonix cli ${name} '${JSON.stringify(example)}'`);
199
+ }
200
+ lines.push("", "query tools (all also accept plain text for their * primary argument, e.g.");
201
+ lines.push("`seonix cli seonix_ask \"what calls X\"` needs no JSON quoting):");
202
+ for (const [name, desc, example] of allToolEntries()) {
203
+ const primary = primaryArgKeyFor(name);
204
+ const star = primary ? "*" : " ";
205
+ lines.push(` ${star} ${name} — ${desc}`);
206
+ lines.push(` seonix cli ${name} '${JSON.stringify(example)}'`);
207
+ }
208
+ lines.push("", "An omitted repo_path defaults to the nearest ancestor of cwd with a .seonix/ index.");
209
+ process.stdout.write(lines.join("\n") + "\n");
210
+ }
211
+
109
212
  /** Load a repo's seonix.toml as a normalized config, or null. Returns null IMMEDIATELY when
110
213
  * `args.config === false` (the BENCH-IMMUNITY flag — an arm's index/locate/digest must NEVER read a
111
214
  * subject repo's seonix.toml) OR when no seonix.toml exists. So on the bench path, and with no
@@ -347,6 +450,33 @@ async function runSync(multiRoot, { ignores, historyDepth }) {
347
450
  async function main() {
348
451
  const [mode, sub, payload] = process.argv.slice(2);
349
452
 
453
+ // bare `seonix` with no args is still the MCP server (unchanged contract, handled below);
454
+ // an explicit --help/-h/help asks for usage instead.
455
+ if (mode === "--help" || mode === "-h" || mode === "help") { printTopLevelUsage(); return; }
456
+ if (mode === "cli" && (sub === "--help" || sub === "-h")) { printToolCatalog(); return; }
457
+
458
+ // `seonix ask "<question>" [--strict]`: the one-shot NL entry point — same engine as
459
+ // seonix_ask (mechanical, no model call), but no `cli`, no JSON, no MCP round-trip. The
460
+ // ergonomic gap this closes: typing a plain question at a terminal shouldn't need a JSON
461
+ // payload and the `seonix_` tool-name prefix, the two things that trip up a first run.
462
+ if (mode === "ask") {
463
+ const strict = process.argv.includes("--strict");
464
+ const query = process.argv.slice(3).filter((a) => a !== "--strict").join(" ").trim();
465
+ if (!query) {
466
+ process.stderr.write('seonix: usage: seonix ask "<question>" [--strict]\n');
467
+ process.exit(2);
468
+ }
469
+ const args = defaultRepoPath({ query, ...(strict ? { strict: true } : {}) });
470
+ const config = configFor(args.repo_path);
471
+ try {
472
+ process.stdout.write((await dispatchTool("seonix_ask", args, { config })) + "\n");
473
+ } catch (e) {
474
+ process.stderr.write(`seonix: ${e?.message || e}\n`);
475
+ process.exit(1);
476
+ }
477
+ return;
478
+ }
479
+
350
480
  if (mode === "cli") {
351
481
  // index mode: `cli index_repository '{"repo_path":"<abs>"}'` (single repo, unchanged),
352
482
  // '{"repo_paths":[…],"out_root":"<abs>"}' (explicit n-repo merge) or
@@ -681,9 +811,32 @@ async function main() {
681
811
  // tool-query fallback: any other `cli <toolName> '{…}'` routes to the MCP dispatcher,
682
812
  // so "cold" tools are invokable from Bash without an MCP connection.
683
813
  if (sub) {
684
- const args = parsePayload(payload);
814
+ // `ask` `seonix_ask`, `search` → `seonix_search`, etc. — the prefix is the single most
815
+ // common typo this CLI produces, since nothing else about it signals every tool is
816
+ // seonix_-prefixed.
817
+ const resolved = resolveToolName(sub);
818
+ let args = parsePayload(payload);
819
+ if (args === null) {
820
+ // Not valid JSON — auto-wrap as plain text for the tool's one obviously-primary
821
+ // argument (query/symbol/module/…), UNLESS the payload starts with `{`: that's an
822
+ // attempted JSON object with a typo in it, and silently treating a broken `{"symbol":`
823
+ // as a literal search string would mask the real mistake instead of surfacing it.
824
+ const looksLikeAttemptedJson = typeof payload === "string" && payload.trim().startsWith("{");
825
+ const primaryKey = primaryArgKeyFor(resolved);
826
+ if (payload !== undefined && primaryKey && !looksLikeAttemptedJson) args = { [primaryKey]: payload };
827
+ }
685
828
  if (args === null) {
686
- process.stderr.write(`seonix: ${sub} expects a JSON arg, e.g. '{"symbol":"<name>"}'\n`);
829
+ const entry = allToolEntries().find(([n]) => n === resolved);
830
+ if (entry) {
831
+ process.stderr.write(`seonix: ${resolved} expects a JSON arg, e.g. '${JSON.stringify(entry[2])}'\n`);
832
+ } else {
833
+ const suggestion = suggestToolName(sub);
834
+ process.stderr.write(
835
+ `seonix: unknown tool "${sub}"` +
836
+ (suggestion ? ` — did you mean "${suggestion}"?\n` : ".\n") +
837
+ "Run `seonix cli --help` to list every tool.\n",
838
+ );
839
+ }
687
840
  process.exit(2);
688
841
  }
689
842
  defaultRepoPath(args);
@@ -694,26 +847,38 @@ async function main() {
694
847
  const tel = createTelemetry({ env: process.env, config, toml, surface: "cli" });
695
848
  const t0 = Date.now();
696
849
  try {
697
- const text = await dispatchTool(sub, args, { config });
850
+ const text = await dispatchTool(resolved, args, { config });
698
851
  process.stdout.write(text + "\n");
699
852
  // Telemetry for the generic tool surface: which tool + how many chars it returned, plus
700
853
  // (wh dogfood backlog #6) the args text/duration/hit-miss the log previously omitted.
701
854
  tel?.record({
702
- tool: sub,
855
+ tool: resolved,
703
856
  query: { raw: truncateStr(JSON.stringify(args)) },
704
857
  perf: { ms_total: Date.now() - t0 },
705
858
  quality: { miss: looksLikeMiss(text) },
706
859
  cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
707
860
  });
708
861
  } catch (e) {
709
- process.stderr.write(`seonix: ${e?.message || e}\n`);
862
+ const msg = e?.message || String(e);
863
+ const unknown = /^unknown tool: (.+)$/.exec(msg);
864
+ if (unknown) {
865
+ const suggestion = suggestToolName(unknown[1]);
866
+ process.stderr.write(
867
+ `seonix: ${msg}` +
868
+ (suggestion ? ` — did you mean "${suggestion}"?\n` : ".\n") +
869
+ "Run `seonix cli --help` to list every tool.\n",
870
+ );
871
+ } else {
872
+ process.stderr.write(`seonix: ${msg}\n`);
873
+ }
710
874
  process.exit(1);
711
875
  }
712
876
  return;
713
877
  }
714
878
 
715
879
  process.stderr.write("seonix: `cli` needs a sub-command (index_repository | digest | <toolName>). " +
716
- "The JSON arg is optional — an omitted repo_path defaults to the nearest ancestor of cwd with a .seonix/ index.\n");
880
+ "The JSON arg is optional — an omitted repo_path defaults to the nearest ancestor of cwd with a .seonix/ index. " +
881
+ "Run `seonix cli --help` to list every command.\n");
717
882
  process.exit(2);
718
883
  }
719
884
 
@@ -777,7 +942,8 @@ async function main() {
777
942
  }
778
943
 
779
944
  process.stderr.write(`seonix: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
780
- "Use bare (MCP server), `cli index_repository …`, `cli <tool> …`, `chat`, `hook-augment`, `viz`, `init`, or `config --effective`.\n");
945
+ "Use bare (MCP server), `ask \"<question>\"`, `cli index_repository …`, `cli <tool> …`, `chat`, " +
946
+ "`hook-augment`, `viz`, `init`, or `config --effective`. Run `seonix --help` for the full list.\n");
781
947
  process.exit(2);
782
948
  }
783
949
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
@@ -87,7 +87,7 @@
87
87
  },
88
88
  "dependencies": {
89
89
  "@modelcontextprotocol/sdk": "^1.29.0",
90
- "@polycode-projects/the-mechanical-code-talker": "^0.9.0",
90
+ "@polycode-projects/the-mechanical-code-talker": "^0.9.1",
91
91
  "cytoscape": "^3.30.0",
92
92
  "smol-toml": "^1.7.0",
93
93
  "tree-sitter": "^0.21.1",
package/src/codegraph.mjs CHANGED
@@ -2010,31 +2010,37 @@ export function renderContextMore(plan) {
2010
2010
 
2011
2011
  // ---- #7 cold-tool catalog (written to <repo>/.seonix/TOOLS.md by the index step) --
2012
2012
 
2013
+ /** The COLD tools (everything except the two hot MCP tools + seonix_ask): each entry is
2014
+ * [name, one-line purpose, example-args-object]. Single source of truth for the TOOLS.md
2015
+ * catalog, `seonix cli --help`, and the CLI's per-tool JSON-arg-error examples — so all
2016
+ * three stay in sync with one edit. */
2017
+ export const COLD_TOOLS = [
2018
+ ["seonix_describe", "Locate one symbol and list its typed edges (both directions) with provenance.", { symbol: "django/utils/text.py" }],
2019
+ ["seonix_signature", "One symbol's API surface (params, returns, raises/catches, flags, decorators, doc) without the body.", { symbol: "Truncator.chars" }],
2020
+ ["seonix_impact", "Transitive reverse closure over imports/calls — what breaks if a module changes, by depth, with tests.", { module: "django/utils/text.py" }],
2021
+ ["seonix_search", "Free-text/ranked lookup over the code-map to find the right module or symbol.", { query: "template filters", kind: "function" }],
2022
+ ["seonix_members", "A class's methods + attributes (file:line, decorators) in one slice.", { class: "Truncator" }],
2023
+ ["seonix_subclasses", "A class's base classes plus the transitive set of classes that extend it.", { class: "Field" }],
2024
+ ["seonix_architecture", "Package/module map + the most-imported hub modules (optionally scoped to a package).", { package: "django/template" }],
2025
+ ["seonix_exports", "A module's public __all__ surface, each name resolved to the module that defines it.", { module: "django/db/models/__init__.py" }],
2026
+ ["seonix_tests_for", "The test modules covering a symbol or module, from the typed test edges.", { symbol: "django/utils/text.py" }],
2027
+ ["seonix_untested", "Source modules with no covering test module — a coverage-gap view (no arguments).", {}],
2028
+ ["seonix_history", "Recent commits that touched a symbol's module (newest first).", { symbol: "django/utils/text.py" }],
2029
+ ["seonix_file_history", "Commits that touched a symbol's module, each with author / date / subject.", { symbol: "django/utils/text.py" }],
2030
+ ["seonix_method_history", "Commits that touched a specific method symbol (fine-grained), with author / date / subject.", { symbol: "Truncator.chars" }],
2031
+ ["seonix_class_history", "Commits that touched a specific class symbol (fine-grained), with author / date / subject.", { symbol: "Truncator" }],
2032
+ ["seonix_callers", "Modules that call into a symbol's module (one hop).", { symbol: "django/utils/text.py" }],
2033
+ ["seonix_callees", "Modules a symbol's module calls into (one hop).", { symbol: "django/utils/text.py" }],
2034
+ ["seonix_calls", "The in-repo symbols a function calls (fn→fn), each with file:line.", { symbol: "slugify" }],
2035
+ ["seonix_cochanges", "Modules that historically change in the same commit as a symbol's module (git co-change).", { symbol: "django/utils/text.py" }],
2036
+ ["seonix_context_more", "The bundle sections a lean seonix_context omitted (siblings / tests / cochange / class members / re-exports).", { symbol: "django/utils/text.py" }],
2037
+ ];
2038
+
2013
2039
  /** Markdown catalog of the COLD tools (everything except the two hot MCP tools): each
2014
2040
  * with a one-line purpose and the exact Bash invocation via the CLI `cli <tool>` route.
2015
2041
  * Pure — `cliPath` is the absolute path to bin/cli.mjs the caller wants embedded. */
2016
2042
  export function renderToolsCatalog(cliPath) {
2017
- const cold = [
2018
- ["seonix_describe", "Locate one symbol and list its typed edges (both directions) with provenance.", { symbol: "django/utils/text.py" }],
2019
- ["seonix_signature", "One symbol's API surface (params, returns, raises/catches, flags, decorators, doc) without the body.", { symbol: "Truncator.chars" }],
2020
- ["seonix_impact", "Transitive reverse closure over imports/calls — what breaks if a module changes, by depth, with tests.", { module: "django/utils/text.py" }],
2021
- ["seonix_search", "Free-text/ranked lookup over the code-map to find the right module or symbol.", { query: "template filters", kind: "function" }],
2022
- ["seonix_members", "A class's methods + attributes (file:line, decorators) in one slice.", { class: "Truncator" }],
2023
- ["seonix_subclasses", "A class's base classes plus the transitive set of classes that extend it.", { class: "Field" }],
2024
- ["seonix_architecture", "Package/module map + the most-imported hub modules (optionally scoped to a package).", { package: "django/template" }],
2025
- ["seonix_exports", "A module's public __all__ surface, each name resolved to the module that defines it.", { module: "django/db/models/__init__.py" }],
2026
- ["seonix_tests_for", "The test modules covering a symbol or module, from the typed test edges.", { symbol: "django/utils/text.py" }],
2027
- ["seonix_untested", "Source modules with no covering test module — a coverage-gap view (no arguments).", {}],
2028
- ["seonix_history", "Recent commits that touched a symbol's module (newest first).", { symbol: "django/utils/text.py" }],
2029
- ["seonix_file_history", "Commits that touched a symbol's module, each with author / date / subject.", { symbol: "django/utils/text.py" }],
2030
- ["seonix_method_history", "Commits that touched a specific method symbol (fine-grained), with author / date / subject.", { symbol: "Truncator.chars" }],
2031
- ["seonix_class_history", "Commits that touched a specific class symbol (fine-grained), with author / date / subject.", { symbol: "Truncator" }],
2032
- ["seonix_callers", "Modules that call into a symbol's module (one hop).", { symbol: "django/utils/text.py" }],
2033
- ["seonix_callees", "Modules a symbol's module calls into (one hop).", { symbol: "django/utils/text.py" }],
2034
- ["seonix_calls", "The in-repo symbols a function calls (fn→fn), each with file:line.", { symbol: "slugify" }],
2035
- ["seonix_cochanges", "Modules that historically change in the same commit as a symbol's module (git co-change).", { symbol: "django/utils/text.py" }],
2036
- ["seonix_context_more", "The bundle sections a lean seonix_context omitted (siblings / tests / cochange / class members / re-exports).", { symbol: "django/utils/text.py" }],
2037
- ];
2043
+ const cold = COLD_TOOLS;
2038
2044
  const lines = [
2039
2045
  "# seonix cold-tool catalog",
2040
2046
  "",
package/src/viz.mjs CHANGED
@@ -938,11 +938,24 @@ export async function runVizCli(argv) {
938
938
  siteNav: values["site-nav"],
939
939
  nav: navFor(browserOut),
940
940
  });
941
- if (values["browser-data-out"]) {
942
- const bDataOut = resolve(values["browser-data-out"]);
941
+ const browserJson = JSON.stringify(browserData);
942
+ // Same large-graph gate as the main graph viewer (writeSplitViewer above): an
943
+ // estate-scale temporal graph embedded inline produces a multi-hundred-MB HTML
944
+ // file that hangs the browser tab parsing it, not just a file:// fetch failure —
945
+ // so this checks the ACTUAL embedded payload size, not graphBytes. Auto-triggers
946
+ // even without --browser-data-out; that flag still works to force sidecars early.
947
+ if (values["browser-data-out"] || (Buffer.byteLength(browserJson) > inlineAskDataMaxBytes() && !values["force-inline"])) {
948
+ const bDataOut = resolve(values["browser-data-out"] || join(dirname(browserOut), "code-browser-data.json"));
943
949
  const rel = relative(dirname(browserOut), bDataOut) || "code-browser-data.json";
944
- await writeFile(bDataOut, JSON.stringify(browserData));
950
+ await writeFile(bDataOut, browserJson);
945
951
  await writeFile(browserOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
952
+ if (!values["browser-data-out"]) {
953
+ const mb = Math.round(Buffer.byteLength(browserJson) / (1024 * 1024));
954
+ process.stderr.write(
955
+ `seonix viz: large chronograph payload (${mb} MB > 32 MB) — data written as a sidecar next to ${browserOut}; ` +
956
+ "file:// pages cannot fetch siblings, open via `seonix viz --serve` (or pass --force-inline to embed anyway)\n",
957
+ );
958
+ }
946
959
  process.stderr.write(
947
960
  `seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
948
961
  `(scope ${values.scope}) → ${browserOut} + ${bDataOut}\n`,