@polycode-projects/seonix 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -23
- package/bin/cli.mjs +71 -17
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +172 -3
- package/src/ask.mjs +703 -79
- package/src/browser.mjs +12 -6
- package/src/chat.mjs +188 -0
- package/src/codegraph.mjs +159 -4
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/prose-nlp.mjs +52 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/timeline.mjs +117 -0
- package/src/viz.mjs +200 -73
package/src/java_treesitter.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
2
|
-
//
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/schema-docs.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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 ${
|
|
442
|
+
process.stderr.write(`seonix: serving ${resolveServedTools().length} tools over ${config.graphFile}\n`);
|
|
420
443
|
}
|
package/src/sessions.mjs
ADDED
|
@@ -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
|
+
}
|