@polycode-projects/seonix 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,38 +1,72 @@
1
1
  # @polycode-projects/seonix
2
2
 
3
3
  **A deterministic, offline, $0 code-graph tool that makes a cheap coding agent
4
- edit like an expensive one.** SEONIX indexes a repository with Python's `ast` +
5
- `git log` **zero model calls** into a typed code graph, then renders a bounded
6
- **edit digest** you inject once at session start. Your agent edits from turn 1
7
- without fishing through the repo.
4
+ edit like an expensive one.** SEONIX indexes a repository with Python's `ast` and
5
+ `git log`, with **zero model calls**, into a typed code graph, then renders a
6
+ bounded **edit digest** you inject once at session start. Your agent edits from
7
+ turn 1 without fishing through the repo.
8
8
 
9
- > **Headline.** On Django localisation tasks the no-MCP `seonix` digest cut input
10
- > tokens **+72% median on Sonnet 4.6** and **+65% on Haiku 4.5** (−58% billed cost
11
- > per both-solved task), **6/6 solved**, **$0 to index**. The win is
12
- > model-conditional full method, numbers and the measurement contract are in the
13
- > repo.
9
+ > **Headline.** Measured as **mean** input tokens per both-solved task (paired
10
+ > arms, identical model pin). Under the shipped score-gap default (R1b), B017
11
+ > re-tested across three models on a held-out TypeScript corpus and the existing
12
+ > suites: two cells clear the ≥50% decision bar, both on exploration-bound
13
+ > complex tasks — a TypeScript middleware task on **Haiku 4.5 at 59.6%** and a C#
14
+ > service task on **Sonnet 4.6 at 53.5%** — with several more in the 35 to 48%
15
+ > range. Across all measured cells the token cut tracks the turn cut
16
+ > (Pearson 0.91): seonix saves tokens by removing exploration turns, so tasks
17
+ > whose edit site is obvious change little. Indexing costs **$0**. The win is
18
+ > task- and model-conditional; full method, numbers and the measurement contract
19
+ > are in `BENCHMARK_017.md`.
14
20
 
15
21
  - **Website / live demo:** https://seonix.polycode.co.uk
16
22
  - **Source + benchmarks:** https://gitlab.com/polycode-projects/seonix
17
23
  - **Licence:** AGPL-3.0-only
18
24
 
25
+ ## Cross-model comparison
26
+
27
+ seonix on the cheaper **Sonnet 4.6** vs the flagship **Opus 4.8** out of the box,
28
+ mean input tokens per solved run (N=3; N=5 where an otb cell reuses the B015
29
+ baseline). `+` means seonix on Sonnet used fewer tokens.
30
+
31
+ | full task name | otb / Opus 4.8 | solved | seonix / Sonnet 4.6 | solved | token change |
32
+ | --- | --- | --- | --- | --- | --- |
33
+ | ts-hono-utils-helper (simple) | 135k | 3/3 | 117k | 3/3 | +13% |
34
+ | ts-hono-response-time-middleware | 345k | 3/3 | 555k | 3/3 | −61% |
35
+ | ts-hono-json-envelope | 1,102k | 3/3 | 864k | 3/3 | +22% |
36
+ | py-django-truncatelines-filter | 610k | 5/5 | 524k | 5/5 | +14% |
37
+ | cs-eshoponweb-order-service-total | 318k | 5/5 | 154k | 3/3 | +51% |
38
+ | cs-eshoponweb-basket-service-total | 353k | 5/5 | 255k | 3/3 | +28% |
39
+ | js-express-request-id | 330k | 5/5 | 267k | 3/3 | +19% |
40
+ | js-express-json-envelope | 174k | 5/5 | 185k | 3/3 | −7% |
41
+
42
+ seonix on Sonnet used fewer input tokens than otb on Opus on six of these eight
43
+ tasks at equal correctness on these tasks. This crosses model and arm together, so
44
+ it is an illustration of practical value, not the paired within-model measurement
45
+ the ≥50% decision rule uses. Across all 13 measured seonix cells the drop in input
46
+ tokens is 0.91-correlated (Pearson) with the drop in turns (slope about 0.71):
47
+ seonix saves tokens by letting the agent skip exploration turns, so where it
48
+ removes no turns there is little to save.
49
+
19
50
  ## Install
20
51
 
21
52
  ```bash
22
53
  npm i -g @polycode-projects/seonix # global — gives you the `seonix` bin on PATH
23
54
  # or one-shot, no install:
24
- npx @polycode-projects/seonix index /abs/path/to/repo
55
+ npx @polycode-projects/seonix cli index_repository '{"repo_path":"/abs/path/to/repo"}'
25
56
  ```
26
57
 
27
58
  **Runtime prerequisites:** Node ≥ 24, **Python 3**, and **git** on `PATH` (the
28
- indexer shells out to Python's `ast` and `git log`). Indexing is fully local and
29
- deterministic no API keys, no network calls, **$0**.
59
+ indexer shells out to Python's `ast` and `git log`). A Java runtime is needed
60
+ only to index Java through JavaParser, and the .NET SDK only to index C# through
61
+ Roslyn; without them, indexing for that language falls back to the bundled
62
+ tree-sitter parser. Indexing is fully local and deterministic: no API keys, no
63
+ network calls, **$0**.
30
64
 
31
65
  ## Quickstart
32
66
 
33
67
  ```bash
34
68
  # 1. index your repo (deterministic, offline) — writes a machine-local .seonix/
35
- seonix index /abs/path/to/repo
69
+ seonix cli index_repository '{"repo_path":"/abs/path/to/repo"}'
36
70
 
37
71
  # 2. self-locate the touched modules from the task text (no human-chosen target)
38
72
  seonix cli seonix_search '{"repo_path":"/abs/path/to/repo","query":"<your task>"}'
@@ -44,15 +78,42 @@ seonix cli digest '{"repo_path":"/abs/path/to/repo","modules":["<top hits>"]}' >
44
78
  claude --prompt "Use the seonix digest below to localise and make the change. $(cat digest.txt)"
45
79
  ```
46
80
 
47
- Add `.seonix/` to your `.gitignore` it is machine-local and rebuilt from source.
81
+ Add `.seonix/` to your `.gitignore`; it is machine-local and rebuilt from source.
82
+
83
+ ## Indexing multiple repositories
84
+
85
+ `index_repository` takes one of three argument forms:
86
+
87
+ ```bash
88
+ # one repo (unchanged): artifacts to <repo_path>/.seonix/, module ids unprefixed
89
+ seonix cli index_repository '{"repo_path":"/abs/repo"}'
90
+
91
+ # n explicit repos, merged into ONE graph at <out_root>/.seonix/
92
+ seonix cli index_repository '{"repo_paths":["/abs/repo1","/abs/repo2"],"out_root":"/abs/dir"}'
93
+
94
+ # estate discovery: every immediate child directory of multi_root that contains a
95
+ # .git (dir or file) is indexed as a repo; artifacts to <multi_root>/.seonix/
96
+ seonix cli index_repository '{"multi_root":"/abs/estate"}'
97
+ ```
98
+
99
+ The forms are mutually exclusive. `out_root` defaults to the deepest common
100
+ ancestor directory of the paths (or the current directory if that ancestor is the
101
+ filesystem root). In the merged graph every module id and label is prefixed with
102
+ the repo's directory basename (`mod:<repoName>/<relpath>`); when two repos share a
103
+ basename the first in path sort order keeps the bare name and later ones get `-2`,
104
+ `-3`, … appended. Each repo's git history and `.seonixignore` are handled per repo,
105
+ exactly as in single mode. Cross-repo call edges only arise where a symbol name
106
+ resolves uniquely across the whole merged graph; ambiguous names are dropped, as
107
+ within one repo. Query a merged graph by passing the merge root as `repo_path`
108
+ (e.g. `seonix cli seonix_search '{"repo_path":"/abs/estate","query":"…"}'`).
48
109
 
49
110
  ## The shape that ships
50
111
 
51
112
  The product is the **no-MCP digest path**: render the digest, inject it once, run
52
- with **no server**. The interactive MCP tool loop is retired even mitigated it
53
- never cleared the 50% bar; the round-trip *count*, not the resident schema, is the
54
- cost. Use `seonix_search` (`locate`, self-derives the target) in production; pass
55
- a known target (`oracle`) only to measure the headroom.
113
+ with **no server**. The interactive MCP tool loop is retired. Even mitigated it
114
+ never cleared the 50% bar, because the cost lies in the round-trip count rather
115
+ than the resident schema. Use `seonix_search` (`locate`, self-derives the target)
116
+ in production; pass a known target (`oracle`) only to measure the headroom.
56
117
 
57
118
  ## Visualise
58
119
 
@@ -60,18 +121,136 @@ a known target (`oracle`) only to measure the headroom.
60
121
  seonix viz --focus <Symbol> --out graph.html
61
122
  ```
62
123
 
63
- Renders a focused, portable Cytoscape sub-graph of the typed code-map as one
64
- self-contained HTML file — the same view that runs live on the website.
124
+ Renders a focused, portable Cytoscape sub-graph of the typed code-map, the same
125
+ view that runs live on the website. By default it also writes a `code-browser.html`
126
+ and a `timeline.html` beside it with a working header nav; pass `--graph-only`
127
+ for just the single self-contained graph file.
128
+
129
+ ## Chat
130
+
131
+ ```bash
132
+ seonix chat [--repo /abs/path] [--with-claude | --with-copilot]
133
+ ```
134
+
135
+ An interactive client over the typed code-graph (deterministic, no model calls).
136
+ With no `--repo` the target defaults to the **git root** (`git rev-parse
137
+ --show-toplevel`), so running it from a nested package dir still indexes the whole
138
+ repo; pass `--repo <path>` to override. Any **bare line** is a plain-English
139
+ question dispatched through the mechanical `seonix_ask` engine — e.g. `which
140
+ modules import walk.mjs` — and the graph answers. Aggregate questions are answered
141
+ straight off the graph header: `how many classes are there` → `20 classes.`
142
+ (`how many <classes|functions|modules|methods|commits>`, `count the …`, `number of
143
+ …`). A near-miss structural question still gets the precise rephrase hint.
144
+
145
+ **Conversational manners** (deterministic, zero-model, recognised *before* any graph
146
+ dispatch). A small closed set of everyday expressions gets a templated reply rather
147
+ than a grammar hint:
148
+
149
+ | you say | you get |
150
+ | --- | --- |
151
+ | `hi` / `hello` / `hey` / `yo` / `g'day` / `good morning` (and `hello there`, with a small nod) | a short greeting + a nudge to ask or `/help` |
152
+ | `thanks` / `ta` / `cheers` / `ty` | an "any time" acknowledgement |
153
+ | `help` / `what can you do` / `who are you` / `?` | the orientation (the friendly help text; see also `/help`, `/stats`) |
154
+ | `bye` / `goodbye` / `quit` / `see ya` | a farewell **and a clean end of session** (flushes the log, closes — the `/exit` path) |
155
+ | `why` / `how` / `explain` / `say more` / `go on` / `elaborate` | **re-renders the last answer verbosely** — repeats it with the ask engine's traversal receipt and the full matched-entity list the terse render trims (no previous answer → it says so plainly) |
156
+
157
+ These turns resolve no code entity, so — like `/help` — they are recorded as plain
158
+ turns and never add `mgx:asksAbout` edges to the session graph.
159
+
160
+ **Slash-commands** reach every other query tool in one line each: `/find <query>`
161
+ (or `/search`) for a lexical search; `/context <symbol>` for the sized edit bundle
162
+ (start here to change code) and `/snippet <symbol>` for exact source; `/describe`,
163
+ `/signature`, `/members <class>`, `/subclasses <class>`, `/impact <module>`,
164
+ `/callers`, `/callees`, `/tests <symbol>`, `/untested`, `/history <symbol>`,
165
+ `/exports <module>` and `/arch [package]`, plus `/stats` for a one-screen overview
166
+ (entity counts, relationship counts, package totals). An unknown `/command` just
167
+ nudges you to `/help`; a bad symbol prints the tool's own clean error, never a stack.
168
+
169
+ The client tracks a **focus** entity for multi-turn context. A command or an answer
170
+ that resolves a primary entity remembers it (the prompt shows it, `seon(walk.mjs)>`);
171
+ a bare `it`/`this`/`that` in a question then binds to it, and a no-argument entity
172
+ command (`/context`, `/impact`, …) reuses it — so `what calls it` works straight
173
+ after `/describe walk.mjs`. Set it explicitly with `/focus <symbol>`. With no focus
174
+ set the client stays single-shot. `/help` lists the commands and the question
175
+ shapes; `/exit` (or Ctrl+C/Ctrl+D) leaves.
176
+
177
+ Each session is appended per turn to `<repo>/.seonix/session-<uuidv7>.log`, and
178
+ becomes first-class graph data like a commit — **slash-command turns included**: a
179
+ structured sidecar (`.seonix/sessions/*.jsonl`) records each turn's query, the
180
+ command used, and the entities it resolved/answered; the session enters `graph.json`
181
+ as a `Session` individual with `mgx:asksAbout` edges wherever a turn resolved an
182
+ entity (re-attached on every re-index), and it appears on the `seonix viz` timeline.
183
+
184
+ ### Opt-in LLM fallback (`--with-claude` / `--with-copilot`)
185
+
186
+ **Default OFF. Chat-only. Never in the benchmark, digest, or MCP path.** With a flag
187
+ set, and *only* when the mechanical engine returns a genuine **miss** on a bare
188
+ question (after the conversational layer and slash-commands have had their turn),
189
+ chat consults an external LLM CLI — spawned in the target repo as cwd, with a
190
+ timeout. To keep answers **graph-grounded** it asks the model to *compile one seonix
191
+ command* (given the tool surface), then runs that command mechanically and shows the
192
+ grounded result, **labelled `[LLM-assisted via …]`**. If the model can't produce a
193
+ valid command, its raw text is shown instead, clearly marked `[answer from external
194
+ model … — NOT the deterministic seonix engine]`; the two are never silently blended.
195
+
196
+ - `--with-claude` → `claude -p "<compile-prompt>" --model haiku` (cheapest model).
197
+ - `--with-copilot` → `gh copilot -- -p "<compile-prompt>" --allow-all-tools --no-color`.
198
+ **Limitation, honestly:** the GitHub Copilot CLI is an *agentic shell assistant*
199
+ (its native verbs are running commands and editing files), not a graph Q&A tool —
200
+ it has no knowledge of the seonix query surface beyond what the prompt hands it, so
201
+ it's a poorer fit than `claude -p` and is wired best-effort. Prefer `--with-claude`.
202
+
203
+ Any failure — tool binary absent, non-zero exit, unparseable output, or timeout —
204
+ degrades to the normal honest-miss hint; the flag never crashes or hangs the chat. If
205
+ the flag is set but the binary is missing, chat says so **once at startup** and
206
+ continues mechanical-only. No LLM call is ever made unless the flag is set **and** the
207
+ mechanical engine missed — the hot path (and every benchmark run) stays zero-model.
208
+
209
+ A real transcript against this repository's own index (verified answers):
210
+
211
+ ```text
212
+ seon> where is buildContextBundle defined
213
+ function buildContextBundle() is defined in packages/seonix/src/server.mjs at lines 138-260.
214
+
215
+ seon> what uses codegraph.mjs
216
+ bench/arms.mjs and bench/run.mjs and packages/seonix/src/ask.mjs and
217
+ packages/seonix/src/chat.mjs and packages/seonix/src/extract.mjs and
218
+ packages/seonix/src/server.mjs and packages/seonix/src/viz.mjs and scripts/rank-gate.mjs.
219
+
220
+ seon> when did walk.mjs change
221
+ packages/seonix/src/walk.mjs was last touched by commit a73ac1ba8bfb on 2026-07-01
222
+ ("feat(seonix): .seonixignore excludes […]"); 1 earlier commit recorded.
223
+
224
+ seon> which changes touch commit 85073031
225
+ commit 85073031ff05 touched modules packages/seonix/src/viz.mjs and
226
+ packages/seonix/test/viz.test.mjs; functions buildViewerData() and cytoscapeSource().
227
+
228
+ seon> which functions call bulidContextBundle
229
+ assuming you meant buildContextBundle: in packages/seonix/src/server.mjs
230
+ there is function dispatchTool().
231
+ ```
232
+
233
+ Question shapes: `which <functions|classes|modules> <import|call|use|test|touch> <name>`,
234
+ `what does <module> <import|export>`, `what uses <name>`, `where is <name> defined`,
235
+ `where is <name> mentioned`, `when did <name> change`, `which changes touch commit <sha>`,
236
+ `what does <term> mean`. Misspellings are corrected from a curated table, close
237
+ near-misses resolve through a bounded edit-distance pass and are announced
238
+ ("assuming you meant"), and anything the graph cannot answer is a stated miss with
239
+ a rephrase hint, never a guess.
65
240
 
66
241
  ## Languages
67
242
 
68
- Python (stdlib `ast`), JS/TS (ts-morph), and C# (Roslyn, with a tree-sitter
69
- fallback) all merged into the one typed graph. The C# extractor source ships in
70
- `roslyn/`; build it on the consumer side if you index C#.
243
+ Python (stdlib `ast`), JS/TS (the TypeScript compiler API, including CommonJS
244
+ `require`/`module.exports`), C# (Roslyn, with a tree-sitter fallback) and Java
245
+ (JavaParser, with a tree-sitter fallback), all merged into the one typed graph. The C# Roslyn helper's source ships in the package under `roslyn/`; build
246
+ it on the consumer side (needs the .NET SDK) if you index C#. The Java JavaParser
247
+ helper is a jar built from the repository's `java/` directory (`mvn package`) and
248
+ is not included in the npm package, so a package install indexes Java with the
249
+ tree-sitter fallback.
71
250
 
72
251
  ## Licence & safety
73
252
 
74
- **AGPL-3.0-only.** The network clause is intended for a hosted MCP tool hosted
75
- derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
253
+ **AGPL-3.0-only.** The network clause is intended for a hosted MCP tool, so that
254
+ hosted derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
76
255
  credits SEON + FAMIX). SEONIX never writes to your global agent config and never
77
256
  phones home; everything it writes lives under `.seonix/`.
package/bin/cli.mjs CHANGED
@@ -6,6 +6,13 @@
6
6
  // seonix cli index_repository '{"repo_path":"<abs>"}' → deterministic index
7
7
  // (honours <repo>/.seonixignore — gitignore-subset patterns; pass
8
8
  // `"ignores":false` in the JSON arg to index everything regardless)
9
+ // seonix cli index_repository '{"repo_paths":["<abs1>","<abs2>",…],"out_root":"<abs-dir>"}' → index
10
+ // n repos into ONE merged graph at <out_root>/.seonix/ (module ids prefixed with each repo's
11
+ // directory basename; out_root defaults to the paths' deepest common ancestor directory).
12
+ // Mutually exclusive with repo_path.
13
+ // seonix cli index_repository '{"multi_root":"<abs-dir>"}' → estate discovery: every immediate
14
+ // child directory carrying a .git (dir or file) is indexed as a repo, merged into
15
+ // <multi_root>/.seonix/ (discovered/skipped counts logged to stderr)
9
16
  // seonix cli digest '{"repo_path":"<abs>","modules":[…]}' → architecture map + per-module
10
17
  // context bundles to stdout (no-MCP arm)
11
18
  // seonix cli digest '{"repo_path":"<abs>","query":"<free text>"}' → same, but auto-locates +
@@ -19,9 +26,10 @@
19
26
  // (e.g. seonix cli seonix_ask '{"query":"<free text>"}' — mechanical, no-LLM NL question
20
27
  // over the graph, PLAN_MECHANICAL_CHAT.md; no bespoke wiring needed, this fallback covers it)
21
28
  // seonix hook-augment → PreToolUse Grep/Glob augmenter (stdin→stdout)
22
- // 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)
23
- // seonix viz --browser-out f.html [--browser-data-out f.json] [--scope product|<prefixes>] → also render the Chronograph code browser (temporal scrub + two-cursor diff + narration + ghost-branch merges + keyboard nav; PLAN_CODE_BROWSER.md)
29
+ // 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
30
+ // 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)
24
31
  // seonix viz --serve [--port N] [--focus <sym>] → serve the same viewer against THIS repo's index (the local equivalent of the site's #viz; code browser at /code-browser.html, live-reannotating on HEAD change via /code-browser-version)
32
+ // seonix chat [--repo <abs>] [--with-claude|--with-copilot] → interactive prompt over the mechanical seonix_ask engine; /exit to leave; session log → <repo>/.seonix/session-<uuidv7>.log. The optional --with-* flags add an opt-in LLM fallback used ONLY when the mechanical engine misses a bare question (chat-only, never in the benchmark path)
25
33
  //
26
34
  // The index step is offline and model-free (Python ast + git). It writes the
27
35
  // graph artifact to <repo_path>/.seonix/graph.json; the server (started with
@@ -30,7 +38,7 @@
30
38
  import { join } from "node:path";
31
39
  import { startServer } from "../src/server.mjs";
32
40
  import { dispatchTool, buildContextBundle } from "../src/server.mjs";
33
- import { indexRepository } from "../src/extract.mjs";
41
+ import { indexRepository, indexRepositories, discoverRepos } from "../src/extract.mjs";
34
42
  import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
35
43
  import * as source from "../src/source.mjs";
36
44
  import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
@@ -173,29 +181,68 @@ async function main() {
173
181
  const [mode, sub, payload] = process.argv.slice(2);
174
182
 
175
183
  if (mode === "cli") {
176
- // index mode: `cli index_repository '{"repo_path":"<abs>"}'`
184
+ // index mode: `cli index_repository '{"repo_path":"<abs>"}'` (single repo, unchanged),
185
+ // '{"repo_paths":[…],"out_root":"<abs>"}' (explicit n-repo merge) or
186
+ // '{"multi_root":"<abs>"}' (discover child repos, merge into <multi_root>/.seonix/).
177
187
  if (sub === "index_repository") {
178
188
  const args = parsePayload(payload);
179
189
  if (args === null) {
180
190
  process.stderr.write("seonix: index_repository expects a JSON arg, e.g. '{\"repo_path\":\"/abs\"}'\n");
181
191
  process.exit(2);
182
192
  }
183
- const repoPath = args.repo_path;
184
- if (!repoPath) {
185
- process.stderr.write("seonix: index_repository requires repo_path\n");
193
+ const given = ["repo_path", "repo_paths", "multi_root"].filter((k) => args[k] !== undefined);
194
+ if (given.length > 1) {
195
+ process.stderr.write(`seonix: ${given.join(" + ")} are mutually exclusive — pass exactly one\n`);
196
+ process.exit(2);
197
+ }
198
+ if (given.length === 0) {
199
+ process.stderr.write("seonix: index_repository requires repo_path, repo_paths or multi_root\n");
186
200
  process.exit(2);
187
201
  }
188
202
  const t0 = Date.now();
189
- const { graphFile, counts } = await indexRepository(repoPath, { ignores: args.ignores !== false });
190
- process.stderr.write(
191
- `seonix: indexed ${counts.modules} modules, ${counts.functions} symbols, ` +
192
- `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
193
- `${counts.commits} commits in ${Date.now() - t0}ms → ${graphFile}\n`,
194
- );
195
- process.stderr.write(
196
- `seonix: history-symbol pass +${counts.historyMs}ms over ${counts.baseMs}ms base ` +
197
- `= ${counts.historyPct}% (depth ${counts.historySymbolDepth}; budget ≤10%)\n`,
198
- );
203
+ const emitSummary = (graphFile, counts, head) => {
204
+ process.stderr.write(
205
+ `seonix: indexed ${head}${counts.modules} modules, ${counts.functions} symbols, ` +
206
+ `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
207
+ `${counts.commits} commits in ${Date.now() - t0}ms → ${graphFile}\n`,
208
+ );
209
+ process.stderr.write(
210
+ `seonix: history-symbol pass +${counts.historyMs}ms over ${counts.baseMs}ms base ` +
211
+ `= ${counts.historyPct}% (depth ${counts.historySymbolDepth}; budget ≤10%)\n`,
212
+ );
213
+ };
214
+
215
+ if (args.repo_path) {
216
+ const { graphFile, counts } = await indexRepository(args.repo_path, { ignores: args.ignores !== false });
217
+ emitSummary(graphFile, counts, "");
218
+ return;
219
+ }
220
+
221
+ let repoPaths = args.repo_paths;
222
+ let outRoot = args.out_root || "";
223
+ if (args.multi_root) {
224
+ const { repos, skipped } = await discoverRepos(args.multi_root);
225
+ process.stderr.write(
226
+ `seonix: multi_root ${args.multi_root}: ${repos.length} repo(s) discovered` +
227
+ (skipped.length ? `; skipped ${skipped.length} non-repo child dir(s): ${skipped.join(", ")}` : "") + "\n",
228
+ );
229
+ if (!repos.length) {
230
+ process.stderr.write("seonix: multi_root contains no repositories (no child directory has a .git)\n");
231
+ process.exit(2);
232
+ }
233
+ repoPaths = repos;
234
+ outRoot = args.multi_root;
235
+ }
236
+ if (!Array.isArray(repoPaths) || repoPaths.length === 0) {
237
+ process.stderr.write("seonix: repo_paths must be a non-empty array of absolute paths\n");
238
+ process.exit(2);
239
+ }
240
+ const { graphFile, counts, repos } = await indexRepositories(repoPaths, {
241
+ ignores: args.ignores !== false,
242
+ outRoot,
243
+ log: (line) => process.stderr.write(`seonix: ${line}\n`),
244
+ });
245
+ emitSummary(graphFile, counts, `${repos.length} repos, `);
199
246
  return;
200
247
  }
201
248
 
@@ -315,13 +362,26 @@ async function main() {
315
362
  return;
316
363
  }
317
364
 
365
+ if (mode === "chat") {
366
+ const { runChat } = await import("../src/chat.mjs");
367
+ const i = process.argv.indexOf("--repo");
368
+ // Opt-in LLM fallback (chat-only, default OFF, miss-gated — never in the benchmark
369
+ // path): --with-claude shells `claude -p`, --with-copilot shells `gh copilot -- -p`.
370
+ await runChat({
371
+ repoPath: i !== -1 ? process.argv[i + 1] : undefined,
372
+ withClaude: process.argv.includes("--with-claude"),
373
+ withCopilot: process.argv.includes("--with-copilot"),
374
+ });
375
+ return;
376
+ }
377
+
318
378
  if (mode === undefined) {
319
379
  await startServer(); // bare invocation → MCP stdio server
320
380
  return;
321
381
  }
322
382
 
323
383
  process.stderr.write(`seonix: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
324
- "Use bare (MCP server), `cli index_repository …`, `hook-augment`, or `viz`.\n");
384
+ "Use bare (MCP server), `cli index_repository …`, `cli <tool> …`, `chat`, `hook-augment`, or `viz`.\n");
325
385
  process.exit(2);
326
386
  }
327
387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
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.",
@@ -45,10 +45,12 @@
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
47
  "cytoscape": "^3.30.0",
48
- "ts-morph": "^24.0.0",
49
48
  "tree-sitter": "^0.21.1",
50
49
  "tree-sitter-c-sharp": "^0.21.3",
51
- "tree-sitter-java": "^0.21.0"
50
+ "tree-sitter-java": "^0.21.0",
51
+ "typescript": "~5.6.2",
52
+ "wink-eng-lite-web-model": "^1.8.1",
53
+ "wink-nlp": "^2.4.0"
52
54
  },
53
55
  "scripts": {
54
56
  "test": "node --test \"test/**/*.test.mjs\"",
package/roslyn/Program.cs CHANGED
@@ -56,6 +56,20 @@ List<string> CallsIn(SyntaxNode body) {
56
56
  name = OneLine(name);
57
57
  if (name.Length > 0) set.Add(Clip(name, 80));
58
58
  }
59
+ // Object creations count as call edges to the created type: `new X(...)` → "X".
60
+ // Always the BARE type identifier (generics/qualifiers stripped) so extract.mjs's
61
+ // unique-lastIdent registry can resolve it to the internal Class. Implicit
62
+ // `new(...)` (no type syntax) is a different node type and stays excluded.
63
+ foreach (var oc in body.DescendantNodes().OfType<ObjectCreationExpressionSyntax>()) {
64
+ var t = oc.Type;
65
+ if (t is QualifiedNameSyntax q) t = q.Right; // rightmost identifier
66
+ string tn = t switch {
67
+ GenericNameSyntax g => g.Identifier.Text,
68
+ IdentifierNameSyntax id => id.Identifier.Text,
69
+ _ => null
70
+ };
71
+ if (!string.IsNullOrEmpty(tn)) set.Add(Clip(tn, 80));
72
+ }
59
73
  return set.ToList();
60
74
  }
61
75
 
@@ -66,6 +80,38 @@ string Vis(SyntaxTokenList mods) {
66
80
  }
67
81
  bool IsPublic(SyntaxTokenList mods) => mods.Any(m => m.Text == "public");
68
82
 
83
+ // Nested types qualify as Outer.Inner (members then inherit it via cname+"."+member).
84
+ string QualName(BaseTypeDeclarationSyntax t) {
85
+ var name = t.Identifier.Text;
86
+ for (var p = t.Parent; p is BaseTypeDeclarationSyntax outer; p = p.Parent)
87
+ name = outer.Identifier.Text + "." + name;
88
+ return name;
89
+ }
90
+
91
+ // kind stays "class" for every type declaration; subkind distinguishes the flavour
92
+ // (record covers `record struct` too — same syntax node). Plain class → null (omitted).
93
+ string SubkindOf(BaseTypeDeclarationSyntax t) => t switch {
94
+ InterfaceDeclarationSyntax => "interface",
95
+ EnumDeclarationSyntax => "enum",
96
+ RecordDeclarationSyntax => "record",
97
+ StructDeclarationSyntax => "struct",
98
+ _ => null
99
+ };
100
+
101
+ // First non-empty line of the XML doc comment (///), tags stripped, clipped to 120
102
+ // chars — mirrors the Java extractor's javadoc emission (extract.mjs consumes d.doc).
103
+ string DocOf(SyntaxNode n) {
104
+ foreach (var trivia in n.GetLeadingTrivia()) {
105
+ if (trivia.GetStructure() is not DocumentationCommentTriviaSyntax dc) continue;
106
+ var text = System.Text.RegularExpressions.Regex.Replace(dc.ToFullString(), "<[^>]*>", " ");
107
+ foreach (var rawLine in text.Split('\n')) {
108
+ var s = rawLine.Trim().TrimStart('/').Trim();
109
+ if (s.Length > 0) return Clip(s, 120);
110
+ }
111
+ }
112
+ return "";
113
+ }
114
+
69
115
  var modules = new List<object>();
70
116
  var failures = new List<string>();
71
117
 
@@ -91,17 +137,34 @@ foreach (var file in files) {
91
137
  var defines = new List<object>();
92
138
  var exports = new SortedSet<string>(StringComparer.Ordinal);
93
139
 
94
- foreach (var type in rootNode.DescendantNodes().OfType<TypeDeclarationSyntax>()) {
95
- var cname = type.Identifier.Text;
96
- var bases = type.BaseList?.Types.Select(t => Clip(OneLine(t.ToString()), 80)).ToList() ?? new List<string>();
140
+ // BaseTypeDeclarationSyntax covers classes/interfaces/structs/records AND enums
141
+ // (TypeDeclarationSyntax alone missed EnumDeclarationSyntax entirely). Document
142
+ // order, so nested types follow their parent names qualify via QualName.
143
+ foreach (var btd in rootNode.DescendantNodes().OfType<BaseTypeDeclarationSyntax>()) {
144
+ var cname = QualName(btd);
145
+ var bases = btd.BaseList?.Types.Select(t => Clip(OneLine(t.ToString()), 80)).ToList() ?? new List<string>();
97
146
  var cdef = new Dictionary<string, object> {
98
- ["name"] = cname, ["kind"] = "class", ["lineno"] = LineOf(type), ["end_lineno"] = EndLineOf(type),
99
- ["bases"] = bases, ["decorators"] = type.AttributeLists.SelectMany(a => a.Attributes).Select(a => OneLine(a.ToString())).ToList()
147
+ ["name"] = cname, ["kind"] = "class", ["lineno"] = LineOf(btd), ["end_lineno"] = EndLineOf(btd),
148
+ ["bases"] = bases, ["decorators"] = btd.AttributeLists.SelectMany(a => a.Attributes).Select(a => OneLine(a.ToString())).ToList()
100
149
  };
101
- if (IsPublic(type.Modifiers)) exports.Add(cname);
102
- var v = Vis(type.Modifiers); if (v != "") cdef["visibility"] = v;
150
+ var subkind = SubkindOf(btd); if (subkind != null) cdef["subkind"] = subkind;
151
+ var cdoc = DocOf(btd); if (cdoc != "") cdef["doc"] = cdoc;
152
+ if (IsPublic(btd.Modifiers)) exports.Add(cname);
153
+ var v = Vis(btd.Modifiers); if (v != "") cdef["visibility"] = v;
103
154
  defines.Add(cdef);
104
155
 
156
+ if (btd is EnumDeclarationSyntax en) {
157
+ foreach (var em in en.Members) {
158
+ defines.Add(new Dictionary<string, object> {
159
+ ["name"] = $"{cname}.{em.Identifier.Text}", ["kind"] = "attribute",
160
+ ["lineno"] = LineOf(em), ["end_lineno"] = EndLineOf(em), ["decorators"] = new List<string>(),
161
+ ["is_constant"] = true
162
+ });
163
+ }
164
+ continue;
165
+ }
166
+ if (btd is not TypeDeclarationSyntax type) continue;
167
+
105
168
  foreach (var mem in type.Members) {
106
169
  if (mem is MethodDeclarationSyntax m) {
107
170
  var md = new Dictionary<string, object> {
@@ -113,19 +176,24 @@ foreach (var file in files) {
113
176
  };
114
177
  if (m.Modifiers.Any(x => x.Text == "static")) md["is_static"] = true;
115
178
  var mv = Vis(m.Modifiers); if (mv != "") md["visibility"] = mv;
179
+ var mdoc = DocOf(m); if (mdoc != "") md["doc"] = mdoc;
116
180
  defines.Add(md);
117
181
  } else if (mem is ConstructorDeclarationSyntax ctor) {
118
- defines.Add(new Dictionary<string, object> {
182
+ var ctd = new Dictionary<string, object> {
119
183
  ["name"] = $"{cname}.{ctor.Identifier.Text}", ["kind"] = "method",
120
184
  ["lineno"] = LineOf(ctor), ["end_lineno"] = EndLineOf(ctor), ["decorators"] = new List<string>(),
121
185
  ["params"] = Clip(OneLine(string.Join(", ", ctor.ParameterList.Parameters.Select(p => p.ToString()))), 160),
122
186
  ["calls"] = CallsIn(ctor.Body as SyntaxNode ?? ctor.ExpressionBody)
123
- });
187
+ };
188
+ var cdoc2 = DocOf(ctor); if (cdoc2 != "") ctd["doc"] = cdoc2;
189
+ defines.Add(ctd);
124
190
  } else if (mem is PropertyDeclarationSyntax prop) {
125
- defines.Add(new Dictionary<string, object> {
191
+ var pd = new Dictionary<string, object> {
126
192
  ["name"] = $"{cname}.{prop.Identifier.Text}", ["kind"] = "attribute",
127
193
  ["lineno"] = LineOf(prop), ["end_lineno"] = EndLineOf(prop), ["decorators"] = new List<string>()
128
- });
194
+ };
195
+ var pdoc = DocOf(prop); if (pdoc != "") pd["doc"] = pdoc;
196
+ defines.Add(pd);
129
197
  } else if (mem is FieldDeclarationSyntax fld) {
130
198
  foreach (var vd in fld.Declaration.Variables) {
131
199
  defines.Add(new Dictionary<string, object> {