@polycode-projects/seonix 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,12 @@ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n));
36
36
  const fieldText = (n, f) => n.childForFieldName(f)?.text || "";
37
37
 
38
38
  const TYPE_DECLS = new Set(["class_declaration", "interface_declaration", "enum_declaration", "record_declaration"]);
39
+ const SUBKIND = { interface_declaration: "interface", enum_declaration: "enum", record_declaration: "record" };
39
40
  const BASE_WRAPPERS = new Set(["superclass", "super_interfaces", "extends_interfaces", "interfaces"]);
40
41
  const TYPE_NODES = new Set(["type_identifier", "generic_type", "scoped_type_identifier"]);
41
42
 
42
- /** Callee names within a node (syntax-level; unresolved). */
43
+ /** Callee names within a node (syntax-level; unresolved). Object creations count as
44
+ * call edges to the created type: `new X(...)` → bare "X" (generics/scope stripped). */
43
45
  function collectCalls(node) {
44
46
  const out = new Set();
45
47
  const stack = [node];
@@ -48,6 +50,10 @@ function collectCalls(node) {
48
50
  if (n.type === "method_invocation") {
49
51
  const nm = fieldText(n, "name");
50
52
  if (nm) out.add(clip(nm, 80));
53
+ } else if (n.type === "object_creation_expression") {
54
+ const t = n.childForFieldName("type");
55
+ const nm = t ? oneLine(t.text).replace(/<.*$/s, "").split(".").pop().trim() : "";
56
+ if (nm) out.add(clip(nm, 80));
51
57
  }
52
58
  for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
53
59
  }
@@ -104,6 +110,79 @@ function bodyContainer(typeNode) {
104
110
  || null;
105
111
  }
106
112
 
113
+ /** Emit one type declaration (and, recursively, its nested types as Outer.Inner —
114
+ * matching the JavaParser backend's qualified names). kind stays "class"; the
115
+ * flavour (interface/enum/record) lands in `subkind`. */
116
+ function emitType(n, prefix, defines, exports) {
117
+ const simple = fieldText(n, "name");
118
+ if (!simple) return;
119
+ const cname = prefix ? `${prefix}.${simple}` : simple;
120
+ const mods = modifiersNode(n);
121
+ defines.push({
122
+ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n),
123
+ bases: basesOf(n), decorators: decoratorsFrom(mods),
124
+ ...(SUBKIND[n.type] ? { subkind: SUBKIND[n.type] } : {}),
125
+ ...(visFrom(mods) ? { visibility: visFrom(mods) } : {}),
126
+ });
127
+ if (isPublic(mods)) exports.add(cname);
128
+
129
+ // record components (formal params) surface as attributes (match JavaParser)
130
+ if (n.type === "record_declaration") {
131
+ const fp = n.childForFieldName("parameters");
132
+ if (fp) for (let i = 0; i < fp.namedChildCount; i++) {
133
+ const fpc = fp.namedChild(i);
134
+ if (fpc.type !== "formal_parameter") continue;
135
+ const pn = fieldText(fpc, "name");
136
+ if (pn) defines.push({ name: `${cname}.${pn}`, kind: "attribute", lineno: line(fpc), end_lineno: endLine(fpc), decorators: [] });
137
+ }
138
+ }
139
+
140
+ const body = bodyContainer(n);
141
+ if (!body) return;
142
+ // enum bodies keep constants at the top and wrap the rest in enum_body_declarations
143
+ const members = [];
144
+ for (let i = 0; i < body.namedChildCount; i++) {
145
+ const mem = body.namedChild(i);
146
+ if (mem.type === "enum_body_declarations") {
147
+ for (let j = 0; j < mem.namedChildCount; j++) members.push(mem.namedChild(j));
148
+ } else {
149
+ members.push(mem);
150
+ }
151
+ }
152
+ for (const mem of members) {
153
+ if (TYPE_DECLS.has(mem.type)) {
154
+ emitType(mem, cname, defines, exports); // nested type → Outer.Inner
155
+ } else if (mem.type === "enum_constant") {
156
+ const en = fieldText(mem, "name");
157
+ if (en) defines.push({ name: `${cname}.${en}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [], is_constant: true });
158
+ } else if (mem.type === "method_declaration" || mem.type === "constructor_declaration") {
159
+ const mn = fieldText(mem, "name") || cname;
160
+ const mmods = modifiersNode(mem);
161
+ const returns = mem.type === "method_declaration"
162
+ ? clip(oneLine(mem.childForFieldName("type")?.text || ""), 80) : "";
163
+ defines.push({
164
+ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
165
+ decorators: decoratorsFrom(mmods), params: paramsText(mem),
166
+ ...(returns ? { returns } : {}), calls: collectCalls(mem),
167
+ ...(isStatic(mmods) ? { is_static: true } : {}),
168
+ ...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}),
169
+ });
170
+ } else if (mem.type === "field_declaration") {
171
+ const fmods = modifiersNode(mem);
172
+ for (let j = 0; j < mem.namedChildCount; j++) {
173
+ const vd = mem.namedChild(j);
174
+ if (vd.type !== "variable_declarator") continue;
175
+ const fn = fieldText(vd, "name");
176
+ if (fn) defines.push({
177
+ name: `${cname}.${fn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem),
178
+ decorators: decoratorsFrom(fmods),
179
+ ...(visFrom(fmods) ? { visibility: visFrom(fmods) } : {}),
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+
107
186
  export async function extractFile(absPath, root) {
108
187
  const text = await readFile(absPath, "utf8").catch(() => null);
109
188
  const path = relPath(root, absPath);
@@ -132,61 +211,9 @@ export async function extractFile(absPath, root) {
132
211
  if (nm) imports.add(oneLine(nm));
133
212
  }
134
213
  } else if (TYPE_DECLS.has(n.type)) {
135
- const cname = fieldText(n, "name");
136
- if (cname) {
137
- const mods = modifiersNode(n);
138
- defines.push({
139
- name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n),
140
- bases: basesOf(n), decorators: decoratorsFrom(mods),
141
- ...(visFrom(mods) ? { visibility: visFrom(mods) } : {}),
142
- });
143
- if (isPublic(mods)) exports.add(cname);
144
-
145
- // record components (formal params) surface as attributes (match JavaParser)
146
- if (n.type === "record_declaration") {
147
- const fp = n.childForFieldName("parameters");
148
- if (fp) for (let i = 0; i < fp.namedChildCount; i++) {
149
- const fpc = fp.namedChild(i);
150
- if (fpc.type !== "formal_parameter") continue;
151
- const pn = fieldText(fpc, "name");
152
- if (pn) defines.push({ name: `${cname}.${pn}`, kind: "attribute", lineno: line(fpc), end_lineno: endLine(fpc), decorators: [] });
153
- }
154
- }
155
-
156
- const body = bodyContainer(n);
157
- if (body) {
158
- for (let i = 0; i < body.namedChildCount; i++) {
159
- const mem = body.namedChild(i);
160
- if (mem.type === "method_declaration" || mem.type === "constructor_declaration") {
161
- const mn = fieldText(mem, "name") || cname;
162
- const mmods = modifiersNode(mem);
163
- const returns = mem.type === "method_declaration"
164
- ? clip(oneLine(mem.childForFieldName("type")?.text || ""), 80) : "";
165
- defines.push({
166
- name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
167
- decorators: decoratorsFrom(mmods), params: paramsText(mem),
168
- ...(returns ? { returns } : {}), calls: collectCalls(mem),
169
- ...(isStatic(mmods) ? { is_static: true } : {}),
170
- ...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}),
171
- });
172
- } else if (mem.type === "field_declaration") {
173
- const fmods = modifiersNode(mem);
174
- for (let j = 0; j < mem.namedChildCount; j++) {
175
- const vd = mem.namedChild(j);
176
- if (vd.type !== "variable_declarator") continue;
177
- const fn = fieldText(vd, "name");
178
- if (fn) defines.push({
179
- name: `${cname}.${fn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem),
180
- decorators: decoratorsFrom(fmods),
181
- ...(visFrom(fmods) ? { visibility: visFrom(fmods) } : {}),
182
- });
183
- }
184
- }
185
- }
186
- }
187
- }
214
+ emitType(n, "", defines, exports);
188
215
  }
189
- // descend, but not back into a type body (members handled above)
216
+ // descend, but not back into a type body (emitType recurses for nested types)
190
217
  if (!TYPE_DECLS.has(n.type)) for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
191
218
  }
192
219
 
package/src/jsts_tsc.mjs CHANGED
@@ -1,11 +1,17 @@
1
- // JS/TS extractor — TypeScript compiler API (via ts-morph's re-exported `ts`
2
- // namespace; no separate `typescript` dep). PICKED candidate for JS/TS.
1
+ // JS/TS extractor — TypeScript compiler API (direct `typescript` dep; the
2
+ // 2026-07-02 library review swapped ts-morph out we only ever used its
3
+ // re-exported `ts` namespace, so importing the compiler directly is byte-identical
4
+ // output for −12.4 MB of install). PICKED candidate for JS/TS.
3
5
  //
4
6
  // Uses ts.createSourceFile per file (no Program / no type-checker) — the fast,
5
7
  // deterministic, offline structural pass. Covers .ts/.tsx/.js/.jsx (allowJs is
6
8
  // implicit: JS is just parsed with the JS script-kind). Emits the SAME
7
9
  // `{path,dotted,imports,defines,calls,exports}` contract extract_ast.py prints,
8
10
  // so extract.mjs/buildEntities + codegraph.mjs/digest consume it unchanged.
11
+ // Both module systems are read: ESM import/export declarations AND a CommonJS
12
+ // pass (top-level require() with literal specifiers → imports; module.exports /
13
+ // exports.name assignments → exports) — express-style repos are CJS-only and
14
+ // were producing edge-empty graphs before the 2026-07-02 pass.
9
15
  //
10
16
  // Fidelity note (honest): params/returns are ANNOTATION strings (Group-A
11
17
  // mechanical), exactly like the Python path's "returns = annotation only". A
@@ -13,7 +19,7 @@
13
19
  // promoting Group-B→A) is the documented ceiling, NOT run here.
14
20
  import { readFile } from "node:fs/promises";
15
21
  import { dirname, join } from "node:path";
16
- import { ts } from "ts-morph";
22
+ import ts from "typescript";
17
23
  import { walk, relPath } from "./walk.mjs";
18
24
 
19
25
  const EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
@@ -98,6 +104,27 @@ export async function extractFile(absPath, root) {
98
104
  imports.add(`${base}/index`); // dir-import fallback (./foo → ./foo/index)
99
105
  };
100
106
 
107
+ // ---- CommonJS pass (2026-07-02): express-style repos are require()/module.exports only,
108
+ // which left their graphs edge-empty (js-express: imports 0 — five of six ask families
109
+ // structurally impossible). Top-level statements only, same resolveSpecifier as the ESM
110
+ // specifiers (relative paths resolve to modules, bare ones stay as-is), and STRICTLY
111
+ // literal arguments — a require(expr) is skipped, never guessed (house "no wrong edge").
112
+ /** `require("x")` / require(`x`) with a single literal arg → its specifier, else null. */
113
+ const requireSpecifier = (node) =>
114
+ node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require" &&
115
+ node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])
116
+ ? node.arguments[0].text
117
+ : null;
118
+ /** Peel `require("x").member` / parens down to the require call (never enters functions). */
119
+ const requireIn = (expr) => {
120
+ let e = expr;
121
+ while (e && (ts.isPropertyAccessExpression(e) || ts.isParenthesizedExpression(e) || ts.isNonNullExpression?.(e))) e = e.expression;
122
+ return requireSpecifier(e);
123
+ };
124
+ const isModuleExports = (e) =>
125
+ ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === "module" && e.name.text === "exports";
126
+ const isExportsRef = (e) => isModuleExports(e) || (ts.isIdentifier(e) && e.text === "exports");
127
+
101
128
  const addFn = (name, node, kind, extra = {}) => {
102
129
  defines.push({
103
130
  name, kind, lineno: lineOf(sf, node), end_lineno: endLineOf(sf, node),
@@ -155,6 +182,11 @@ export async function extractFile(absPath, root) {
155
182
  } else if (ts.isVariableStatement(stmt)) {
156
183
  const isConst = (stmt.declarationList.flags & ts.NodeFlags.Const) !== 0;
157
184
  for (const d of stmt.declarationList.declarations) {
185
+ // CJS import: const X = require('spec') / const {a, b} = require('spec') /
186
+ // const Y = require('spec').member — BEFORE the identifier-only gate so a
187
+ // destructuring binding still records the import edge.
188
+ const reqSpec = requireIn(d.initializer);
189
+ if (reqSpec != null) resolveSpecifier(reqSpec);
158
190
  if (!ts.isIdentifier(d.name)) continue;
159
191
  const vn = d.name.text;
160
192
  const init = d.initializer;
@@ -173,6 +205,21 @@ export async function extractFile(absPath, root) {
173
205
  }
174
206
  } else if (ts.isExportAssignment?.(stmt)) {
175
207
  exports.add("default");
208
+ } else if (ts.isExpressionStatement(stmt)) {
209
+ // CJS exports + side-effect/re-export requires. Walk `=` chains so
210
+ // `exports = module.exports = X` records one default export, and
211
+ // `module.exports = require('./lib')` records both the export and the import.
212
+ let expr = stmt.expression;
213
+ while (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
214
+ const lhs = expr.left;
215
+ if (isExportsRef(lhs)) exports.add("default"); // module.exports = X / exports = X
216
+ else if (ts.isPropertyAccessExpression(lhs) && isExportsRef(lhs.expression)) {
217
+ exports.add(lhs.name.text); // module.exports.name = / exports.name =
218
+ }
219
+ expr = expr.right;
220
+ }
221
+ const reqSpec = requireIn(expr); // covers bare `require('./side-effect')` too
222
+ if (reqSpec != null) resolveSpecifier(reqSpec);
176
223
  }
177
224
  }
178
225
 
@@ -208,4 +255,4 @@ export async function ingest(root, { ignore = null } = {}) {
208
255
  return { modules, failures, fileCount: files.length };
209
256
  }
210
257
 
211
- export const meta = { id: "tsc", language: "js/ts", lib: "typescript compiler API (via ts-morph)", exts: EXTS };
258
+ export const meta = { id: "tsc", language: "js/ts", lib: "typescript compiler API", exts: EXTS };
@@ -0,0 +1,52 @@
1
+ // prose-nlp.mjs — the OPTIONAL wink-nlp lemma loader behind prose.mjs's LEMMA layer.
2
+ //
3
+ // Deliberately a SEPARATE loader from ask-nlp.mjs (same createRequire pattern, same
4
+ // deps) rather than an import of it: ask-nlp.mjs belongs to the ask-engine surface
5
+ // and is under active concurrent work — the prose pre-pass must not couple its
6
+ // index-build path to that file's export shape. The ~20 duplicated lines are the
7
+ // decoupling fee; the wink model itself is loaded lazily and at most once per
8
+ // process either way.
9
+ //
10
+ // BOUNDARY (same as ask-nlp.mjs, hard): Node-only, never inlined into the viewer
11
+ // bundle. prose.mjs is itself never inlined by viz.mjs's askSource(), so nothing
12
+ // browser-side can reach this module. wink-nlp + wink-eng-lite-web-model are CJS —
13
+ // loaded via createRequire, lazily, with failure cached as null: a checkout without
14
+ // the optional deps simply builds no lemma layer (honestly absent), it never throws.
15
+ //
16
+ // Determinism: wink's lemmatiser is a fixed trained model with no sampling — the
17
+ // same token always yields the same lemma across runs and processes, which is what
18
+ // lets the lemma layer meet the "byte-identical proseIndex across builds" contract.
19
+
20
+ import { createRequire } from "node:module";
21
+
22
+ let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
23
+
24
+ /** Lazily build a `lemma(word) -> string` function, or null when wink isn't loadable.
25
+ * Results are memoized per token: the layer build lemmatises each unique vocabulary
26
+ * token once, not once per posting. */
27
+ export function proseLemma() {
28
+ if (cached !== undefined) return cached;
29
+ try {
30
+ const require = createRequire(import.meta.url);
31
+ const winkNLP = require("wink-nlp");
32
+ const model = require("wink-eng-lite-web-model");
33
+ const nlp = winkNLP(model);
34
+ const its = nlp.its;
35
+ const memo = new Map();
36
+ cached = (word) => {
37
+ const w = String(word || "");
38
+ if (memo.has(w)) return memo.get(w);
39
+ let out;
40
+ try {
41
+ out = String(nlp.readDoc(w).tokens().out(its.lemma)[0] || w).toLowerCase();
42
+ } catch {
43
+ out = w.toLowerCase();
44
+ }
45
+ memo.set(w, out);
46
+ return out;
47
+ };
48
+ } catch {
49
+ cached = null;
50
+ }
51
+ return cached;
52
+ }
@@ -49,6 +49,12 @@ export const CLASS_DOCS = Object.freeze([
49
49
  "A single recorded git commit. Carries author/date/message attributes and connects " +
50
50
  "to the Modules it touched (mgx:touchedByCommit) and, more precisely, the specific " +
51
51
  "symbols whose current source span its changed lines intersect (mgx:touchesSymbol)." },
52
+ { name: "Session", description:
53
+ "One recorded `seonix chat` session — a runtime observation, not a source " +
54
+ "derivation. Carries started/ended timestamps (temporal ordering, like a Commit's " +
55
+ "date), a turn count and the queries asked, and connects to the entities its turns " +
56
+ "resolved or answered with (mgx:asksAbout). Recorded under .seonix/sessions/ and " +
57
+ "re-attached on every re-index (sessions.mjs)." },
52
58
  ]);
53
59
 
54
60
  // ---- predicates / attributes (every `prop:` token actually emitted by buildEntities,
@@ -96,6 +102,11 @@ export const PREDICATE_DOCS = Object.freeze([
96
102
  "Module → Function/Class. A module's public-API (`__all__`) entry that re-exports a " +
97
103
  "symbol defined or imported elsewhere — answers \"where is X importable from,\" not " +
98
104
  "just \"where is X defined.\"" },
105
+ { prop: "mgx:asksAbout", kind: "asksAbout", description:
106
+ "Session → any entity. A chat session's turn resolved this entity as its subject " +
107
+ "or cited it in the answer — which parts of the codebase a human actually asked " +
108
+ "about. A runtime observation (owned term, no SEON equivalent); references that no " +
109
+ "longer resolve after a re-index are dropped and counted, never guessed." },
99
110
  { prop: "mgx:hasProseTokens", kind: "prose", description:
100
111
  "Individual → word tokens (an attribute, not a between-individuals edge). The " +
101
112
  "decomposed word sequence extracted from an identifier's name (camelCase/snake_case " +
@@ -120,6 +131,20 @@ export const PREDICATE_DOCS = Object.freeze([
120
131
  { prop: "mgx:value", kind: "attribute", description:
121
132
  "The literal right-hand-side value of a GlobalVariable's assignment, when the AST " +
122
133
  "extractor could capture one cheaply (a constant or simple literal)." },
134
+ { prop: "mgx:sessionStarted", kind: "attribute", description:
135
+ "A chat Session's start time, ISO-8601 — the timestamp the session enters the " +
136
+ "timeline at (the Session analogue of a Commit's date)." },
137
+ { prop: "mgx:sessionEnded", kind: "attribute", description:
138
+ "A chat Session's end time, ISO-8601 (the /exit, EOF, or last recorded turn)." },
139
+ { prop: "mgx:sessionTurns", kind: "attribute", description:
140
+ "How many query turns a chat Session ran (non-empty, non-/exit inputs)." },
141
+ { prop: "mgx:sessionQueries", kind: "attribute", description:
142
+ "The queries a chat Session asked, ' | '-joined and length-capped — what the " +
143
+ "human wanted to know, in their own words." },
144
+ { prop: "mgx:sessionDroppedEdges", kind: "attribute", description:
145
+ "How many of a Session's recorded entity references could not be re-resolved " +
146
+ "against the current graph (renamed/removed code) — those edges are dropped, " +
147
+ "never guessed, and this attribute keeps the loss honest and visible." },
123
148
  { prop: "mgx:commitAuthor", kind: "attribute", description: "A Commit's author name (git %an)." },
124
149
  { prop: "mgx:commitDate", kind: "attribute", description:
125
150
  "A Commit's author date, ISO-8601 (git %aI)." },
@@ -146,6 +171,10 @@ export const PREDICATE_DOCS = Object.freeze([
146
171
  { prop: "seon:isConstant", kind: "attribute", description:
147
172
  "An ALL_CAPS module-level GlobalVariable — a naming-convention signal, not enforced " +
148
173
  "immutability." },
174
+ { prop: "seon:subKind", kind: "attribute", description:
175
+ "The flavour of a Class define when it is not a plain class: interface, enum, struct " +
176
+ "or record. The graph keeps kind=class for every type declaration; this attribute " +
177
+ "carries the distinction." },
149
178
  { prop: "seon:hasAccessModifier", kind: "attribute", description:
150
179
  "Visibility inferred from a leading underscore (private/protected); a public member " +
151
180
  "carries no value for this attribute." },
package/src/server.mjs CHANGED
@@ -90,7 +90,7 @@ export const TOOLS = [
90
90
  {
91
91
  name: "seonix_ask",
92
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.",
93
+ "Ask a structural question in plain English: \"which functions call X\", \"what uses X\", \"where is X defined\", \"when did X change\". One call, no model. A clean miss beats a guess.",
94
94
  inputSchema: {
95
95
  type: "object",
96
96
  required: ["query"],
@@ -394,13 +394,36 @@ export async function dispatchTool(name, args, { config, source = defaultSource
394
394
  throw new ToolError(`unknown tool: ${name}`);
395
395
  }
396
396
 
397
+ /** SEONIX_TOOLS (csv of tool names) narrows what the server registers AND serves —
398
+ * an unlisted tool is invisible to the client and un-dispatchable, not merely
399
+ * un-granted, so a permission layer that auto-denies visible-but-ungranted tools
400
+ * (headless `claude -p`) has nothing to deny. Unknown names in the csv throw at
401
+ * startup: a typo must fail loudly, not silently serve the full set. */
402
+ export function resolveServedTools(env = process.env) {
403
+ const csv = (env.SEONIX_TOOLS || "").trim();
404
+ if (!csv) return TOOLS;
405
+ const wanted = csv.split(",").map((s) => s.trim()).filter(Boolean);
406
+ const byName = new Map(TOOLS.map((t) => [t.name, t]));
407
+ const unknown = wanted.filter((n) => !byName.has(n));
408
+ if (unknown.length) throw new Error(`SEONIX_TOOLS names unknown tool(s): ${unknown.join(", ")}`);
409
+ return wanted.map((n) => byName.get(n));
410
+ }
411
+
397
412
  /** Build the MCP server (transport-agnostic — tests connect it in-memory). */
398
- export function buildServer({ config = loadConfig(), source = defaultSource } = {}) {
413
+ export function buildServer({ config = loadConfig(), source = defaultSource, env = process.env } = {}) {
414
+ const served = resolveServedTools(env);
415
+ const servedNames = new Set(served.map((t) => t.name));
416
+ // The dispatch gate applies ONLY under an active SEONIX_TOOLS filter: unfiltered, the cold
417
+ // tools stay dispatchable-though-unlisted (the C4 tiered surface); filtered, EVERYTHING
418
+ // outside the csv — hot siblings and cold tools alike — is un-dispatchable, because the
419
+ // filter's whole point is a provably single-tool server for the seonix-ask benchmark arm.
420
+ const filtered = served !== TOOLS;
399
421
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
400
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
422
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: served }));
401
423
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
402
424
  const { name, arguments: args } = req.params;
403
425
  try {
426
+ if (filtered && !servedNames.has(name)) throw new ToolError(`unknown tool: ${name}`);
404
427
  const text = await dispatchTool(name, args || {}, { config, source });
405
428
  return { content: [{ type: "text", text }] };
406
429
  } catch (e) {
@@ -416,5 +439,5 @@ export async function startServer() {
416
439
  const server = buildServer({ config });
417
440
  await server.connect(new StdioServerTransport());
418
441
  // stdout belongs to the MCP transport; sign on via stderr.
419
- process.stderr.write(`seonix: serving ${TOOLS.length} tools over ${config.graphFile}\n`);
442
+ process.stderr.write(`seonix: serving ${resolveServedTools().length} tools over ${config.graphFile}\n`);
420
443
  }
@@ -0,0 +1,206 @@
1
+ // sessions.mjs — chat sessions as first-class temporal graph data, like commits.
2
+ //
3
+ // A `seonix chat` session leaves two artifacts under the target repo:
4
+ // .seonix/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
5
+ // .seonix/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
+ // {"type":"session", id, started, repo, seonixVersion} (header line)
7
+ // {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
8
+ // {"type":"end", ts} (clean close marker)
9
+ //
10
+ // From the sidecar the session enters the typed graph twice:
11
+ // - READ TIME (chat.mjs, per turn): appendSessionToGraph() upserts one `Session`
12
+ // individual (`session:<uuidv7>`) + `mgx:asksAbout` edges into graph.json —
13
+ // atomically (temp + rename), re-reading the file first so a re-index that
14
+ // happened mid-session is tolerated: edges whose targets vanished are dropped,
15
+ // never left dangling (honest degradation).
16
+ // - RE-INDEX (extract.mjs single-path mode): readSessionRecords() + foldInSessions()
17
+ // re-attach every recorded session to the FRESH graph, re-resolving each recorded
18
+ // entity id (by id first, then by unique label derived from the id shape);
19
+ // unresolvable references are dropped and counted on the session node
20
+ // (mgx:sessionDroppedEdges) — never a guessed edge.
21
+ //
22
+ // Sessions are runtime observations, not source derivations: they record what a human
23
+ // asked the graph about and which entities answered, so re-indexing re-attaches them
24
+ // rather than re-deriving them from source.
25
+
26
+ import { readFile, readdir, rename, writeFile } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+
29
+ export const SESSIONS_DIR_REL = join(".seonix", "sessions");
30
+
31
+ export const SESSION_CLASS = "Session";
32
+ export const ASKS_ABOUT_PREDICATE = "asksAbout";
33
+ export const ASKS_ABOUT_PROP = "mgx:asksAbout";
34
+
35
+ const QUERIES_ATTR_CAP = 500; // joined-queries attribute cap (mirrors commitMessage's cap idea)
36
+
37
+ /** Session labels mirror Commit's short-sha convention: the uuid's leading time-ordered hex. */
38
+ const sessionLabel = (id) => String(id).slice(0, 8);
39
+
40
+ /** Best-effort label a recorded entity id would carry, derived from the id shape —
41
+ * the "then by label" tier of fold-in re-resolution (ids are `mod:<path>`,
42
+ * `fn:<path>#<name>`, `commit:<sha>`; labels are path / name / short sha). */
43
+ function labelFromId(id) {
44
+ const s = String(id);
45
+ if (s.startsWith("mod:")) return s.slice(4);
46
+ const fn = s.match(/^fn:.*#(.+)$/);
47
+ if (fn) return fn[1];
48
+ if (s.startsWith("commit:")) return s.slice(7, 19);
49
+ return null;
50
+ }
51
+
52
+ /** Atomic JSON write: temp file in the same directory + rename, so a concurrent
53
+ * reader never sees a torn graph.json and a crash never destroys the old one. */
54
+ async function atomicWriteJson(file, obj) {
55
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
56
+ await writeFile(tmp, JSON.stringify(obj));
57
+ await rename(tmp, file);
58
+ }
59
+
60
+ /**
61
+ * Upsert one session record into an `entities` payload (mutates; pure of I/O).
62
+ * Shared by the read-time append AND the re-index fold-in, so both resolve and
63
+ * degrade identically:
64
+ * - a prior copy of the same session (per-turn re-appends) is replaced;
65
+ * - every recorded ref resolves by id, else by UNIQUE label (ambiguous → drop);
66
+ * - dropped refs are counted on the session node, never guessed into edges.
67
+ * Record shape: { id, started, ended?, turns: [{ts, query, resolvedIds, answeredIds, miss}] }.
68
+ * Returns { kept, dropped }.
69
+ */
70
+ export function upsertSession(entities, record) {
71
+ const sid = `session:${record.id}`;
72
+ entities.individuals ||= [];
73
+ entities.objectProperties ||= [];
74
+
75
+ // replace any prior copy of this session (read-time appends run once per turn)
76
+ entities.individuals = entities.individuals.filter((i) => i?.id !== sid);
77
+ let group = entities.objectProperties.find((g) => g?.prop === ASKS_ABOUT_PROP);
78
+ if (group) {
79
+ group.examples = (group.examples || []).filter((e) => e?.subject !== sid);
80
+ } else {
81
+ group = { predicate: ASKS_ABOUT_PREDICATE, prop: ASKS_ABOUT_PROP, count: 0, examples: [] };
82
+ entities.objectProperties.push(group);
83
+ }
84
+
85
+ // resolve refs against the CURRENT individuals — by id, then by unique label
86
+ const byId = new Map();
87
+ const byLabel = new Map(); // label -> id, or null when ambiguous
88
+ for (const i of entities.individuals) {
89
+ if (!i?.id) continue;
90
+ byId.set(i.id, i);
91
+ if (i.label) byLabel.set(i.label, byLabel.has(i.label) ? null : i.id);
92
+ }
93
+ const turns = Array.isArray(record.turns) ? record.turns : [];
94
+ const refs = [...new Set(turns.flatMap((t) => [...(t?.resolvedIds || []), ...(t?.answeredIds || [])]))];
95
+ const targets = [];
96
+ let dropped = 0;
97
+ for (const ref of refs) {
98
+ if (byId.has(ref)) { targets.push(ref); continue; }
99
+ const cand = labelFromId(ref);
100
+ const viaLabel = cand ? byLabel.get(cand) : undefined;
101
+ if (viaLabel) { targets.push(viaLabel); continue; }
102
+ dropped += 1; // vanished or ambiguous — an honest drop, never a dangling/guessed edge
103
+ }
104
+
105
+ const started = record.started || "";
106
+ const ended = record.ended || turns.at(-1)?.ts || started;
107
+ const queries = turns.map((t) => String(t?.query || "")).filter(Boolean);
108
+ const label = sessionLabel(record.id);
109
+ entities.individuals.push({
110
+ id: sid, label, class: SESSION_CLASS,
111
+ derived_from: [], mentions: [],
112
+ attributes: [
113
+ { prop: "mgx:sessionStarted", key: "started", value: started },
114
+ { prop: "mgx:sessionEnded", key: "ended", value: ended },
115
+ { prop: "mgx:sessionTurns", key: "turns", value: String(turns.length) },
116
+ ...(queries.length ? [{ prop: "mgx:sessionQueries", key: "queries", value: queries.join(" | ").slice(0, QUERIES_ATTR_CAP) }] : []),
117
+ ...(dropped ? [{ prop: "mgx:sessionDroppedEdges", key: "dropped", value: String(dropped) }] : []),
118
+ ],
119
+ });
120
+ for (const t of targets) {
121
+ group.examples.push({ subject: sid, object: t, subjectLabel: label, objectLabel: byId.get(t)?.label || t });
122
+ }
123
+ group.count = group.examples.length;
124
+
125
+ // classes[] + vocabulary[] entries appear ONLY once a session exists, so a
126
+ // session-less graph stays byte-identical to what buildEntities always produced.
127
+ const sessions = entities.individuals.filter((i) => i.class === SESSION_CLASS);
128
+ if (Array.isArray(entities.classes)) {
129
+ let cls = entities.classes.find((c) => c?.name === SESSION_CLASS);
130
+ if (!cls) { cls = { name: SESSION_CLASS, count: 0, sample: [] }; entities.classes.push(cls); }
131
+ cls.count = sessions.length;
132
+ cls.sample = sessions.slice(0, 3).map((i) => i.label);
133
+ }
134
+ if (Array.isArray(entities.vocabulary) && !entities.vocabulary.some((v) => v?.prop === ASKS_ABOUT_PROP)) {
135
+ entities.vocabulary.push({
136
+ prop: ASKS_ABOUT_PROP, predicate: ASKS_ABOUT_PREDICATE,
137
+ note: "chat Session → entity a turn resolved/answered with; runtime observation, owned (no SEON term)",
138
+ });
139
+ }
140
+ return { kept: targets.length, dropped };
141
+ }
142
+
143
+ /** Read-time append (chat.mjs, once per turn): re-read graph.json FRESH (a re-index
144
+ * may have replaced it mid-session), upsert, write back atomically. Throws on a
145
+ * missing/invalid artifact — the caller treats the append as best-effort. */
146
+ export async function appendSessionToGraph(graphFile, record) {
147
+ const entities = JSON.parse(await readFile(graphFile, "utf8"));
148
+ const res = upsertSession(entities, record);
149
+ await atomicWriteJson(graphFile, entities);
150
+ return res;
151
+ }
152
+
153
+ /** Parse one sidecar .jsonl into a session record (null if no valid header).
154
+ * Torn/partial trailing lines (a killed session) are skipped, not fatal. */
155
+ export function parseSessionJsonl(text) {
156
+ let header = null;
157
+ let ended = "";
158
+ const turns = [];
159
+ const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
160
+ for (const line of String(text).split("\n")) {
161
+ const s = line.trim();
162
+ if (!s) continue;
163
+ let rec;
164
+ try { rec = JSON.parse(s); } catch { continue; }
165
+ if (rec?.type === "session" && !header) header = rec;
166
+ else if (rec?.type === "turn") {
167
+ turns.push({
168
+ ts: String(rec.ts || ""), query: String(rec.query || ""),
169
+ resolvedIds: arr(rec.resolvedIds), answeredIds: arr(rec.answeredIds), miss: !!rec.miss,
170
+ });
171
+ } else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
172
+ }
173
+ if (!header?.id) return null;
174
+ return { id: String(header.id), started: String(header.started || ""), ended, turns };
175
+ }
176
+
177
+ /** All recorded sessions under <rootDir>/.seonix/sessions/*.jsonl, oldest first
178
+ * (uuidv7 filenames sort chronologically). Best-effort: no dir → []. */
179
+ export async function readSessionRecords(rootDir) {
180
+ const dir = join(rootDir, SESSIONS_DIR_REL);
181
+ let names;
182
+ try { names = await readdir(dir); } catch { return []; }
183
+ const records = [];
184
+ for (const name of names.filter((n) => n.endsWith(".jsonl")).sort()) {
185
+ try {
186
+ const rec = parseSessionJsonl(await readFile(join(dir, name), "utf8"));
187
+ if (rec) records.push(rec);
188
+ } catch { /* unreadable sidecar — skip, never fail an index run */ }
189
+ }
190
+ return records;
191
+ }
192
+
193
+ /** Re-index fold-in: attach every recorded session to a FRESH entities payload.
194
+ * Sessions are runtime observations, not source derivations — they are re-attached
195
+ * after the source-derived build, with every reference re-resolved against the new
196
+ * graph (upsertSession's id-then-label tiers; unresolvable → dropped + counted). */
197
+ export function foldInSessions(entities, records) {
198
+ let kept = 0;
199
+ let dropped = 0;
200
+ for (const rec of records || []) {
201
+ const r = upsertSession(entities, rec);
202
+ kept += r.kept;
203
+ dropped += r.dropped;
204
+ }
205
+ return { sessions: (records || []).length, kept, dropped };
206
+ }