@polycode-projects/seonix 0.2.1 → 0.3.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 +132 -23
- package/bin/cli.mjs +71 -17
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +172 -3
- package/src/ask.mjs +703 -79
- package/src/browser.mjs +12 -6
- package/src/chat.mjs +188 -0
- package/src/codegraph.mjs +159 -4
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/prose-nlp.mjs +52 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/timeline.mjs +117 -0
- package/src/viz.mjs +185 -64
package/README.md
CHANGED
|
@@ -1,21 +1,49 @@
|
|
|
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
|
|
6
|
-
**edit digest** you inject once at session start. Your agent edits from
|
|
7
|
-
without fishing through the repo.
|
|
8
|
-
|
|
9
|
-
> **Headline.**
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
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
|
+
|
|
9
|
+
> **Headline.** Measured as **mean** input tokens per both-solved task (paired
|
|
10
|
+
> arms, identical model pin): on the Django localisation suite the no-MCP
|
|
11
|
+
> `seonix` digest cut mean input tokens **38% on Haiku 4.5** (`BENCHMARK_016.md`
|
|
12
|
+
> restatement, N=5). Under the score-gap default shipped 2026-07-02 (R1b), the
|
|
13
|
+
> re-tested Django cell on Sonnet 4.6 shows a **41% mean** cut and a C# cell on
|
|
14
|
+
> Sonnet 4.6 shows **57.9% mean**, clearing the ≥50% decision bar. Indexing costs
|
|
15
|
+
> **$0**. The win is model-conditional; full method, numbers and the measurement
|
|
16
|
+
> contract are in the repo.
|
|
14
17
|
|
|
15
18
|
- **Website / live demo:** https://seonix.polycode.co.uk
|
|
16
19
|
- **Source + benchmarks:** https://gitlab.com/polycode-projects/seonix
|
|
17
20
|
- **Licence:** AGPL-3.0-only
|
|
18
21
|
|
|
22
|
+
## Cross-model comparison
|
|
23
|
+
|
|
24
|
+
seonix on the cheaper **Sonnet 4.6** vs the flagship **Opus 4.8** out of the box,
|
|
25
|
+
mean input tokens per solved run (N=3; N=5 where an otb cell reuses the B015
|
|
26
|
+
baseline). `+` means seonix on Sonnet used fewer tokens.
|
|
27
|
+
|
|
28
|
+
| full task name | otb / Opus 4.8 | solved | seonix / Sonnet 4.6 | solved | token change |
|
|
29
|
+
| --- | --- | --- | --- | --- | --- |
|
|
30
|
+
| ts-hono-utils-helper (simple) | 135k | 3/3 | 117k | 3/3 | +13% |
|
|
31
|
+
| ts-hono-response-time-middleware | 345k | 3/3 | 555k | 3/3 | −61% |
|
|
32
|
+
| ts-hono-json-envelope | 1,102k | 3/3 | 864k | 3/3 | +22% |
|
|
33
|
+
| py-django-truncatelines-filter | 610k | 5/5 | 524k | 5/5 | +14% |
|
|
34
|
+
| cs-eshoponweb-order-service-total | 318k | 5/5 | 154k | 3/3 | +51% |
|
|
35
|
+
| cs-eshoponweb-basket-service-total | 353k | 5/5 | 255k | 3/3 | +28% |
|
|
36
|
+
| js-express-request-id | 330k | 5/5 | 267k | 3/3 | +19% |
|
|
37
|
+
| js-express-json-envelope | 174k | 5/5 | 185k | 3/3 | −7% |
|
|
38
|
+
|
|
39
|
+
seonix on Sonnet used fewer input tokens than otb on Opus on six of these eight
|
|
40
|
+
tasks at equal correctness on these tasks. This crosses model and arm together, so
|
|
41
|
+
it is an illustration of practical value, not the paired within-model measurement
|
|
42
|
+
the ≥50% decision rule uses. Across all 13 measured seonix cells the drop in input
|
|
43
|
+
tokens is 0.91-correlated (Pearson) with the drop in turns (slope about 0.71):
|
|
44
|
+
seonix saves tokens by letting the agent skip exploration turns, so where it
|
|
45
|
+
removes no turns there is little to save.
|
|
46
|
+
|
|
19
47
|
## Install
|
|
20
48
|
|
|
21
49
|
```bash
|
|
@@ -25,8 +53,11 @@ npx @polycode-projects/seonix index /abs/path/to/repo
|
|
|
25
53
|
```
|
|
26
54
|
|
|
27
55
|
**Runtime prerequisites:** Node ≥ 24, **Python 3**, and **git** on `PATH` (the
|
|
28
|
-
indexer shells out to Python's `ast` and `git log`).
|
|
29
|
-
|
|
56
|
+
indexer shells out to Python's `ast` and `git log`). A Java runtime is needed
|
|
57
|
+
only to index Java through JavaParser, and the .NET SDK only to index C# through
|
|
58
|
+
Roslyn; without them, indexing for that language falls back to the bundled
|
|
59
|
+
tree-sitter parser. Indexing is fully local and deterministic: no API keys, no
|
|
60
|
+
network calls, **$0**.
|
|
30
61
|
|
|
31
62
|
## Quickstart
|
|
32
63
|
|
|
@@ -44,15 +75,42 @@ seonix cli digest '{"repo_path":"/abs/path/to/repo","modules":["<top hits>"]}' >
|
|
|
44
75
|
claude --prompt "Use the seonix digest below to localise and make the change. $(cat digest.txt)"
|
|
45
76
|
```
|
|
46
77
|
|
|
47
|
-
Add `.seonix/` to your `.gitignore
|
|
78
|
+
Add `.seonix/` to your `.gitignore`; it is machine-local and rebuilt from source.
|
|
79
|
+
|
|
80
|
+
## Indexing multiple repositories
|
|
81
|
+
|
|
82
|
+
`index_repository` takes one of three argument forms:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# one repo (unchanged): artifacts to <repo_path>/.seonix/, module ids unprefixed
|
|
86
|
+
seonix cli index_repository '{"repo_path":"/abs/repo"}'
|
|
87
|
+
|
|
88
|
+
# n explicit repos, merged into ONE graph at <out_root>/.seonix/
|
|
89
|
+
seonix cli index_repository '{"repo_paths":["/abs/repo1","/abs/repo2"],"out_root":"/abs/dir"}'
|
|
90
|
+
|
|
91
|
+
# estate discovery: every immediate child directory of multi_root that contains a
|
|
92
|
+
# .git (dir or file) is indexed as a repo; artifacts to <multi_root>/.seonix/
|
|
93
|
+
seonix cli index_repository '{"multi_root":"/abs/estate"}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The forms are mutually exclusive. `out_root` defaults to the deepest common
|
|
97
|
+
ancestor directory of the paths (or the current directory if that ancestor is the
|
|
98
|
+
filesystem root). In the merged graph every module id and label is prefixed with
|
|
99
|
+
the repo's directory basename (`mod:<repoName>/<relpath>`); when two repos share a
|
|
100
|
+
basename the first in path sort order keeps the bare name and later ones get `-2`,
|
|
101
|
+
`-3`, … appended. Each repo's git history and `.seonixignore` are handled per repo,
|
|
102
|
+
exactly as in single mode. Cross-repo call edges only arise where a symbol name
|
|
103
|
+
resolves uniquely across the whole merged graph; ambiguous names are dropped, as
|
|
104
|
+
within one repo. Query a merged graph by passing the merge root as `repo_path`
|
|
105
|
+
(e.g. `seonix cli seonix_search '{"repo_path":"/abs/estate","query":"…"}'`).
|
|
48
106
|
|
|
49
107
|
## The shape that ships
|
|
50
108
|
|
|
51
109
|
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
|
|
53
|
-
never cleared the 50% bar
|
|
54
|
-
|
|
55
|
-
a known target (`oracle`) only to measure the headroom.
|
|
110
|
+
with **no server**. The interactive MCP tool loop is retired. Even mitigated it
|
|
111
|
+
never cleared the 50% bar, because the cost lies in the round-trip count rather
|
|
112
|
+
than the resident schema. Use `seonix_search` (`locate`, self-derives the target)
|
|
113
|
+
in production; pass a known target (`oracle`) only to measure the headroom.
|
|
56
114
|
|
|
57
115
|
## Visualise
|
|
58
116
|
|
|
@@ -61,17 +119,68 @@ seonix viz --focus <Symbol> --out graph.html
|
|
|
61
119
|
```
|
|
62
120
|
|
|
63
121
|
Renders a focused, portable Cytoscape sub-graph of the typed code-map as one
|
|
64
|
-
self-contained HTML file
|
|
122
|
+
self-contained HTML file, the same view that runs live on the website.
|
|
123
|
+
|
|
124
|
+
## Chat
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
seonix chat [--repo /abs/path]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
An interactive prompt over the mechanical `seonix_ask` engine (deterministic, no
|
|
131
|
+
model calls): type a question about the indexed codebase — e.g. `which modules
|
|
132
|
+
import walk.mjs` — and the graph answers; `/exit` (or Ctrl+C/Ctrl+D) leaves. Each
|
|
133
|
+
session is appended per turn to `<repo>/.seonix/session-<uuidv7>.log`, and becomes
|
|
134
|
+
first-class graph data like a commit: a structured sidecar
|
|
135
|
+
(`.seonix/sessions/*.jsonl`) records which entities each turn asked about, the
|
|
136
|
+
session enters `graph.json` as a `Session` individual with `mgx:asksAbout` edges
|
|
137
|
+
(re-attached on every re-index), and it appears on the `seonix viz` timeline.
|
|
138
|
+
|
|
139
|
+
A real transcript against this repository's own index (verified answers):
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
seon> where is buildContextBundle defined
|
|
143
|
+
function buildContextBundle() is defined in packages/seonix/src/server.mjs at lines 138-260.
|
|
144
|
+
|
|
145
|
+
seon> what uses codegraph.mjs
|
|
146
|
+
bench/arms.mjs and bench/run.mjs and packages/seonix/src/ask.mjs and
|
|
147
|
+
packages/seonix/src/chat.mjs and packages/seonix/src/extract.mjs and
|
|
148
|
+
packages/seonix/src/server.mjs and packages/seonix/src/viz.mjs and scripts/rank-gate.mjs.
|
|
149
|
+
|
|
150
|
+
seon> when did walk.mjs change
|
|
151
|
+
packages/seonix/src/walk.mjs was last touched by commit a73ac1ba8bfb on 2026-07-01
|
|
152
|
+
("feat(seonix): .seonixignore excludes […]"); 1 earlier commit recorded.
|
|
153
|
+
|
|
154
|
+
seon> which changes touch commit 85073031
|
|
155
|
+
commit 85073031ff05 touched modules packages/seonix/src/viz.mjs and
|
|
156
|
+
packages/seonix/test/viz.test.mjs; functions buildViewerData() and cytoscapeSource().
|
|
157
|
+
|
|
158
|
+
seon> which functions call bulidContextBundle
|
|
159
|
+
assuming you meant buildContextBundle: in packages/seonix/src/server.mjs
|
|
160
|
+
there is function dispatchTool().
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Question shapes: `which <functions|classes|modules> <import|call|use|test|touch> <name>`,
|
|
164
|
+
`what does <module> <import|export>`, `what uses <name>`, `where is <name> defined`,
|
|
165
|
+
`where is <name> mentioned`, `when did <name> change`, `which changes touch commit <sha>`,
|
|
166
|
+
`what does <term> mean`. Misspellings are corrected from a curated table, close
|
|
167
|
+
near-misses resolve through a bounded edit-distance pass and are announced
|
|
168
|
+
("assuming you meant"), and anything the graph cannot answer is a stated miss with
|
|
169
|
+
a rephrase hint, never a guess.
|
|
65
170
|
|
|
66
171
|
## Languages
|
|
67
172
|
|
|
68
|
-
Python (stdlib `ast`), JS/TS (ts-morph),
|
|
69
|
-
fallback)
|
|
70
|
-
|
|
173
|
+
Python (stdlib `ast`), JS/TS (ts-morph), C# (Roslyn, with a tree-sitter fallback)
|
|
174
|
+
and Java (JavaParser, with a tree-sitter fallback), all merged into the one typed
|
|
175
|
+
graph. The C# Roslyn helper's source ships in the package under `roslyn/`; build
|
|
176
|
+
it on the consumer side (needs the .NET SDK) if you index C#. The Java JavaParser
|
|
177
|
+
helper is a jar built from the repository's `java/` directory (`mvn package`) and
|
|
178
|
+
is not included in the npm package, so a package install indexes Java with the
|
|
179
|
+
tree-sitter fallback.
|
|
71
180
|
|
|
72
181
|
## Licence & safety
|
|
73
182
|
|
|
74
|
-
**AGPL-3.0-only.** The network clause is intended for a hosted MCP tool
|
|
75
|
-
derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
|
|
183
|
+
**AGPL-3.0-only.** The network clause is intended for a hosted MCP tool, so that
|
|
184
|
+
hosted derivatives stay open. The `seonix:` ontology vocabulary is CC-BY-4.0 (NOTICE
|
|
76
185
|
credits SEON + FAMIX). SEONIX never writes to your global agent config and never
|
|
77
186
|
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>] →
|
|
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>] → interactive prompt over the mechanical seonix_ask engine; /exit to leave; session log → <repo>/.seonix/session-<uuidv7>.log
|
|
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
|
|
184
|
-
if (
|
|
185
|
-
process.stderr.write(
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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,6 +362,13 @@ 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
|
+
await runChat({ repoPath: i !== -1 ? process.argv[i + 1] : undefined });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
318
372
|
if (mode === undefined) {
|
|
319
373
|
await startServer(); // bare invocation → MCP stdio server
|
|
320
374
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/seonix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
99
|
-
["bases"] = bases, ["decorators"] =
|
|
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
|
-
|
|
102
|
-
var
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/ask-nlp.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ask-nlp.mjs — the OPTIONAL wink-nlp adapter behind ask.mjs's lemma/POS tier.
|
|
2
|
+
//
|
|
3
|
+
// BOUNDARY (hard, do not move): this file is Node-only and is NEVER inlined into
|
|
4
|
+
// the viewer bundle. viz.mjs's askSource() inlines codegraph.mjs + ask-vocab.mjs +
|
|
5
|
+
// ask.mjs ONLY and strips their import lines, so the portable single-file HTML has
|
|
6
|
+
// no wink, no model, and no `nlpAdapter` binding at all — ask.mjs reaches this
|
|
7
|
+
// module exclusively through a `typeof nlpAdapter === "function"` guard and
|
|
8
|
+
// degrades to adapter-less parsing (lemma/POS tiers off; the curated tables and
|
|
9
|
+
// the bounded edit-distance tier still work, browser and Node alike). Keeping the
|
|
10
|
+
// ~1MB CJS model out of the page is the point of the split.
|
|
11
|
+
//
|
|
12
|
+
// wink-nlp and wink-eng-lite-web-model are CJS — loaded via createRequire, the
|
|
13
|
+
// same Node-module-resolution approach viz.mjs uses to locate cytoscape (resolve
|
|
14
|
+
// through the module system, never a guessed path). The require happens lazily on
|
|
15
|
+
// first use and failure is cached as null: a checkout without the optional deps
|
|
16
|
+
// installed answers exactly like the browser bundle, it never throws.
|
|
17
|
+
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
|
|
20
|
+
let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
|
|
21
|
+
|
|
22
|
+
/** Lazily build the {lemma, posTags} adapter, or null when wink isn't loadable.
|
|
23
|
+
* Deterministic: wink-nlp's tagger/lemmatiser is a fixed model with no sampling,
|
|
24
|
+
* so the same input always yields the same tags/lemmas across processes. */
|
|
25
|
+
export function nlpAdapter() {
|
|
26
|
+
if (cached !== undefined) return cached;
|
|
27
|
+
try {
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const winkNLP = require("wink-nlp");
|
|
30
|
+
const model = require("wink-eng-lite-web-model");
|
|
31
|
+
const nlp = winkNLP(model);
|
|
32
|
+
const its = nlp.its;
|
|
33
|
+
cached = {
|
|
34
|
+
/** Lowercase lemma of a single token ("imported" -> "import"); the word
|
|
35
|
+
* itself when wink has nothing better (unknown words come back as-is). */
|
|
36
|
+
lemma(word) {
|
|
37
|
+
const w = String(word || "");
|
|
38
|
+
try {
|
|
39
|
+
const out = nlp.readDoc(w).tokens().out(its.lemma);
|
|
40
|
+
return String(out[0] || w).toLowerCase();
|
|
41
|
+
} catch {
|
|
42
|
+
return w.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
/** UPOS tags aligned to the CALLER's word array. wink re-tokenizes (it
|
|
46
|
+
* splits "walk.mjs" into three tokens), so each input word is greedily
|
|
47
|
+
* matched to the run of wink tokens that spell it and takes its FIRST
|
|
48
|
+
* sub-token's tag; null per word on any surprise, never a throw. */
|
|
49
|
+
posTags(words) {
|
|
50
|
+
try {
|
|
51
|
+
const toks = nlp.readDoc(words.join(" ")).tokens();
|
|
52
|
+
const texts = toks.out();
|
|
53
|
+
const tags = toks.out(its.pos);
|
|
54
|
+
const out = [];
|
|
55
|
+
let k = 0;
|
|
56
|
+
for (const w of words) {
|
|
57
|
+
if (k >= texts.length) { out.push(null); continue; }
|
|
58
|
+
out.push(tags[k]);
|
|
59
|
+
let acc = texts[k];
|
|
60
|
+
k += 1;
|
|
61
|
+
while (acc.length < w.length && k < texts.length) { acc += texts[k]; k += 1; }
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
} catch {
|
|
65
|
+
return words.map(() => null);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
cached = null;
|
|
71
|
+
}
|
|
72
|
+
return cached;
|
|
73
|
+
}
|