@polycode-projects/seonix 0.9.1 → 0.10.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 +23 -7
- package/bin/cli.mjs +92 -11
- package/package.json +2 -9
- package/src/ask-browser.bundle.js +136 -18
- package/src/browser.mjs +9 -3
- package/src/codegraph.mjs +126 -18
- package/src/cs_treesitter.mjs +58 -3
- package/src/extract.mjs +98 -23
- package/src/extract_lang.mjs +4 -2
- package/src/interfaces.mjs +110 -16
- package/src/jsts_tsc.mjs +51 -0
- package/src/schema-docs.mjs +33 -3
- package/src/server.mjs +51 -7
- package/src/summary.mjs +14 -3
- package/src/telemetry.mjs +31 -0
package/README.md
CHANGED
|
@@ -46,13 +46,13 @@ network calls, **$0**.
|
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
# 1. index your repo (deterministic, offline) — writes a machine-local .seonix/
|
|
49
|
-
|
|
49
|
+
cd /path/to/repo && seonix cli index_repository
|
|
50
50
|
|
|
51
51
|
# 2. self-locate the touched modules from the task text (no human-chosen target)
|
|
52
|
-
seonix cli seonix_search '{"
|
|
52
|
+
seonix cli seonix_search '{"query":"<your task>"}'
|
|
53
53
|
|
|
54
54
|
# 3. render the bounded edit digest for those modules
|
|
55
|
-
seonix cli digest '{"
|
|
55
|
+
seonix cli digest '{"modules":["<top hits>"]}' > digest.txt
|
|
56
56
|
|
|
57
57
|
# 4. inject digest.txt once at session start, then run your coding agent
|
|
58
58
|
claude --prompt "Use the seonix digest below to localise and make the change. $(cat digest.txt)"
|
|
@@ -60,6 +60,13 @@ claude --prompt "Use the seonix digest below to localise and make the change. $(
|
|
|
60
60
|
|
|
61
61
|
Add `.seonix/` to your `.gitignore`; it is machine-local and rebuilt from source.
|
|
62
62
|
|
|
63
|
+
The JSON argument is optional on every `cli` command: an omitted `repo_path`
|
|
64
|
+
resolves to the nearest ancestor of the current directory (current directory
|
|
65
|
+
first) containing a `.seonix/`, so query commands work from any subdirectory of
|
|
66
|
+
an indexed repo. `index_repository` falls back to the current directory when
|
|
67
|
+
nothing is indexed yet; query commands print a one-line error instead. An
|
|
68
|
+
explicit `repo_path` always wins.
|
|
69
|
+
|
|
63
70
|
## Indexing multiple repositories
|
|
64
71
|
|
|
65
72
|
`index_repository` takes one of three argument forms:
|
|
@@ -117,14 +124,17 @@ should start it with cwd = the indexed worktree.
|
|
|
117
124
|
### `cli index_repository` — build the graph
|
|
118
125
|
|
|
119
126
|
```bash
|
|
127
|
+
seonix cli index_repository # no arg: index the repo you are in
|
|
120
128
|
seonix cli index_repository '{"repo_path":"/abs/repo"}'
|
|
121
129
|
seonix cli index_repository '{"repo_paths":["/abs/repo1","/abs/repo2"],"out_root":"/abs/dir"}'
|
|
122
130
|
seonix cli index_repository '{"multi_root":"/abs/estate"}'
|
|
123
131
|
```
|
|
124
132
|
|
|
125
133
|
`repo_path` / `repo_paths`+`out_root` / `multi_root` are mutually exclusive — pass
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
at most one. When none is given, `seonix.toml`'s `repositories` supplies the set
|
|
135
|
+
if present; otherwise the target defaults to the nearest ancestor of the current
|
|
136
|
+
directory carrying a `.seonix/` (re-index), else the current directory itself
|
|
137
|
+
(first index). Shared JSON options:
|
|
128
138
|
|
|
129
139
|
- `ignores` (bool, default `true`) — honour the repo's `.seonixignore`. Pass
|
|
130
140
|
`"ignores":false` to index everything regardless (the bench harness always
|
|
@@ -235,7 +245,9 @@ seonix cli seonix_context '{"symbol":"buildContextBundle","repo_path":"/abs/repo
|
|
|
235
245
|
Any sub-command name that isn't one of the above routes straight to the MCP tool
|
|
236
246
|
dispatcher, so any tool in the server's surface — cold or hot — is invokable from
|
|
237
247
|
Bash with no MCP connection. `repo_path` in the JSON arg selects the target
|
|
238
|
-
graph;
|
|
248
|
+
graph; omitted, it defaults to the nearest ancestor of the current directory
|
|
249
|
+
containing a `.seonix/` (a clean one-line error when there is none). Every other
|
|
250
|
+
key is passed through as the tool's own arguments.
|
|
239
251
|
|
|
240
252
|
### `hook-augment` — PreToolUse Grep/Glob augmenter
|
|
241
253
|
|
|
@@ -316,7 +328,11 @@ seonix supplies the graph through tmct's provider seam.
|
|
|
316
328
|
Two opt-in/opt-out environment knobs, orthogonal to everything above:
|
|
317
329
|
`SEONIX_STORE=sqlite` turns on the resident `node:sqlite` store as a derived,
|
|
318
330
|
rebuildable companion to `graph.json` (rebuild it explicitly with `cli
|
|
319
|
-
store_rebuild`; unset, nothing sqlite loads).
|
|
331
|
+
store_rebuild`; unset, nothing sqlite loads). The variable must be **exported**
|
|
332
|
+
(`export SEONIX_STORE=sqlite`) — a plain shell assignment is invisible to the
|
|
333
|
+
`seonix` child process, and the JSON path runs instead. The index summary's
|
|
334
|
+
`store:` line states which mode actually ran (`store: sqlite (graph.db +
|
|
335
|
+
graph.json)` only when `graph.db` was built). `SEONIX_GRAPH_FORMAT` controls the
|
|
320
336
|
graph's on-disk wire format — the interned v2 form is the shipped default;
|
|
321
337
|
set `SEONIX_GRAPH_FORMAT=1` to opt back out to the legacy v1 form.
|
|
322
338
|
|
package/bin/cli.mjs
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
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
|
+
// The JSON arg is OPTIONAL for every `cli` command: an omitted repo_path defaults
|
|
10
|
+
// to the nearest ancestor of cwd (cwd first) containing a .seonix/ directory —
|
|
11
|
+
// so a bare `seonix cli index_repository` indexes the repo you are in, and query
|
|
12
|
+
// commands run from any subdirectory of an indexed repo. index_repository falls
|
|
13
|
+
// back to cwd when nothing is indexed yet (it creates the index); query commands
|
|
14
|
+
// error cleanly instead. An explicit repo_path always wins, unchanged.
|
|
9
15
|
// seonix cli index_repository '{"repo_paths":["<abs1>","<abs2>",…],"out_root":"<abs-dir>"}' → index
|
|
10
16
|
// n repos into ONE merged graph at <out_root>/.seonix/ (module ids prefixed with each repo's
|
|
11
17
|
// directory basename; out_root defaults to the paths' deepest common ancestor directory).
|
|
@@ -39,7 +45,7 @@
|
|
|
39
45
|
// graph artifact to <repo_path>/.seonix/graph.json; the server (started with
|
|
40
46
|
// cwd = that repo) loads it by default. No flags, no config files.
|
|
41
47
|
|
|
42
|
-
import { join } from "node:path";
|
|
48
|
+
import { dirname, join } from "node:path";
|
|
43
49
|
import { existsSync } from "node:fs";
|
|
44
50
|
import { readFile, writeFile } from "node:fs/promises";
|
|
45
51
|
import { fileURLToPath } from "node:url";
|
|
@@ -52,7 +58,7 @@ import * as source from "../src/source.mjs";
|
|
|
52
58
|
import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
|
|
53
59
|
import { loadTomlConfig, normalizeConfig, mergeEffective, CONFIG_FILE } from "../src/toml-config.mjs";
|
|
54
60
|
import { compileGlobs } from "../src/walk.mjs";
|
|
55
|
-
import { createTelemetry } from "../src/telemetry.mjs";
|
|
61
|
+
import { createTelemetry, truncateStr, looksLikeMiss } from "../src/telemetry.mjs";
|
|
56
62
|
|
|
57
63
|
/** Build a config pointed at a specific repo's artifact (for `cli` sub-commands that
|
|
58
64
|
* take a repo_path), or fall back to the cwd-derived default. */
|
|
@@ -67,6 +73,39 @@ function parsePayload(payload) {
|
|
|
67
73
|
catch { return null; }
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/** Nearest ancestor of `startDir` (itself first) containing a .seonix/ directory, or null. */
|
|
77
|
+
function findIndexRoot(startDir = process.cwd()) {
|
|
78
|
+
let dir = startDir;
|
|
79
|
+
for (;;) {
|
|
80
|
+
if (existsSync(join(dir, ".seonix"))) return dir;
|
|
81
|
+
const parent = dirname(dir);
|
|
82
|
+
if (parent === dir) return null;
|
|
83
|
+
dir = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Default an omitted repo_path for a `cli` command. Precedence:
|
|
88
|
+
* 1. explicit repo_path — always wins, args returned untouched (byte-identical to today);
|
|
89
|
+
* 2. SEONIX_GRAPH_FILE — also untouched (configFor's loadConfig fallback consumes it);
|
|
90
|
+
* 3. the nearest ancestor of cwd (cwd first) carrying a .seonix/ → written into args.repo_path;
|
|
91
|
+
* 4. nothing found: commands that READ an index (`mustExist`) get a one-line error + exit 2;
|
|
92
|
+
* index_repository falls back to cwd (it CREATES the index there).
|
|
93
|
+
* Mutating args means downstream configFor/tomlFor/telemetry all see the resolved repo. */
|
|
94
|
+
function defaultRepoPath(args, { mustExist = true } = {}) {
|
|
95
|
+
if (args.repo_path !== undefined) return args;
|
|
96
|
+
// The env override only steers commands that READ a graph (query commands); index_repository
|
|
97
|
+
// writes to <repo>/.seonix and never consumes SEONIX_GRAPH_FILE, so it still defaults.
|
|
98
|
+
if (mustExist && process.env.SEONIX_GRAPH_FILE && process.env.SEONIX_GRAPH_FILE.trim()) return args;
|
|
99
|
+
const root = findIndexRoot();
|
|
100
|
+
if (root) { args.repo_path = root; return args; }
|
|
101
|
+
if (mustExist) {
|
|
102
|
+
process.stderr.write("seonix: no .seonix index found here or above — run `seonix cli index_repository` first or pass repo_path\n");
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
args.repo_path = process.cwd();
|
|
106
|
+
return args;
|
|
107
|
+
}
|
|
108
|
+
|
|
70
109
|
/** Load a repo's seonix.toml as a normalized config, or null. Returns null IMMEDIATELY when
|
|
71
110
|
* `args.config === false` (the BENCH-IMMUNITY flag — an arm's index/locate/digest must NEVER read a
|
|
72
111
|
* subject repo's seonix.toml) OR when no seonix.toml exists. So on the bench path, and with no
|
|
@@ -163,6 +202,7 @@ async function runDigest(args) {
|
|
|
163
202
|
// stdout is the digest injected into the no-MCP arm, so telemetry must never touch it. The
|
|
164
203
|
// seonix.toml [telemetry] toggle rides in via `toml`; SEONIX_TELEMETRY still wins both ways.
|
|
165
204
|
const tel = createTelemetry({ env: process.env, config: configFor(repoPath), toml, surface: "cli" });
|
|
205
|
+
const t0 = Date.now();
|
|
166
206
|
const tune = resolveDigestTune(args, toml);
|
|
167
207
|
let modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
|
|
168
208
|
let autoSelected = null; // for the header, when `query` drove selection
|
|
@@ -236,10 +276,14 @@ async function runDigest(args) {
|
|
|
236
276
|
process.stdout.write(out);
|
|
237
277
|
// One telemetry line for the digest surface: the selected modules + the effective tier/topup the
|
|
238
278
|
// header already carries, plus the injected body size. `tier` here MUST equal the header's tier=.
|
|
279
|
+
// (wh dogfood backlog #6): duration + a miss flag — a query-mode digest that selected zero
|
|
280
|
+
// modules is an unambiguous miss (autoSelected is only set in query mode).
|
|
239
281
|
tel?.record({
|
|
240
282
|
surface: "digest",
|
|
241
|
-
query: args.query ? { raw: args.query } : undefined,
|
|
283
|
+
query: args.query ? { raw: truncateStr(args.query) } : undefined,
|
|
242
284
|
response: { count: emitted, node_ids: modules, tier: effTier, topup },
|
|
285
|
+
perf: { ms_total: Date.now() - t0 },
|
|
286
|
+
quality: { miss: autoSelected ? autoSelected.length === 0 : emitted === 0 },
|
|
243
287
|
cost: { returned_chars: out.length, returned_tokens_est: Math.ceil(out.length / 4) },
|
|
244
288
|
});
|
|
245
289
|
}
|
|
@@ -320,15 +364,21 @@ async function main() {
|
|
|
320
364
|
}
|
|
321
365
|
// seonix.toml: config dir = the explicit repo/estate path, else out_root, else cwd (where the
|
|
322
366
|
// file sits). Skipped entirely on the bench path (config:false → tomlFor null → byte-identical).
|
|
323
|
-
|
|
367
|
+
let toml = await tomlFor(args.repo_path || args.multi_root || args.out_root || process.cwd(), args);
|
|
324
368
|
warnUnwired(toml);
|
|
325
369
|
// repositories: seonix.toml MAY supply the repo set when NO explicit path arg is given
|
|
326
370
|
// (explicit repo_path/repo_paths/multi_root always win).
|
|
327
371
|
const tomlRepos = (given.length === 0 && toml && Array.isArray(toml.repositories) && toml.repositories.length)
|
|
328
372
|
? toml.repositories : null;
|
|
329
373
|
if (given.length === 0 && !tomlRepos) {
|
|
330
|
-
|
|
331
|
-
|
|
374
|
+
// No path arg and no seonix.toml repo set: default to the nearest ancestor of cwd
|
|
375
|
+
// carrying a .seonix/ (re-index it), else cwd itself (first index — it creates .seonix/).
|
|
376
|
+
defaultRepoPath(args, { mustExist: false });
|
|
377
|
+
// The resolved root may carry its own seonix.toml (cwd can be a subdirectory) — re-read there.
|
|
378
|
+
if (args.repo_path && args.repo_path !== process.cwd()) {
|
|
379
|
+
toml = await tomlFor(args.repo_path, args);
|
|
380
|
+
warnUnwired(toml);
|
|
381
|
+
}
|
|
332
382
|
}
|
|
333
383
|
// A5 `history_depth`: unified cap over BOTH git-history passes. 0 = skip history entirely;
|
|
334
384
|
// N>0 = `git log -n N`; absent = today's defaults. Precedence: explicit arg > seonix.toml
|
|
@@ -449,7 +499,7 @@ async function main() {
|
|
|
449
499
|
process.stderr.write("seonix: digest expects a JSON arg, e.g. '{\"repo_path\":\"/abs\",\"modules\":[…]}'\n");
|
|
450
500
|
process.exit(2);
|
|
451
501
|
}
|
|
452
|
-
await runDigest(args);
|
|
502
|
+
await runDigest(defaultRepoPath(args));
|
|
453
503
|
return;
|
|
454
504
|
}
|
|
455
505
|
|
|
@@ -463,6 +513,24 @@ async function main() {
|
|
|
463
513
|
process.stderr.write("seonix: seonix_locate expects a JSON arg, e.g. '{\"query\":\"…\",\"repo_path\":\"/abs\"}'\n");
|
|
464
514
|
process.exit(2);
|
|
465
515
|
}
|
|
516
|
+
// Tool honesty (wh dogfood T7): seonix_locate used to print NOTHING and exit 0 for
|
|
517
|
+
// any input without a query ({}, {"symbol":…}, {"bogus":1}) — an empty ranking is
|
|
518
|
+
// indistinguishable from success in a tool loop. Bad/missing args are now a one-line
|
|
519
|
+
// error naming the valid keys + a nonzero exit, like the generic dispatchTool route.
|
|
520
|
+
const LOCATE_KEYS = [
|
|
521
|
+
"query", "raw_query", "repo_path", "config", "limit", "spiral", "spiral_depth",
|
|
522
|
+
"demote_nonprod", "call_adjacency", "impl_of_interface", "beam_search", "beam_width", "literal_mention",
|
|
523
|
+
];
|
|
524
|
+
const unknownKeys = Object.keys(args).filter((k) => !LOCATE_KEYS.includes(k));
|
|
525
|
+
if (unknownKeys.length) {
|
|
526
|
+
process.stderr.write(`seonix: unknown argument(s) for seonix_locate: ${unknownKeys.join(", ")}. Valid: ${LOCATE_KEYS.join(", ")}.\n`);
|
|
527
|
+
process.exit(2);
|
|
528
|
+
}
|
|
529
|
+
if (!String(args.query || "").trim()) {
|
|
530
|
+
process.stderr.write("seonix: seonix_locate requires a non-empty \"query\" (free text to rank modules against). For a single symbol use seonix_search or seonix_describe.\n");
|
|
531
|
+
process.exit(2);
|
|
532
|
+
}
|
|
533
|
+
defaultRepoPath(args);
|
|
466
534
|
const config = configFor(args.repo_path);
|
|
467
535
|
// seonix.toml [tune] defaults the locate levers (arg > seonix.toml > default). Skipped on the
|
|
468
536
|
// bench path (config:false → tomlFor null) and when no seonix.toml exists → byte-identical.
|
|
@@ -471,6 +539,7 @@ async function main() {
|
|
|
471
539
|
// Opt-in telemetry (default OFF → null → no-op, no file). stdout is the ranked list the rig
|
|
472
540
|
// parses, so telemetry is LOG FILE ONLY. seonix.toml [telemetry] rides in via `toml`.
|
|
473
541
|
const tel = createTelemetry({ env: process.env, config, toml, surface: "cli" });
|
|
542
|
+
const t0 = Date.now();
|
|
474
543
|
const tune = resolveLocateTune(args, toml);
|
|
475
544
|
try {
|
|
476
545
|
const graph = parseEntities(await source.fetchEntities(config));
|
|
@@ -510,15 +579,18 @@ async function main() {
|
|
|
510
579
|
: {}),
|
|
511
580
|
});
|
|
512
581
|
process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
|
|
513
|
-
// Telemetry for the locate surface: the ranked count + the top few paths/scores
|
|
582
|
+
// Telemetry for the locate surface: the ranked count + the top few paths/scores, plus
|
|
583
|
+
// (wh dogfood backlog #6) duration + a miss flag (an empty ranking is an unambiguous miss).
|
|
514
584
|
tel?.record({
|
|
515
585
|
surface: "locate",
|
|
516
|
-
query: { raw: String(args.query || "") },
|
|
586
|
+
query: { raw: truncateStr(String(args.query || "")) },
|
|
517
587
|
response: {
|
|
518
588
|
count: ranked.length,
|
|
519
589
|
node_ids: ranked.slice(0, 3).map((r) => r.path),
|
|
520
590
|
scores: ranked.slice(0, 3).map((r) => r.score),
|
|
521
591
|
},
|
|
592
|
+
perf: { ms_total: Date.now() - t0 },
|
|
593
|
+
quality: { miss: ranked.length === 0 },
|
|
522
594
|
});
|
|
523
595
|
} catch (e) {
|
|
524
596
|
process.stderr.write(`seonix: ${e?.message || e}\n`);
|
|
@@ -539,6 +611,7 @@ async function main() {
|
|
|
539
611
|
process.stderr.write("seonix: browser_link expects a JSON arg, e.g. '{\"query\":\"classes that change with render\"}'\n");
|
|
540
612
|
process.exit(2);
|
|
541
613
|
}
|
|
614
|
+
defaultRepoPath(args);
|
|
542
615
|
const config = configFor(args.repo_path);
|
|
543
616
|
const { buildTemporalGraph, gitCommitOrder } = await import("../src/browser.mjs");
|
|
544
617
|
const { nlToQuery, validateLink, encodeViewState, VIEW_DEFAULTS } = await import("../src/temporal.mjs");
|
|
@@ -578,6 +651,7 @@ async function main() {
|
|
|
578
651
|
process.stderr.write("seonix: store_rebuild expects a JSON arg, e.g. '{\"repo_path\":\"/abs\"}'\n");
|
|
579
652
|
process.exit(2);
|
|
580
653
|
}
|
|
654
|
+
defaultRepoPath(args);
|
|
581
655
|
const { graphFile } = configFor(args.repo_path);
|
|
582
656
|
const { readFile, stat } = await import("node:fs/promises");
|
|
583
657
|
const { writeStore, storeFileFor, verifyStore } = await import("../src/store.mjs");
|
|
@@ -612,17 +686,23 @@ async function main() {
|
|
|
612
686
|
process.stderr.write(`seonix: ${sub} expects a JSON arg, e.g. '{"symbol":"<name>"}'\n`);
|
|
613
687
|
process.exit(2);
|
|
614
688
|
}
|
|
689
|
+
defaultRepoPath(args);
|
|
615
690
|
const config = configFor(args.repo_path);
|
|
616
691
|
// seonix.toml [telemetry] toggle for the generic tool surface (byte-identical with no toml:
|
|
617
692
|
// tomlFor short-circuits to null on the bench path and when no seonix.toml exists).
|
|
618
693
|
const toml = await tomlFor(args.repo_path, args);
|
|
619
694
|
const tel = createTelemetry({ env: process.env, config, toml, surface: "cli" });
|
|
695
|
+
const t0 = Date.now();
|
|
620
696
|
try {
|
|
621
697
|
const text = await dispatchTool(sub, args, { config });
|
|
622
698
|
process.stdout.write(text + "\n");
|
|
623
|
-
// Telemetry for the generic tool surface: which tool + how many chars it returned
|
|
699
|
+
// Telemetry for the generic tool surface: which tool + how many chars it returned, plus
|
|
700
|
+
// (wh dogfood backlog #6) the args text/duration/hit-miss the log previously omitted.
|
|
624
701
|
tel?.record({
|
|
625
702
|
tool: sub,
|
|
703
|
+
query: { raw: truncateStr(JSON.stringify(args)) },
|
|
704
|
+
perf: { ms_total: Date.now() - t0 },
|
|
705
|
+
quality: { miss: looksLikeMiss(text) },
|
|
626
706
|
cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
|
|
627
707
|
});
|
|
628
708
|
} catch (e) {
|
|
@@ -632,7 +712,8 @@ async function main() {
|
|
|
632
712
|
return;
|
|
633
713
|
}
|
|
634
714
|
|
|
635
|
-
process.stderr.write("seonix: `cli` needs a sub-command (index_repository | digest | <toolName>)
|
|
715
|
+
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");
|
|
636
717
|
process.exit(2);
|
|
637
718
|
}
|
|
638
719
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/seonix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.",
|
|
@@ -51,13 +51,6 @@
|
|
|
51
51
|
"join:telemetry": "node scripts/join-telemetry.mjs",
|
|
52
52
|
"bench:lite": "node bench/run.mjs --suite lite",
|
|
53
53
|
"bench:seonix": "node bench/run.mjs --suite django-lh --arms seonix,seonix-min --runs 5",
|
|
54
|
-
"bench:b010": "node bench/run.mjs --suite django-lh --arms otb,seonix,seonix-min,seonix-b010 --models claude-opus-4-8,claude-sonnet-4-6 --runs 5 --concurrency 10",
|
|
55
|
-
"bench:b011": "node bench/run.mjs --suite django-lh --arms seonix,seonix-min --models claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5 --runs 5 --concurrency 15",
|
|
56
|
-
"bench:b011-haiku-refs": "node bench/run.mjs --suite django-lh --arms otb,seonix-b010 --models claude-haiku-4-5 --runs 5 --concurrency 10",
|
|
57
|
-
"bench:b015": "node scripts/bench-b015.mjs",
|
|
58
|
-
"bench:b016p1": "node scripts/bench-b016-p1.mjs",
|
|
59
|
-
"bench:b016h": "node scripts/bench-b016-h.mjs",
|
|
60
|
-
"bench:b016p2": "node scripts/bench-b016-p2.mjs",
|
|
61
54
|
"bench:baseline": "node scripts/use-baseline.mjs",
|
|
62
55
|
"proof": "node scripts/proof-demo.mjs",
|
|
63
56
|
"smoke": "node bench/smoke.mjs --suite django-lh",
|
|
@@ -92,7 +85,7 @@
|
|
|
92
85
|
},
|
|
93
86
|
"dependencies": {
|
|
94
87
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
95
|
-
"@polycode-projects/the-mechanical-code-talker": "^0.8.
|
|
88
|
+
"@polycode-projects/the-mechanical-code-talker": "^0.8.2",
|
|
96
89
|
"cytoscape": "^3.30.0",
|
|
97
90
|
"smol-toml": "^1.7.0",
|
|
98
91
|
"tree-sitter": "^0.21.1",
|
|
@@ -952,12 +952,62 @@
|
|
|
952
952
|
);
|
|
953
953
|
var MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
954
954
|
var WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
955
|
+
var RELATION_VERB_RE = new RegExp(
|
|
956
|
+
"\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
957
|
+
"i"
|
|
958
|
+
);
|
|
959
|
+
var INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
|
|
960
|
+
var LISTING_TAIL_KINDS = /* @__PURE__ */ new Set([
|
|
961
|
+
"modules",
|
|
962
|
+
"files",
|
|
963
|
+
"functions",
|
|
964
|
+
"methods",
|
|
965
|
+
"classes",
|
|
966
|
+
"attributes",
|
|
967
|
+
"fields",
|
|
968
|
+
"properties",
|
|
969
|
+
"variables",
|
|
970
|
+
"globals",
|
|
971
|
+
"commits",
|
|
972
|
+
"changes",
|
|
973
|
+
"tests",
|
|
974
|
+
"members"
|
|
975
|
+
]);
|
|
976
|
+
var BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
|
|
977
|
+
var isListingRemainder = (rest) => {
|
|
978
|
+
if (BARE_KIND_RE.test(rest)) return true;
|
|
979
|
+
const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
|
|
980
|
+
return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
|
|
981
|
+
};
|
|
982
|
+
var GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
|
|
983
|
+
var MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
|
|
984
|
+
var SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
|
|
985
|
+
function applyPreambleFrames(text) {
|
|
986
|
+
let q = String(text || "");
|
|
987
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
988
|
+
const before = q;
|
|
989
|
+
let m = q.match(GREETING_PREAMBLE_RE);
|
|
990
|
+
if (m) q = m[1].trim();
|
|
991
|
+
m = q.match(MODAL_WRAPPER_RE);
|
|
992
|
+
if (m) q = m[1].trim();
|
|
993
|
+
m = q.match(SHOW_GIVE_ME_RE);
|
|
994
|
+
if (m) {
|
|
995
|
+
const rest = m[1].trim();
|
|
996
|
+
if (!isListingRemainder(rest)) {
|
|
997
|
+
q = RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest) ? rest : `describe ${rest}`;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (q === before) break;
|
|
1001
|
+
}
|
|
1002
|
+
return q;
|
|
1003
|
+
}
|
|
955
1004
|
function normalizeQuery(text) {
|
|
956
1005
|
let q = String(text || "");
|
|
957
1006
|
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
958
1007
|
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
959
1008
|
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
960
1009
|
q = q.replace(G_DROP, "$1ing");
|
|
1010
|
+
q = applyPreambleFrames(q);
|
|
961
1011
|
if (FILLER_WORDS.length) {
|
|
962
1012
|
const fillerRe = new RegExp(
|
|
963
1013
|
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
@@ -1045,8 +1095,24 @@
|
|
|
1045
1095
|
// separate authorship edge; "touched" IS the authorship signal (the churn commits
|
|
1046
1096
|
// carry the author), so these are true synonyms of "who touched X", not a new
|
|
1047
1097
|
// capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
|
|
1048
|
-
|
|
1049
|
-
|
|
1098
|
+
// SHA GUARD (0.8.2 feel wave): a COMMIT object is NOT a synonym — "who is the
|
|
1099
|
+
// author of abc1234" rewritten to "who touched abc1234" dumps the commit's
|
|
1100
|
+
// touch-SET instead of naming its author. The negative lookahead refuses the
|
|
1101
|
+
// rewrite when the object is a bare (optionally "commit "-prefixed) 7-40 char
|
|
1102
|
+
// hex sha, leaving the un-rewritten form for the author lane to consume;
|
|
1103
|
+
// file/symbol objects (anything non-sha, e.g. "deadbeef.mjs") keep the rewrite.
|
|
1104
|
+
{ re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
1105
|
+
{ re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
1106
|
+
// HAS-TESTS → the coverage question the RELATIONS table answers. "does X have
|
|
1107
|
+
// tests" parses "have" as a defines-verb (VERB_TO_KIND), producing the garbled
|
|
1108
|
+
// "No — no defines edge found from X to <whatever resolves>" receipt; "is X
|
|
1109
|
+
// tested" traverses tests edges from the WRONG side (subject = X). Both mean
|
|
1110
|
+
// the coverage question "what tests X" — rewrite onto it. Closed to a
|
|
1111
|
+
// tests/coverage object ("does X have methods/members" stays the members
|
|
1112
|
+
// family) and refuses any "not" in the subject span, so the set-complement
|
|
1113
|
+
// negations ("is X not tested") keep their own handler downstream.
|
|
1114
|
+
{ re: /^(?:does|do)\s+(?!.*\bnot\b)(.+?)\s+have\s+(?:any\s+)?(?:tests?|test\s+coverage|coverage)\??$/i, to: (m) => `what tests ${m[1]}` },
|
|
1115
|
+
{ re: /^(?:is|are)\s+(?!.*\bnot\b)(.+?)\s+tested\??$/i, to: (m) => `what tests ${m[1]}` },
|
|
1050
1116
|
// NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
|
|
1051
1117
|
// testing" is the plainest way to ask which modules are uncovered, and it hit the
|
|
1052
1118
|
// grammar wall ("no module matching 'needs'…"). Route it onto the same attributive
|
|
@@ -1864,7 +1930,9 @@
|
|
|
1864
1930
|
|
|
1865
1931
|
// node_modules/@polycode-projects/the-mechanical-code-talker/src/interpret/merge.mjs
|
|
1866
1932
|
var DEFAULT_CONFIDENCE = 0.5;
|
|
1867
|
-
var cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
1933
|
+
var cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^(?:the|a|an)\s+/, "").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
1934
|
+
var LEADING_DET_RE = /^\s*(?:the|a|an)\s+/i;
|
|
1935
|
+
var detCount = (p) => [p?.subject, p?.object].filter((t) => LEADING_DET_RE.test(String(t || ""))).length;
|
|
1868
1936
|
function sameParse(p, q) {
|
|
1869
1937
|
if (p.shape !== q.shape || p.kind !== q.kind) return false;
|
|
1870
1938
|
if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
|
|
@@ -1904,6 +1972,7 @@
|
|
|
1904
1972
|
if (dup) {
|
|
1905
1973
|
dup.agreed += 1;
|
|
1906
1974
|
dup.confidence = Math.max(dup.confidence, c.confidence);
|
|
1975
|
+
if (detCount(c.parsed) < detCount(dup.parsed)) dup.parsed = c.parsed;
|
|
1907
1976
|
continue;
|
|
1908
1977
|
}
|
|
1909
1978
|
distinct.push({ ...c, agreed: 1 });
|
|
@@ -2049,11 +2118,15 @@
|
|
|
2049
2118
|
// node_modules/@polycode-projects/the-mechanical-code-talker/src/ask.mjs
|
|
2050
2119
|
function edgesOfKind(graph, kind) {
|
|
2051
2120
|
const out = [];
|
|
2052
|
-
for (const g of graph.relations)
|
|
2121
|
+
for (const g of graph.relations) {
|
|
2122
|
+
if (relationKind(g) !== kind) continue;
|
|
2123
|
+
for (const e of g.edges) out.push(e);
|
|
2124
|
+
}
|
|
2053
2125
|
return out;
|
|
2054
2126
|
}
|
|
2055
2127
|
var SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
|
|
2056
2128
|
var FINE_ENTITY_TYPES = /* @__PURE__ */ new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
|
|
2129
|
+
var FINE_CLASS_SIBLING = { Function: "Method", Method: "Function" };
|
|
2057
2130
|
var KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
|
|
2058
2131
|
var kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
|
|
2059
2132
|
var OVERFLOW_CAP = 12;
|
|
@@ -2077,6 +2150,10 @@
|
|
|
2077
2150
|
function verbFor(kind) {
|
|
2078
2151
|
return REVERSE_MISS_VERB[kind] || kind;
|
|
2079
2152
|
}
|
|
2153
|
+
var LEADING_RELATION_VERB_RE = new RegExp(
|
|
2154
|
+
`^(?:${Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?:s|ing|ed)?\\s+`,
|
|
2155
|
+
"i"
|
|
2156
|
+
);
|
|
2080
2157
|
function defaultNlp() {
|
|
2081
2158
|
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
2082
2159
|
}
|
|
@@ -2981,7 +3058,24 @@
|
|
|
2981
3058
|
const token = (i.attributes || []).find((a) => a.key === "token")?.value;
|
|
2982
3059
|
return token && String(token).toLowerCase() === termLc;
|
|
2983
3060
|
});
|
|
2984
|
-
if (!match)
|
|
3061
|
+
if (!match) {
|
|
3062
|
+
const classHits = (graph.individuals || []).filter((i) => i.class === "Class" && String(i.label).toLowerCase() === termLc);
|
|
3063
|
+
if (classHits.length === 1) {
|
|
3064
|
+
const hit2 = classHits[0];
|
|
3065
|
+
const mid = moduleIdOf2(graph, hit2);
|
|
3066
|
+
const modLabel = mid && graph.byId.get(mid)?.label || String((hit2.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0] || null;
|
|
3067
|
+
return {
|
|
3068
|
+
matches: [hit2],
|
|
3069
|
+
objMatch: hit2,
|
|
3070
|
+
candidates: [],
|
|
3071
|
+
ambiguous: false,
|
|
3072
|
+
metaCodeClass: true,
|
|
3073
|
+
metaModuleLabel: modLabel,
|
|
3074
|
+
traversal: `schema lookup for "${term}" (miss), then unique Class individual by label`
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
|
|
3078
|
+
}
|
|
2985
3079
|
return {
|
|
2986
3080
|
matches: [match],
|
|
2987
3081
|
objMatch: match,
|
|
@@ -3090,9 +3184,13 @@
|
|
|
3090
3184
|
return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
|
|
3091
3185
|
}
|
|
3092
3186
|
if (shape === "forward") {
|
|
3093
|
-
const
|
|
3094
|
-
const
|
|
3095
|
-
|
|
3187
|
+
const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
|
|
3188
|
+
const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
3189
|
+
const fwdKinds = subjIsFineSymbol ? [.../* @__PURE__ */ new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
|
|
3190
|
+
const edges2 = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
|
|
3191
|
+
const targets = edges2.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
3192
|
+
const matches2 = subjIsFineSymbol ? uniqueById(targets) : targets;
|
|
3193
|
+
return { matches: matches2, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
|
|
3096
3194
|
}
|
|
3097
3195
|
if (parsed.modifier === "transitive") {
|
|
3098
3196
|
const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
|
|
@@ -3111,8 +3209,17 @@
|
|
|
3111
3209
|
if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsFineSymbol)) {
|
|
3112
3210
|
const edges2 = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
|
|
3113
3211
|
const subjects2 = uniqueById(edges2.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
3114
|
-
|
|
3115
|
-
|
|
3212
|
+
let matches2 = !entityType || entityType === "Change" ? subjects2 : subjects2.filter((i) => i.class === entityType);
|
|
3213
|
+
let widenNote = "";
|
|
3214
|
+
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
3215
|
+
if (!matches2.length && siblingClass) {
|
|
3216
|
+
const widened = subjects2.filter((i) => i.class === siblingClass);
|
|
3217
|
+
if (widened.length) {
|
|
3218
|
+
matches2 = widened;
|
|
3219
|
+
widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
return { matches: matches2, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
|
|
3116
3223
|
}
|
|
3117
3224
|
let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
|
|
3118
3225
|
let extNote = "";
|
|
@@ -3214,6 +3321,16 @@
|
|
|
3214
3321
|
ambiguous: false
|
|
3215
3322
|
};
|
|
3216
3323
|
}
|
|
3324
|
+
if (result.metaCodeClass) {
|
|
3325
|
+
const label = result.objMatch.label;
|
|
3326
|
+
const definedIn = result.metaModuleLabel ? `, defined in ${result.metaModuleLabel}` : "";
|
|
3327
|
+
return {
|
|
3328
|
+
content: `${label} is a class in this codebase${definedIn} \u2014 try "describe ${label}" or "which classes inherit from ${label}".`,
|
|
3329
|
+
miss: false,
|
|
3330
|
+
ambiguous: false,
|
|
3331
|
+
matches: result.matches
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3217
3334
|
const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
|
|
3218
3335
|
const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
|
|
3219
3336
|
return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
|
|
@@ -3221,7 +3338,7 @@
|
|
|
3221
3338
|
if (result.mentionsShape) {
|
|
3222
3339
|
if (!result.matches.length) {
|
|
3223
3340
|
return {
|
|
3224
|
-
content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose
|
|
3341
|
+
content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose.`,
|
|
3225
3342
|
miss: true,
|
|
3226
3343
|
ambiguous: false
|
|
3227
3344
|
};
|
|
@@ -3278,7 +3395,7 @@
|
|
|
3278
3395
|
if (result.whenShape) {
|
|
3279
3396
|
const subject = result.objMatch.label;
|
|
3280
3397
|
if (!result.matches.length) {
|
|
3281
|
-
return { content: `no recorded commit touches ${subject} in this index
|
|
3398
|
+
return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
|
|
3282
3399
|
}
|
|
3283
3400
|
const newest = result.matches[0];
|
|
3284
3401
|
const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
|
|
@@ -3306,7 +3423,7 @@
|
|
|
3306
3423
|
const cite = `commit ${result.objMatch.label}`;
|
|
3307
3424
|
if (!result.matches.length) {
|
|
3308
3425
|
return {
|
|
3309
|
-
content: `${cite} touched nothing recorded in the index
|
|
3426
|
+
content: `${cite} touched nothing recorded in the index.`,
|
|
3310
3427
|
miss: true,
|
|
3311
3428
|
ambiguous: false
|
|
3312
3429
|
};
|
|
@@ -3326,7 +3443,7 @@
|
|
|
3326
3443
|
return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
|
|
3327
3444
|
}
|
|
3328
3445
|
return {
|
|
3329
|
-
content: result.answer ? `Yes
|
|
3446
|
+
content: result.answer ? `Yes \u2014 ${result.traversal}.` : `No \u2014 no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
|
|
3330
3447
|
miss: !result.answer,
|
|
3331
3448
|
ambiguous: false
|
|
3332
3449
|
};
|
|
@@ -3334,22 +3451,23 @@
|
|
|
3334
3451
|
if (!result.matches.length) {
|
|
3335
3452
|
if (parsed.shape === "forward") {
|
|
3336
3453
|
return {
|
|
3337
|
-
content: `${result.objMatch.label} has no ${parsed.kind} edges in the index
|
|
3454
|
+
content: `${result.objMatch.label} has no ${parsed.kind} edges in the index.`,
|
|
3338
3455
|
miss: true,
|
|
3339
3456
|
ambiguous: false
|
|
3340
3457
|
};
|
|
3341
3458
|
}
|
|
3342
3459
|
if (parsed.kind === "tests" && !parsed.entityType) {
|
|
3343
|
-
const
|
|
3460
|
+
const stripped = String(parsed.object || "").replace(LEADING_RELATION_VERB_RE, "").trim();
|
|
3461
|
+
const obj = stripped || String(parsed.object || "").trim();
|
|
3344
3462
|
return {
|
|
3345
|
-
content: `No tests cover ${obj}
|
|
3463
|
+
content: `No tests cover ${obj}.`,
|
|
3346
3464
|
miss: true,
|
|
3347
3465
|
ambiguous: false
|
|
3348
3466
|
};
|
|
3349
3467
|
}
|
|
3350
3468
|
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
3351
3469
|
return {
|
|
3352
|
-
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}
|
|
3470
|
+
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}.`,
|
|
3353
3471
|
miss: true,
|
|
3354
3472
|
ambiguous: false
|
|
3355
3473
|
};
|