@polycode-projects/seonix 0.1.0 → 0.2.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.
@@ -0,0 +1,225 @@
1
+ // schema-docs.mjs — the single source of truth for seonix's own ontology documentation.
2
+ //
3
+ // seonix's typed graph (packages/seonix/src/extract.mjs's buildEntities, queried by
4
+ // codegraph.mjs) has had its schema documented since day one, but only in scattered code
5
+ // comments (extract.mjs's header, the `note` fields on some — not all — of the `vocabulary`
6
+ // array entries it emits) — readable by a human editing the source, invisible to anything
7
+ // that only sees graph.json. This file completes that documentation (every entity class,
8
+ // every predicate actually emitted — verified against real `prop:`/`class:` literals in
9
+ // extract.mjs, not just what the existing vocabulary array already claimed) and
10
+ // `ingestSchemaDocs()` (called once, from `indexRepository`, see extract.mjs) merges it
11
+ // into every graph build so a question like "what does cochange mean" or "what is a
12
+ // Commit" is answerable by querying the SAME graph the same way any other question is —
13
+ // not via separate hardcoded documentation logic.
14
+ //
15
+ // GLOBAL, NOT PER-REPO: this documentation does not vary by repository — "Module" means
16
+ // the same thing indexing Django or a JS library — so it is static, committed data
17
+ // (this file), never recomputed at index time. Ingesting it is a fixed-size merge, not a
18
+ // computation (see the ~0ms measured delta in schema-docs.test.mjs / extract.test.mjs).
19
+ //
20
+ // Mirrors the pattern in marginalia's app/lib/vocab.mjs (a `comment`/description per
21
+ // schema term, single-sourced) minus the RDF/OWL external-alignment machinery, which
22
+ // doesn't apply here — seonix's schema is code-relationship-specific, not general-domain.
23
+
24
+ // ---- entity classes (7 — verified against every `class: "X"` individual literal in
25
+ // extract.mjs) -----------------------------------------------------------------------
26
+ export const CLASS_DOCS = Object.freeze([
27
+ { name: "Module", description:
28
+ "A source file — one importable unit within the indexed repository (a .py/.mjs/.ts/" +
29
+ ".cs/.java file, etc). The coarsest unit seonix ranks, locates, and injects as a " +
30
+ "digest; every other entity class belongs to exactly one Module." },
31
+ { name: "Class", description:
32
+ "A class definition inside a Module. Contains Methods and Attributes (seon:" +
33
+ "containsCodeEntity) and may declare a base class (seon:hasSuperType)." },
34
+ { name: "Function", description:
35
+ "A top-level, module-scope function definition — not a method (methods belong to a " +
36
+ "Class and are their own entity class)." },
37
+ { name: "Method", description:
38
+ "A function defined inside a Class — an instance, static, or class method. Distinct " +
39
+ "from Function so class membership (seon:containsCodeEntity) and free functions " +
40
+ "never get confused." },
41
+ { name: "Attribute", description:
42
+ "A field or property belonging to a Class — either a class-level assignment or a " +
43
+ "self-scoped instance field seen in a method body (self.<name> =). Distinct from a " +
44
+ "module-level GlobalVariable." },
45
+ { name: "GlobalVariable", description:
46
+ "A module-level variable or constant assignment that belongs to no Class or " +
47
+ "function — a name defined directly in a Module's top-level scope." },
48
+ { name: "Commit", description:
49
+ "A single recorded git commit. Carries author/date/message attributes and connects " +
50
+ "to the Modules it touched (mgx:touchedByCommit) and, more precisely, the specific " +
51
+ "symbols whose current source span its changed lines intersect (mgx:touchesSymbol)." },
52
+ ]);
53
+
54
+ // ---- predicates / attributes (every `prop:` token actually emitted by buildEntities,
55
+ // verified by grep against extract.mjs — not just what the pre-existing `vocabulary`
56
+ // array already documented; three real gaps found this way: seon:startsAt, mgx:value,
57
+ // mgx:dotted, plus the prose second-pass's mgx:hasProseTokens) ------------------------
58
+ export const PREDICATE_DOCS = Object.freeze([
59
+ // ---- object properties (edges between individuals) ----
60
+ { prop: "mgx:importsNamespace", kind: "imports", description:
61
+ "Module → Module. The subject module has a top-level import statement that resolves " +
62
+ "to a module already present in this repository's index. External/unresolved " +
63
+ "imports are dropped, never guessed." },
64
+ { prop: "mgx:callsCoarse", kind: "calls", description:
65
+ "Module → Module. An import-backed, module-granular \"this file calls into that " +
66
+ "file\" edge — not resolved to a specific symbol (see callsSymbol for that)." },
67
+ { prop: "mgx:callsSymbol", kind: "callsSymbol", description:
68
+ "Function/Method → Function/Class. A call site resolved to exactly one same-named " +
69
+ "definition in the repository — unambiguous names only; an ambiguous or external " +
70
+ "call is dropped rather than guessed (no wrong edge)." },
71
+ { prop: "seon:declaresMethod", kind: "defines", description:
72
+ "Module → Function/Class/Method/Attribute/GlobalVariable. What a module's top-level " +
73
+ "scope actually declares." },
74
+ { prop: "seon:containsCodeEntity", kind: "contains", description:
75
+ "Class → Method/Attribute. Class membership — which methods and fields belong to " +
76
+ "which class." },
77
+ { prop: "mgx:touchedByCommit", kind: "touches", description:
78
+ "Commit → Module. From `git log --name-only`, restricted to modules already in the " +
79
+ "index — which files a commit's diff lists, at file granularity." },
80
+ { prop: "mgx:touchesSymbol", kind: "touchesSymbol", description:
81
+ "Commit → Function/Method/Class/Attribute. A commit's changed-line-range intersected " +
82
+ "with a symbol's current source span — WHICH specific function or class a commit " +
83
+ "actually edited, not just which file it touched." },
84
+ { prop: "mgx:testsCoverage", kind: "tests", description:
85
+ "Module → Module. A test module's own internal imports — a lightweight \"this test " +
86
+ "file exercises that source file\" signal, not line-level coverage-tool data." },
87
+ { prop: "seon:hasSuperType", kind: "inherits", description:
88
+ "Class → Class. A class's base class, resolved to an internal Class when the name " +
89
+ "matches one defined in the repo; otherwise recorded as an external `ext:` reference." },
90
+ { prop: "mgx:changeCoupledWith", kind: "cochange", description:
91
+ "Module ↔ Module. Two modules frequently committed together, independent of any " +
92
+ "import or call relationship — surfaces coupling that static analysis alone misses " +
93
+ "(e.g. a module and its test file, or two files that always change together for a " +
94
+ "reason no import edge captures)." },
95
+ { prop: "mgx:reExports", kind: "reexports", description:
96
+ "Module → Function/Class. A module's public-API (`__all__`) entry that re-exports a " +
97
+ "symbol defined or imported elsewhere — answers \"where is X importable from,\" not " +
98
+ "just \"where is X defined.\"" },
99
+ { prop: "mgx:hasProseTokens", kind: "prose", description:
100
+ "Individual → word tokens (an attribute, not a between-individuals edge). The " +
101
+ "decomposed word sequence extracted from an identifier's name (camelCase/snake_case " +
102
+ "split) and any doc-comment prose, used by the second-pass prose-to-symbol " +
103
+ "cross-reference index (PLAN_PROSE_INDEX.md) to resolve free-text object terms that " +
104
+ "don't exact-match an identifier." },
105
+
106
+ // ---- attributes (individual-scoped facts, not edges) ----
107
+ { prop: "seon:startsAt", kind: "attribute", description:
108
+ "The `path:line` or `path:start-end` source location of a Function/Method/Class/" +
109
+ "Attribute/GlobalVariable — where in its Module the definition actually lives." },
110
+ { prop: "mgx:dotted", kind: "attribute", description:
111
+ "A Module's dotted import name (e.g. `pkg.sub.mod` for Python), when the language " +
112
+ "has one — used to resolve import statements to internal modules." },
113
+ { prop: "mgx:exportsAll", kind: "attribute", description:
114
+ "A Module's literal `__all__` membership list, stored even for entries that don't " +
115
+ "resolve to a real symbol — so the digest can always tell an agent \"this module has " +
116
+ "an __all__, add your new symbol to it.\"" },
117
+ { prop: "mgx:decorator", kind: "attribute", description:
118
+ "Python/framework decorators applied to a Function/Method/Class definition (e.g. " +
119
+ "`@property`, `@app.route(...)`), as written." },
120
+ { prop: "mgx:value", kind: "attribute", description:
121
+ "The literal right-hand-side value of a GlobalVariable's assignment, when the AST " +
122
+ "extractor could capture one cheaply (a constant or simple literal)." },
123
+ { prop: "mgx:commitAuthor", kind: "attribute", description: "A Commit's author name (git %an)." },
124
+ { prop: "mgx:commitDate", kind: "attribute", description:
125
+ "A Commit's author date, ISO-8601 (git %aI)." },
126
+ { prop: "mgx:commitMessage", kind: "attribute", description:
127
+ "A Commit's subject line (git %s), capped to 120 characters." },
128
+ { prop: "seon:hasParameter", kind: "attribute", description:
129
+ "A Function/Method's formal parameter list, as a signature string (the AST's " +
130
+ "unparsed argument list — not resolved types)." },
131
+ { prop: "seon:hasReturnType", kind: "attribute", description:
132
+ "A Function/Method's return type ANNOTATION exactly as written — not an inferred or " +
133
+ "resolved type." },
134
+ { prop: "seon:throwsException", kind: "attribute", description:
135
+ "Exception class names literally named in a `raise` statement inside the definition — " +
136
+ "not resolved to their own Class individuals." },
137
+ { prop: "seon:catchesException", kind: "attribute", description:
138
+ "Exception type names literally named in an `except` handler inside the definition." },
139
+ { prop: "seon:accessesField", kind: "attribute", description:
140
+ "`self.<field>` names a Method reads or writes — self-scoped only (an honest, " +
141
+ "narrow signal, not general data-flow analysis)." },
142
+ { prop: "seon:isStatic", kind: "attribute", description:
143
+ "The definition is decorated `@staticmethod` or `@classmethod`." },
144
+ { prop: "seon:isAbstract", kind: "attribute", description:
145
+ "The definition is decorated `@abstractmethod` or `@abstractproperty`." },
146
+ { prop: "seon:isConstant", kind: "attribute", description:
147
+ "An ALL_CAPS module-level GlobalVariable — a naming-convention signal, not enforced " +
148
+ "immutability." },
149
+ { prop: "seon:hasAccessModifier", kind: "attribute", description:
150
+ "Visibility inferred from a leading underscore (private/protected); a public member " +
151
+ "carries no value for this attribute." },
152
+ { prop: "seon:hasDoc", kind: "attribute", description:
153
+ "The first line of a docstring, length-capped — a one-line purpose summary without " +
154
+ "the full docstring body." },
155
+ ]);
156
+
157
+ const classDocByName = new Map(CLASS_DOCS.map((c) => [c.name, c]));
158
+ const predicateDocByProp = new Map(PREDICATE_DOCS.map((p) => [p.prop, p]));
159
+
160
+ /** Meta-individual ids are namespaced (`schema:class:`/`schema:predicate:`) so they can
161
+ * never collide with a real code entity's id (`mod:`/`fn:`/`commit:`) and are trivially
162
+ * filterable out of normal code-entity queries by anything that cares to. */
163
+ const classIndividual = ({ name, description }) => ({
164
+ id: `schema:class:${name}`,
165
+ label: name,
166
+ class: "SchemaClass",
167
+ derived_from: [],
168
+ mentions: [],
169
+ attributes: [{ prop: "mgx:schemaDoc", key: "doc", value: description }],
170
+ });
171
+
172
+ const predicateIndividual = ({ prop, kind, description }) => ({
173
+ id: `schema:predicate:${prop}`,
174
+ // The label is the human-facing relation name (what a user would actually type — "what
175
+ // does cochange mean" — matching ask-vocab.mjs's VERB_TO_KIND/kind vocabulary), not the
176
+ // raw prop token; the token is kept as a separate attribute for exact lookup.
177
+ label: kind,
178
+ class: "SchemaPredicate",
179
+ derived_from: [],
180
+ mentions: [],
181
+ attributes: [
182
+ { prop: "mgx:schemaDoc", key: "doc", value: description },
183
+ { prop: "mgx:schemaToken", key: "token", value: prop },
184
+ ],
185
+ });
186
+
187
+ /** Merge the static schema documentation into a just-built `entities` payload (the
188
+ * object `buildEntities` returns, before it's serialized to graph.json):
189
+ * - `entities.classes[].description` filled in for every known class,
190
+ * - `entities.vocabulary[].note` backfilled wherever it was missing (existing notes
191
+ * are left as-is — this only fills gaps, it doesn't overwrite a human's wording),
192
+ * - `entities.individuals` gains one SchemaClass + one SchemaPredicate individual per
193
+ * documented term, so the SAME graph traversal that answers "what calls X" can also
194
+ * answer "what is a Commit" or "what does cochange mean".
195
+ * Mutates and returns `entities`. Idempotent (safe to call more than once — individuals
196
+ * are only appended if not already present by id). Pure w.r.t. repo content: the output
197
+ * is identical regardless of what was indexed. */
198
+ export function ingestSchemaDocs(entities) {
199
+ if (!entities || typeof entities !== "object") return entities;
200
+
201
+ if (Array.isArray(entities.classes)) {
202
+ for (const c of entities.classes) {
203
+ const doc = classDocByName.get(c.name);
204
+ if (doc && c.description === undefined) c.description = doc.description;
205
+ }
206
+ }
207
+ if (Array.isArray(entities.vocabulary)) {
208
+ for (const v of entities.vocabulary) {
209
+ const doc = predicateDocByProp.get(v.prop);
210
+ if (doc && !v.note) v.note = doc.description;
211
+ }
212
+ }
213
+
214
+ entities.individuals ||= [];
215
+ const existingIds = new Set(entities.individuals.map((i) => i?.id));
216
+ for (const c of CLASS_DOCS) {
217
+ const ind = classIndividual(c);
218
+ if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
219
+ }
220
+ for (const p of PREDICATE_DOCS) {
221
+ const ind = predicateIndividual(p);
222
+ if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
223
+ }
224
+ return entities;
225
+ }
package/src/server.mjs CHANGED
@@ -9,6 +9,10 @@
9
9
  // seonix_architecture, seonix_tests_for, seonix_untested, seonix_history, seonix_callers,
10
10
  // seonix_callees. Each answers one question in ONE compact call so the agent need not
11
11
  // Read/Grep. Errors reach the caller as clean tool errors — message only, never a stack.
12
+ //
13
+ // seonix_ask (PLAN_MECHANICAL_CHAT.md, hot tool): a mechanical, zero-model-call NL query
14
+ // over the graph — collapses the search+describe+traversal composition loop an agent would
15
+ // otherwise hand-compose into one deterministic round-trip. See ask.mjs.
12
16
 
13
17
  import { readFile } from "node:fs/promises";
14
18
  import { dirname, join } from "node:path";
@@ -46,6 +50,7 @@ import {
46
50
  renderMethodHistory,
47
51
  renderClassHistory,
48
52
  } from "./codegraph.mjs";
53
+ import { ask } from "./ask.mjs";
49
54
 
50
55
  const SNIPPET_MAX_LINES = 200;
51
56
 
@@ -82,6 +87,18 @@ export const TOOLS = [
82
87
  },
83
88
  },
84
89
  },
90
+ {
91
+ name: "seonix_ask",
92
+ description:
93
+ "Ask a structural question in plain English (e.g. \"which functions call X\") — one call, no model, replaces manual search+describe+traversal. A clean miss beats a guess.",
94
+ inputSchema: {
95
+ type: "object",
96
+ required: ["query"],
97
+ properties: {
98
+ query: { type: "string", description: "A free-text question, e.g. \"which functions explicitly couple to logging\"." },
99
+ },
100
+ },
101
+ },
85
102
  ];
86
103
 
87
104
  async function loadGraph(config, source) {
@@ -123,22 +140,26 @@ export async function buildContextBundle(args, { config, source = defaultSource,
123
140
  if (!symbol) throw new ToolError("symbol is required");
124
141
  const depth = String(args?.depth || "auto").trim().toLowerCase();
125
142
  // Tuning-flag contract: `min` forces the LEANEST bundle (TINY mask, no top-up) regardless of
126
- // exemplar length; `untuned` reproduces the pre-B010 escalation (tuning #1 bypassed). Neither
143
+ // exemplar length; `untuned` reproduces the earlier escalation (tuning #1 bypassed). Neither
127
144
  // → the tuned default (sizeBundle's anchor-gated escalation).
128
145
  const min = Boolean(args?.min);
129
146
  const untuned = Boolean(args?.untuned);
147
+ // `max` forces the injection CEILING — FULL tier (every section + inlined depth-1 callee
148
+ // bodies) with top-up, and it OVERRIDES trim so even secondary modules get the full bundle. Used
149
+ // by the seonix-max arm to test whether more injection re-bloats.
150
+ const max = Boolean(args?.max);
130
151
  const graph = await loadGraph(config, source);
131
152
  const { match } = resolveOrThrow(graph, symbol, "symbol");
132
153
  const plan = contextPlan(graph, match);
133
- // #6/B1/B6: pick the section mask by depth — min forces TINY, full forces everything, auto
154
+ // #6/B1/B6: pick the section mask by depth — min forces TINY, full/max forces everything, auto
134
155
  // runs the size classifier (lean TINY default + one-tier top-up when the edit needs it).
135
156
  let tier;
136
157
  let mask;
137
158
  let topup = false;
138
159
  if (min || depth === "min") { tier = "TINY"; mask = bundleMask("TINY"); }
139
- else if (depth === "full") { tier = "FULL"; mask = bundleMask("FULL"); }
160
+ else if (max || depth === "full") { tier = "FULL"; mask = bundleMask("FULL"); topup = true; }
140
161
  else ({ tier, mask, topup } = sizeBundle(plan, graph, { untuned }));
141
- if (trim) mask = trimBundleMask(mask); // B2: secondary digest module → signatures + region only
162
+ if (trim && !max) mask = trimBundleMask(mask); // B2: secondary digest module → signatures + region only (max keeps the full bundle)
142
163
  const repoRoot = dirname(dirname(config.graphFile));
143
164
  let lines = null;
144
165
  if (plan.moduleLabel) {
@@ -341,6 +362,16 @@ export async function dispatchTool(name, args, { config, source = defaultSource
341
362
  const graph = await loadGraph(config, source);
342
363
  return renderUntested(graph);
343
364
  }
365
+ if (name === "seonix_ask") {
366
+ const query = String(args?.query || "").trim();
367
+ if (!query) throw new ToolError("query is required");
368
+ const graph = await loadGraph(config, source);
369
+ const { content, seonix_ask } = ask(graph, query);
370
+ // Every dispatchTool caller (this MCP handler, the CLI fallback) expects a plain string —
371
+ // append the structured envelope as a delimited, machine-parseable block rather than
372
+ // changing that shared contract for one tool. PLAN_MECHANICAL_CHAT.md §6.2.
373
+ return `${content}\n\n---seonix_ask---\n${JSON.stringify(seonix_ask, null, 2)}`;
374
+ }
344
375
  if (
345
376
  name === "seonix_tests_for" || name === "seonix_history" || name === "seonix_callers" ||
346
377
  name === "seonix_callees" || name === "seonix_cochanges" || name === "seonix_calls" ||