@polycode-projects/seonix 0.3.0 → 0.5.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
@@ -7,13 +7,16 @@ bounded **edit digest** you inject once at session start. Your agent edits from
7
7
  turn 1 without fishing through the repo.
8
8
 
9
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.
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`.
17
20
 
18
21
  - **Website / live demo:** https://seonix.polycode.co.uk
19
22
  - **Source + benchmarks:** https://gitlab.com/polycode-projects/seonix
@@ -49,7 +52,7 @@ removes no turns there is little to save.
49
52
  ```bash
50
53
  npm i -g @polycode-projects/seonix # global — gives you the `seonix` bin on PATH
51
54
  # or one-shot, no install:
52
- npx @polycode-projects/seonix index /abs/path/to/repo
55
+ npx @polycode-projects/seonix cli index_repository '{"repo_path":"/abs/path/to/repo"}'
53
56
  ```
54
57
 
55
58
  **Runtime prerequisites:** Node ≥ 24, **Python 3**, and **git** on `PATH` (the
@@ -63,7 +66,7 @@ network calls, **$0**.
63
66
 
64
67
  ```bash
65
68
  # 1. index your repo (deterministic, offline) — writes a machine-local .seonix/
66
- seonix index /abs/path/to/repo
69
+ seonix cli index_repository '{"repo_path":"/abs/path/to/repo"}'
67
70
 
68
71
  # 2. self-locate the touched modules from the task text (no human-chosen target)
69
72
  seonix cli seonix_search '{"repo_path":"/abs/path/to/repo","query":"<your task>"}'
@@ -118,23 +121,90 @@ in production; pass a known target (`oracle`) only to measure the headroom.
118
121
  seonix viz --focus <Symbol> --out graph.html
119
122
  ```
120
123
 
121
- Renders a focused, portable Cytoscape sub-graph of the typed code-map as one
122
- 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.
123
128
 
124
129
  ## Chat
125
130
 
126
131
  ```bash
127
- seonix chat [--repo /abs/path]
132
+ seonix chat [--repo /abs/path] [--with-claude | --with-copilot]
128
133
  ```
129
134
 
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.
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.
138
208
 
139
209
  A real transcript against this repository's own index (verified answers):
140
210
 
@@ -170,9 +240,9 @@ a rephrase hint, never a guess.
170
240
 
171
241
  ## Languages
172
242
 
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
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
176
246
  it on the consumer side (needs the .NET SDK) if you index C#. The Java JavaParser
177
247
  helper is a jar built from the repository's `java/` directory (`mvn package`) and
178
248
  is not included in the npm package, so a package install indexes Java with the
package/bin/cli.mjs CHANGED
@@ -29,7 +29,7 @@
29
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
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)
31
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
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)
33
33
  //
34
34
  // The index step is offline and model-free (Python ast + git). It writes the
35
35
  // graph artifact to <repo_path>/.seonix/graph.json; the server (started with
@@ -81,7 +81,12 @@ async function runDigest(args) {
81
81
  let autoSelected = null; // for the header, when `query` drove selection
82
82
  if (!modules.length && args.query) {
83
83
  const graph = parseEntities(await source.fetchEntities(configFor(repoPath)));
84
- const ranked = searchModulesRanked(graph, args.query);
84
+ // SHIPPED DEFAULT (0.5.0): the no-MCP digest's query-mode auto-locate resolves literal-mention ON
85
+ // (a fresh invocation with no seonix.toml), disable-able via `literal_mention:false`. Kept in
86
+ // lockstep with the `seonix_locate` handler so `cli digest '{query}'` ≡ `cli seonix_locate` for the
87
+ // same query. A strict no-op unless the query carries a ≥3-component dotted path / repo-relative
88
+ // path; searchModulesRanked derives rawQuery from the query when literalMention is on.
89
+ const ranked = searchModulesRanked(graph, args.query, { literalMention: args.literal_mention !== false });
85
90
  const scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
86
91
  modules = selectRankedModules(ranked, { top_k: Number.isFinite(args.top_k) ? args.top_k : 2, scoreGapK }).slice(0, DIGEST_MODULE_CAP);
87
92
  autoSelected = modules;
@@ -277,6 +282,18 @@ async function main() {
277
282
  implOfInterface: !!args.impl_of_interface, // E1b: C# impl-of-interface boost
278
283
  beamSearch: !!args.beam_search, // §5.15: multi-ply discriminative expansion
279
284
  ...(Number.isFinite(Number(args.beam_width)) ? { beamWidth: Number(args.beam_width) } : {}),
285
+ // B018 §8.1.3 literal-mention lever: match verbatim dotted-name/path mentions in the RAW
286
+ // query (which the locate tokenizer destroys). searchModulesRanked derives rawQuery from the
287
+ // query arg when literalMention is on; the rig passes the raw problem as the query, so literal
288
+ // matching keys off the untokenized text. raw_query is forwarded too for callers that normalize
289
+ // the query arg separately from the raw problem text.
290
+ // SHIPPED DEFAULT (0.5.0): literal-mention is ON for a fresh invocation (no arg, no
291
+ // seonix.toml) — pass `literal_mention:false` to disable. It is a strict no-op on queries
292
+ // with no ≥3-component dotted path / repo-relative path, so it never perturbs the cells the
293
+ // headline B018 numbers were measured on. The low-level scoreModules default (codegraph.mjs)
294
+ // stays literalMention=false; the product surface opts in explicitly, right here.
295
+ literalMention: args.literal_mention !== false,
296
+ ...(args.raw_query != null ? { rawQuery: String(args.raw_query) } : {}),
280
297
  });
281
298
  process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
282
299
  } catch (e) {
@@ -365,7 +382,13 @@ async function main() {
365
382
  if (mode === "chat") {
366
383
  const { runChat } = await import("../src/chat.mjs");
367
384
  const i = process.argv.indexOf("--repo");
368
- await runChat({ repoPath: i !== -1 ? process.argv[i + 1] : undefined });
385
+ // Opt-in LLM fallback (chat-only, default OFF, miss-gated never in the benchmark
386
+ // path): --with-claude shells `claude -p`, --with-copilot shells `gh copilot -- -p`.
387
+ await runChat({
388
+ repoPath: i !== -1 ? process.argv[i + 1] : undefined,
389
+ withClaude: process.argv.includes("--with-claude"),
390
+ withCopilot: process.argv.includes("--with-copilot"),
391
+ });
369
392
  return;
370
393
  }
371
394
 
@@ -375,7 +398,7 @@ async function main() {
375
398
  }
376
399
 
377
400
  process.stderr.write(`seonix: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
378
- "Use bare (MCP server), `cli index_repository …`, `hook-augment`, or `viz`.\n");
401
+ "Use bare (MCP server), `cli index_repository …`, `cli <tool> …`, `chat`, `hook-augment`, or `viz`.\n");
379
402
  process.exit(2);
380
403
  }
381
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.",
package/src/ask-vocab.mjs CHANGED
@@ -109,8 +109,13 @@ export const RELATIONS = {
109
109
  "derives from", "derive from", "is a subclass of", "are a subclass of",
110
110
  "is a kind of", "are a kind of", "is built off", "are built off",
111
111
  "is built on top of", "are built on top of",
112
- // gerund (g-drop normalization — §3.5)
113
- "extending",
112
+ // gerund (g-drop normalization — §3.5, and the compositional grammar's
113
+ // gerund-led boolean gate: "classes inheriting from Base but not tested").
114
+ // Both the two-word "inheriting from" (so "from" is consumed into the verb
115
+ // phrase, not the object term) and the bare "inheriting" (the single token
116
+ // the gerund-lead check reads) are listed; longest-match-first prefers the
117
+ // two-word form when "from" follows.
118
+ "extending", "inheriting from", "inheriting", "subclassing", "extends from",
114
119
  ],
115
120
  },
116
121
  touches: {
@@ -293,6 +298,18 @@ export const MISSPELLINGS = Object.freeze({
293
298
  "waht": "what",
294
299
  "dose": "does", "doess": "does",
295
300
  "teh": "the",
301
+ // aggregate/list TRIGGER words (2026-07-02, trigger-typo work) — a typo of a count
302
+ // or list trigger used to be DROPPED as unmatched by the relaxation cascade, losing
303
+ // the aggregate/list INTENT entirely ("how manyn classes" → the count was lost);
304
+ // curated here so the intended trigger is restored BEFORE parsing (the general
305
+ // bounded fuzzy path in ask.mjs's cascade is the backstop for uncurated typos).
306
+ "manyn": "many", "mnay": "many", "amny": "many", "mnany": "many",
307
+ "coutn": "count", "conut": "count", "cuont": "count", "ocunt": "count",
308
+ "numer": "number", "nubmer": "number", "numbr": "number", "nmuber": "number",
309
+ "lst": "list", "lsit": "list", "ilst": "list",
310
+ "shwo": "show", "hsow": "show",
311
+ "dispaly": "display", "dsiplay": "display",
312
+ "funtcions": "functions", "funciton": "function", "funcitons": "functions",
296
313
  });
297
314
 
298
315
  /** Words used INCORRECTLY but with clear intent, mapped to the canonical schema
@@ -401,6 +418,249 @@ export const META_MEANING_VERBS = Object.freeze([
401
418
  "represent", "represents", "refer to", "refers to",
402
419
  ]);
403
420
 
421
+ // ---- §compositional grammar vocabulary (PLAN §5.16 P3 — the ask engine's step up
422
+ // from ELIZA keyword-spotting to a real recursive-descent grammar over CLAUSES).
423
+ // ask.mjs's parseComposite reads these tables to recognize the compositional
424
+ // MARKERS — relative clauses, boolean connectives, subject qualifiers, aggregates,
425
+ // superlatives, and anaphora — that compose the SAME closed clause vocabulary
426
+ // (RELATIONS/ENTITY_TO_TYPE above) into multi-hop / set-algebra queries. The
427
+ // grammar COMPOSES the closed vocabulary; it never opens it. Every table here is
428
+ // curated + closed, same discipline as everything above: a marker the tables don't
429
+ // carry is an honest miss, never a guess. ----
430
+
431
+ /** Relative-clause introducers: "<noun> that/which/who <predicate>". Only ever
432
+ * read as a relative pronoun when it sits AFTER a noun and BEFORE a predicate
433
+ * (ask.mjs enforces both) — "that" is also a CONTEXT_PRONOUN ("what calls that"),
434
+ * so position, not mere presence, decides. This gates the nested/relative and
435
+ * same-subject boolean shapes: their absence is exactly what keeps the bare
436
+ * reverse-template query "which classes extends Base and couples to logging" out
437
+ * of the compositional path (it stays the existing two-strategy ambiguous parse). */
438
+ export const RELATIVE_PRONOUNS = Object.freeze(["that", "which", "who"]);
439
+
440
+ /** Placeholder object nouns — the indefinite "something" in "what calls something
441
+ * that imports X": they name NO entity, they stand in for the inner clause's
442
+ * result set. Mapped to entityType null (any grain); a real entity noun in the
443
+ * same slot ("what calls modules that import X") narrows the grain instead. */
444
+ export const PLACEHOLDER_NOUNS = Object.freeze([
445
+ "something", "anything", "everything", "somethings", "things", "thing",
446
+ "entities", "entity", "nodes", "node", "stuff", "code",
447
+ // "symbol(s)" is a grain-agnostic stand-in for any code entity — "exported
448
+ // symbols of X" means whatever X defines, at any grain, filtered by the qualifier.
449
+ "symbol", "symbols",
450
+ ]);
451
+
452
+ /** Boolean connectives over same-subject clauses -> a set operation on result ids.
453
+ * "and" = intersection, "or" = union, "but not"/"and not"/"without"/"except" =
454
+ * difference. Multi-word keys are matched longest-first by ask.mjs so "but not"
455
+ * wins over a bare "not". Left-associative in ask.mjs's fold. */
456
+ export const BOOLEAN_CONNECTIVES = Object.freeze({
457
+ "but not": "difference", "and not": "difference", "except": "difference",
458
+ "without": "difference",
459
+ "and": "intersection", "plus": "intersection",
460
+ "or": "union",
461
+ });
462
+
463
+ /** Subject QUALIFIERS (adjectives) -> a post-filter over the result set, read off
464
+ * attributes/edges the graph already carries (codegraph.mjs): `visibility`
465
+ * (private/protected present; public is the default/absent), `isStatic`/
466
+ * `isConstant`/`isAbstract` boolean attrs, the reexports edge set (exported), and
467
+ * the tests edge set (tested/untested). `isAbstract` is never populated by any
468
+ * extractor today (codegraph.mjs says so) — "abstract methods" therefore honestly
469
+ * returns an empty set, not an error, exactly like any other zero-hit answer. An
470
+ * UNKNOWN qualifier is an honest miss naming these supported ones (ask.mjs). */
471
+ export const QUALIFIERS = Object.freeze({
472
+ public: { via: "visibility", value: "public" },
473
+ private: { via: "visibility", value: "private" },
474
+ protected: { via: "visibility", value: "protected" },
475
+ static: { via: "attr", attr: "isStatic" },
476
+ abstract: { via: "attr", attr: "isAbstract" },
477
+ constant: { via: "attr", attr: "isConstant" },
478
+ exported: { via: "exported" },
479
+ "re-exported": { via: "exported" },
480
+ tested: { via: "tested", value: true },
481
+ covered: { via: "tested", value: true },
482
+ untested: { via: "tested", value: false },
483
+ uncovered: { via: "tested", value: false },
484
+ });
485
+
486
+ /** Aggregate/count triggers: "how many <kind> …", "count <kind>s", "number of
487
+ * <kind>". Answered by counting a class of individuals or a clause's result set —
488
+ * no header magic, a straight count over the graph (ask.mjs). Register-spread the
489
+ * same way RELATIONS' verbs are (2026-07-02): the count question has a formal
490
+ * ("how many"/"number of"), a neutral/imperative ("count"/"count up"), and a
491
+ * quantity ("quantity of"/"total number of") register a developer actually types.
492
+ * Judgement calls, kept OUT deliberately: bare "tally"/"sum"/"total" — those are
493
+ * already mapped to "count" by CASCADE_SYNONYMS (ask.mjs's relaxation layer), and a
494
+ * cascade test asserts "tally the classes" reaches the count via that synonym path,
495
+ * so promoting them to direct triggers here would both duplicate the mapping and
496
+ * break that test; and single-word "total"/"sum" would false-match identifier
497
+ * fragments ("total price", "sum of squares") the count intent never meant. */
498
+ export const AGGREGATE_TRIGGERS = Object.freeze([
499
+ // formal ("the number of classes" reaches "number of" once the cascade strips the
500
+ // leading article — keeping the trigger list clear of "the" so it never enters
501
+ // CONTENT_VOCAB and blocks the article's own noise-strip)
502
+ "how many", "how much", "how many of", "number of",
503
+ "total number of", "quantity of",
504
+ // neutral / imperative
505
+ "count", "count up", "count of", "tot up",
506
+ ]);
507
+
508
+ /** LIST triggers (2026-07-02, list shape) — the many ways a developer asks to SEE the
509
+ * individuals of a kind ("list functions", "show me the classes", "what are the
510
+ * modules"). Read by ask.mjs's parseList (a sibling of the count node): a trigger
511
+ * followed by an entity kind noun lists that class (capped at OVERFLOW_CAP), a
512
+ * trailing scope/predicate narrows it, and an unknown kind after a clear list trigger
513
+ * is an honest miss naming the kinds. Two registers, wide-but-deliberate (the operator's
514
+ * "err toward inclusion", bounded by the file-header discipline: a phrase earns its
515
+ * place only if it genuinely means "enumerate these" and won't false-match an unrelated
516
+ * identifier — parseList further requires a real entity kind to follow, so a stray
517
+ * "show"/"name" in another question is never seized):
518
+ * · IMPERATIVE — "<verb> [me/us] [the] <kind>". Bare determiners/objects (the/a/all/
519
+ * me/us) after the verb are skipped by parseList's LIST_SKIP, so only the verb stem
520
+ * is listed here (not every "... the"/"... all" inflection).
521
+ * · INTERROGATIVE — "what/which are [the] <kind>"; the bare "what <kind>"/"which
522
+ * <kind>" (+ optional "are there") form is handled directly in parseList, not here.
523
+ * Kept OUT on purpose: "tell me" / "give me" collide only where normalizeQuery already
524
+ * strips them as FILLER_WORDS ("tell me the classes" → "the classes"), so "tell me" is
525
+ * omitted (it never survives to parseList); "gimme" is omitted because CONTRACTIONS
526
+ * rewrites it to "give me" before parseList runs. */
527
+ export const LIST_TRIGGERS = Object.freeze([
528
+ // imperative — "<verb> [me/us] [the] <kind>"
529
+ "list", "show", "show me", "show us", "display", "print", "print out",
530
+ "dump", "enumerate", "name", "give me", "get me", "spit out", "rattle off",
531
+ "run down", "run through", "ls",
532
+ // interrogative — "what/which are [the] <kind>"
533
+ "what are", "which are",
534
+ ]);
535
+
536
+ /** Superlative extremes -> ranking direction. "most/greatest/highest/biggest/
537
+ * largest/most-connected" rank descending; "fewest/least/smallest/lowest" rank
538
+ * ascending. Read by ask.mjs's parseSuperlative alongside EDGE_NOUN_TO_METRIC. */
539
+ export const SUPERLATIVE_EXTREMES = Object.freeze({
540
+ most: "most", greatest: "most", highest: "most", biggest: "most",
541
+ largest: "most", "most-connected": "most", "most connected": "most",
542
+ fewest: "fewest", least: "fewest", smallest: "fewest", lowest: "fewest",
543
+ });
544
+
545
+ /** Degree-metric nouns for superlatives ("which module has the most <noun>") ->
546
+ * {kind, dir} over the SAME classified edge groups. dir "out" counts edges where
547
+ * the ranked entity is the subject (its own imports/calls); "in" counts edges
548
+ * where it is the object (its importers/callers/tests). "connections"/"edges"/
549
+ * "connected" is the total (both directions, all structural kinds). A `sibling`
550
+ * fine-grained kind is added to the tally when present (callers include the
551
+ * symbol-grain callsSymbol edges, not just module-coarse calls); a `filter`
552
+ * restricts the counted objects to one class ("methods"). */
553
+ export const EDGE_NOUN_TO_METRIC = Object.freeze({
554
+ imports: { kind: "imports", dir: "out" },
555
+ dependencies: { kind: "imports", dir: "out" },
556
+ importers: { kind: "imports", dir: "in" },
557
+ dependents: { kind: "imports", dir: "in" },
558
+ callers: { kind: "calls", dir: "in", sibling: "callsSymbol" },
559
+ callees: { kind: "calls", dir: "out", sibling: "callsSymbol" },
560
+ calls: { kind: "calls", dir: "out", sibling: "callsSymbol" },
561
+ methods: { kind: "contains", dir: "out", filter: "Method" },
562
+ members: { kind: "contains", dir: "out" },
563
+ tests: { kind: "tests", dir: "in" },
564
+ subclasses: { kind: "inherits", dir: "in" },
565
+ connections: { kind: "*", dir: "both" },
566
+ edges: { kind: "*", dir: "both" },
567
+ connected: { kind: "*", dir: "both" },
568
+ });
569
+
570
+ /** Anaphora triggers over the PREVIOUS result set (ask()'s `prev` id array):
571
+ * "which of those/them/these …", "how many of those …". The pronoun refers to the
572
+ * last answer's ids, not a graph term — with no prev supplied it is an honest miss
573
+ * (ask.mjs), never a guess, exactly like an unresolved context pronoun. */
574
+ export const ANAPHORA_TRIGGERS = Object.freeze(["those", "them", "these"]);
575
+
576
+ /** Membership relations for "<entity> of/in <term>" (qualifier/relative inner
577
+ * clauses like "public methods of Widget", "untested functions in walk.mjs") ->
578
+ * the forward edge kinds whose subject is <term> and whose objects are the
579
+ * members. contains (Class->member) and defines (Module->symbol) are both tried;
580
+ * the asked entity type narrows the result. */
581
+ export const MEMBERSHIP_KINDS = Object.freeze(["contains", "defines"]);
582
+
583
+ // ---- §progressive-relaxation cascade vocabulary (SHRDLU-in-a-code-graph, with a
584
+ // Zork parser's forgiveness) — the three closed, curated tables ask.mjs's relaxParse
585
+ // reads when the DIRECT parse of a query would MISS. The cascade only ever DROPS
586
+ // noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary;
587
+ // it never invents a term or guesses an entity, and it bottoms out in the same honest
588
+ // rephrase hint. Every entry is hand-curated with inline provenance, same "closed is
589
+ // deliberate" ethos as everything above: a word these tables don't carry is left for
590
+ // the honest miss, never a general-English stoplist that would silently eat a real
591
+ // code term. ----
592
+
593
+ /** Politeness / filler / vocative / presentation-frame tokens the cascade may strip
594
+ * ONE AT A TIME (leftmost first) when a query misses — but only ever a token that is
595
+ * NOT itself a content vocabulary word (a relation verb, entity noun, modifier,
596
+ * qualifier, aggregate/superlative trigger, …) and does NOT resolve to a graph entity
597
+ * (ask.mjs guards both), so a module literally named "show" or "the" is never eaten.
598
+ * This is a SUPERSET of the multi-word politeness FILLER_WORDS strips up-front during
599
+ * normalization: those handle "could you"/"tell me"/"please" before either parse runs;
600
+ * these single tokens catch what a spoken-style question keeps AFTER that pass — the
601
+ * bare vocative ("matey"), the article ("the"/"a"), and the presentation frame words
602
+ * ("show"/"me"/"list") that the compositional grammar already skips as FRAME_WORDS but
603
+ * the keyword-spotting strategy does not, so an un-stripped "show me" otherwise
604
+ * decomposes to a bogus ask{subject:"show me"}. Curated, not a general stoplist:
605
+ * question words (what/which/…), connectives (and/or), and pronouns (this/it/that)
606
+ * are deliberately ABSENT — they carry grammatical weight and must survive. */
607
+ export const CASCADE_NOISE = Object.freeze([
608
+ // articles / vague determiners (kept OUT of content vocab so they're strippable;
609
+ // the aggregate/where parsers already tolerate a stray "the"/"a", so stripping is
610
+ // belt-and-braces, not load-bearing)
611
+ "the", "a", "an", "some",
612
+ // topic lead-in filler — "what about the modules", "how about classes": "about"
613
+ // carries no graph meaning here, so stripping it lets the bare kind noun surface for
614
+ // the cascade's bare-kind-noun terminal rule (ask.mjs). ("what"/"how" are structural
615
+ // question words the drop-pass keeps; only the "about" between them and the kind is
616
+ // noise.) A module literally named "about" is safe-listed by relaxParse's resolvesExact
617
+ // guard, same as every other noise token.
618
+ "about",
619
+ // politeness / hedges (single-token; multi-word "could you"/"please" etc. are
620
+ // FILLER_WORDS, stripped earlier during normalization)
621
+ "please", "pls", "plz", "kindly", "just", "simply", "maybe", "perhaps",
622
+ "thanks", "thank", "ta", "cheers",
623
+ // greetings a question sometimes opens with (chat.mjs owns standalone greetings;
624
+ // here they're only stripped when embedded in an otherwise-real question)
625
+ "hi", "hello", "hey", "yo", "hiya", "howdy", "ok", "okay",
626
+ // vocatives / terms of address (the "matey" of the worked example, and its kin)
627
+ "matey", "mate", "buddy", "pal", "dude", "man", "bro", "bru", "fam",
628
+ "friend", "sir", "maam", "folks", "guys", "everyone", "dear",
629
+ // presentation frames — the keyword-spotting strategy's blind spot: the
630
+ // compositional grammar skips these as FRAME_WORDS, but "show me what imports X"
631
+ // otherwise decomposes (via keyword-spot) to ask{subject:"show me"}. Stripping
632
+ // them on a miss recovers the underlying reverse/forward question. ("count" is NOT
633
+ // here — it is an aggregate trigger; "find"/"search" are here as presentation
634
+ // verbs, not the seonix_search tool, which ask.mjs never dispatches.)
635
+ "show", "tell", "give", "list", "find", "me", "us", "lemme",
636
+ ]);
637
+
638
+ /** Near-canonical words the cascade REWRITES to the closed vocabulary once noise and
639
+ * unmatched tokens are gone (SYNONYM-NORMALISE, the cascade's third layer). Kept
640
+ * deliberately TINY and aggregate-flavoured: the relation/entity synonyms a developer
641
+ * actually types already live in RELATIONS/ENTITY_TO_TYPE (and "uses"/"depends on" are
642
+ * mapped there); this table only closes the gap for the count family, whose triggers
643
+ * (AGGREGATE_TRIGGERS) don't include the "tally the classes"/"total number of classes"
644
+ * register. Each key is also kept OUT of the drop pass (ask.mjs treats a synonym key as
645
+ * meaningful) so it survives to be normalised rather than dropped as unmatched. The
646
+ * rewrite is guarded by ask.mjs (never applied to a token that resolves to a graph
647
+ * entity), so a symbol named "total" is never bent into "count". */
648
+ export const CASCADE_SYNONYMS = Object.freeze({
649
+ tally: "count", tallies: "count", sum: "count", total: "count", totals: "count",
650
+ });
651
+
652
+ /** Explicit help / orientation requests — when the WHOLE query is one of these, ask.mjs
653
+ * shows the rephrase hint DIRECTLY (the honest bottom of the cascade, reached on
654
+ * demand) rather than pretending to answer or running the relaxation loop. A closed
655
+ * set matched against the whole normalized query only, so "which functions call help"
656
+ * (a real question about a symbol named "help") is untouched. Standalone greetings and
657
+ * the chat "/help" command are chat.mjs's own surface; this is the bare CLI/MCP ask()
658
+ * entry point's equivalent. */
659
+ export const HELP_TRIGGERS = Object.freeze([
660
+ "help", "help me", "how do i ask", "how do i use this", "what can i ask",
661
+ "what can you ask", "usage", "commands", "examples", "syntax", "options",
662
+ ]);
663
+
404
664
  // ---- omitted-on-purpose (judgment calls, not oversights) -------------------
405
665
  // "runs"/"executes" (calls) are common English words with many non-code
406
666
  // senses — accepted anyway, formal-template AND keyword-spotting alike,