@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/embed.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// embed.mjs — deterministic static-embedding lookup (model2vec potion-base-8M).
|
|
2
|
+
// PLAN_SEON_TUNING.md §7.6(5b), 2026-07-02 library-leverage review.
|
|
3
|
+
//
|
|
4
|
+
// The honest "near-LLM" locate lever: a STATIC per-token embedding table (model2vec's
|
|
5
|
+
// potion-base-8M, MIT — 29,528 WordPiece subwords × 256 fp32 dims, ~30 MB) read straight
|
|
6
|
+
// from its safetensors export, mean-pooled and L2-normalised. No ONNX runtime, no model
|
|
7
|
+
// calls, no network after the one-time fetch — pure table lookup + float arithmetic, so
|
|
8
|
+
// the same text always embeds to the same vector ($0, deterministic, offline).
|
|
9
|
+
//
|
|
10
|
+
// Dependency choice (documented per the review): the safetensors format is an 8-byte LE
|
|
11
|
+
// header length + JSON header + raw little-endian tensor bytes, and the tokenizer is a
|
|
12
|
+
// plain Bert-style WordPiece (tokenizer.json: BertNormalizer lowercase + BertPreTokenizer
|
|
13
|
+
// + greedy longest-match with "##" continuations) — both are small enough to hand-roll
|
|
14
|
+
// with node built-ins, so neither the @yarflam/potion-base-8m fallback package nor an HF
|
|
15
|
+
// tokenizer dependency is taken. Numerical intent follows model2vec's own encode (subword
|
|
16
|
+
// ids WITHOUT the [CLS]/[SEP] template, mean pool, normalize per config.json) — exact
|
|
17
|
+
// float parity with the Python lib is not claimed; determinism and rank usefulness are.
|
|
18
|
+
//
|
|
19
|
+
// The weights are NEVER committed and NEVER in the npm package: they live in the
|
|
20
|
+
// gitignored vendor/ tree (fetched by scripts/fetch-embeddings.mjs, `npm run
|
|
21
|
+
// refs:embeddings`, SHA-256-pinned like the repo's other binary artefacts). Everything
|
|
22
|
+
// here degrades gracefully — loadEmbedder() returns null when the weights dir is absent,
|
|
23
|
+
// and callers (codegraph.mjs's opt-in embedRank, scripts/rank-gate.mjs) no-op with a
|
|
24
|
+
// clear note instead of failing, so CI/tests never require the 30 MB download.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { join, dirname } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const MODEL_FILE = "model.safetensors";
|
|
31
|
+
const TOKENIZER_FILE = "tokenizer.json";
|
|
32
|
+
const CONFIG_FILE = "config.json";
|
|
33
|
+
|
|
34
|
+
/** Default artifact dir: $SEONIX_EMBED_DIR, else <repo>/vendor/embeddings/potion-base-8M
|
|
35
|
+
* (gitignored via vendor/ — the location scripts/fetch-embeddings.mjs writes). */
|
|
36
|
+
export function defaultEmbeddingsDir() {
|
|
37
|
+
if (process.env.SEONIX_EMBED_DIR) return process.env.SEONIX_EMBED_DIR;
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url)); // packages/seonix/src
|
|
39
|
+
return join(here, "..", "..", "..", "vendor", "embeddings", "potion-base-8M");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- safetensors (hand-rolled: u64le header length + JSON header + raw tensors) -------
|
|
43
|
+
|
|
44
|
+
/** Read the single 2-D F32 embedding tensor from a safetensors file →
|
|
45
|
+
* { matrix: Float32Array (row-major), rows, dim }. model2vec exports exactly one
|
|
46
|
+
* tensor named "embeddings"; any lone 2-D F32 tensor is accepted for test fixtures. */
|
|
47
|
+
function readSafetensors(file) {
|
|
48
|
+
const buf = readFileSync(file);
|
|
49
|
+
const headerLen = Number(buf.readBigUInt64LE(0));
|
|
50
|
+
const header = JSON.parse(buf.subarray(8, 8 + headerLen).toString("utf8"));
|
|
51
|
+
const name = header.embeddings
|
|
52
|
+
? "embeddings"
|
|
53
|
+
: Object.keys(header).find((k) => k !== "__metadata__" && header[k]?.shape?.length === 2);
|
|
54
|
+
const t = name && header[name];
|
|
55
|
+
if (!t) throw new Error(`no 2-D tensor in ${file}`);
|
|
56
|
+
if (t.dtype !== "F32") throw new Error(`unsupported dtype ${t.dtype} in ${file} (only F32)`);
|
|
57
|
+
const [rows, dim] = t.shape;
|
|
58
|
+
const [start, end] = t.data_offsets;
|
|
59
|
+
const bytes = buf.subarray(8 + headerLen + start, 8 + headerLen + end);
|
|
60
|
+
if (bytes.byteLength !== rows * dim * 4) throw new Error(`tensor size mismatch in ${file}`);
|
|
61
|
+
// Copy into a fresh ArrayBuffer: the slice's byteOffset inside the file buffer is not
|
|
62
|
+
// guaranteed 4-byte aligned, and Float32Array requires alignment.
|
|
63
|
+
const matrix = new Float32Array(rows * dim);
|
|
64
|
+
new Uint8Array(matrix.buffer).set(bytes);
|
|
65
|
+
return { matrix, rows, dim };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- Bert-style WordPiece tokenizer (from tokenizer.json) -----------------------------
|
|
69
|
+
|
|
70
|
+
const isPunct = (ch) => {
|
|
71
|
+
const c = ch.codePointAt(0);
|
|
72
|
+
// ASCII punctuation ranges (Bert treats these as standalone tokens) + general unicode P/S.
|
|
73
|
+
return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) ||
|
|
74
|
+
/[\p{P}\p{S}]/u.test(ch);
|
|
75
|
+
};
|
|
76
|
+
const isCjk = (ch) => {
|
|
77
|
+
const c = ch.codePointAt(0);
|
|
78
|
+
return (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
|
|
79
|
+
(c >= 0xf900 && c <= 0xfaff) || (c >= 0x20000 && c <= 0x2ffff);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function makeTokenizer(spec) {
|
|
83
|
+
if (spec?.model?.type !== "WordPiece") {
|
|
84
|
+
throw new Error(`unsupported tokenizer model "${spec?.model?.type}" (only WordPiece)`);
|
|
85
|
+
}
|
|
86
|
+
const vocab = spec.model.vocab; // token -> id
|
|
87
|
+
const contPrefix = spec.model.continuing_subword_prefix ?? "##";
|
|
88
|
+
const maxChars = spec.model.max_input_chars_per_word ?? 100;
|
|
89
|
+
const unkId = vocab[spec.model.unk_token] ?? null;
|
|
90
|
+
const lowercase = spec.normalizer?.lowercase !== false;
|
|
91
|
+
|
|
92
|
+
// BertNormalizer: clean control chars, pad CJK, lowercase (+ strip accents when lowercasing).
|
|
93
|
+
const normalize = (text) => {
|
|
94
|
+
let s = String(text).replace(/[\u0000\ufffd]/g, "").replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
|
|
95
|
+
s = [...s].map((ch) => (isCjk(ch) ? ` ${ch} ` : ch)).join("");
|
|
96
|
+
if (lowercase) s = s.toLowerCase().normalize("NFD").replace(/\p{Mn}/gu, "");
|
|
97
|
+
return s;
|
|
98
|
+
};
|
|
99
|
+
// BertPreTokenizer: split on whitespace; every punctuation char is its own word.
|
|
100
|
+
const preTokenize = (s) => {
|
|
101
|
+
const words = [];
|
|
102
|
+
for (const chunk of s.split(/\s+/)) {
|
|
103
|
+
if (!chunk) continue;
|
|
104
|
+
let cur = "";
|
|
105
|
+
for (const ch of chunk) {
|
|
106
|
+
if (isPunct(ch)) {
|
|
107
|
+
if (cur) { words.push(cur); cur = ""; }
|
|
108
|
+
words.push(ch);
|
|
109
|
+
} else cur += ch;
|
|
110
|
+
}
|
|
111
|
+
if (cur) words.push(cur);
|
|
112
|
+
}
|
|
113
|
+
return words;
|
|
114
|
+
};
|
|
115
|
+
// Greedy longest-match WordPiece per word; a word with no valid segmentation → [UNK].
|
|
116
|
+
const wordPiece = (word) => {
|
|
117
|
+
if (word.length > maxChars) return unkId == null ? [] : [unkId];
|
|
118
|
+
const ids = [];
|
|
119
|
+
let start = 0;
|
|
120
|
+
while (start < word.length) {
|
|
121
|
+
let end = word.length;
|
|
122
|
+
let id = null;
|
|
123
|
+
while (end > start) {
|
|
124
|
+
const piece = (start > 0 ? contPrefix : "") + word.slice(start, end);
|
|
125
|
+
if (vocab[piece] !== undefined) { id = vocab[piece]; break; }
|
|
126
|
+
end--;
|
|
127
|
+
}
|
|
128
|
+
if (id == null) return unkId == null ? [] : [unkId];
|
|
129
|
+
ids.push(id);
|
|
130
|
+
start = end;
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
};
|
|
134
|
+
// No [CLS]/[SEP] template — model2vec pools content subwords only.
|
|
135
|
+
return (text) => preTokenize(normalize(text)).flatMap(wordPiece);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- embedder ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const LOADED = new Map(); // dir -> embedder | null (per-process cache; weights load once)
|
|
141
|
+
|
|
142
|
+
/** Load tokenizer + embedding matrix from `dir` (default: defaultEmbeddingsDir()).
|
|
143
|
+
* Returns null — silently — when the artifacts are absent (the one-time
|
|
144
|
+
* `npm run refs:embeddings` fetch has not run): callers no-op, never fail.
|
|
145
|
+
* The returned embedder is { dim, dir, embed(text) → L2-normalised Float32Array }. */
|
|
146
|
+
export function loadEmbedder({ dir = defaultEmbeddingsDir() } = {}) {
|
|
147
|
+
if (LOADED.has(dir)) return LOADED.get(dir);
|
|
148
|
+
const modelFile = join(dir, MODEL_FILE);
|
|
149
|
+
const tokFile = join(dir, TOKENIZER_FILE);
|
|
150
|
+
if (!existsSync(modelFile) || !existsSync(tokFile)) {
|
|
151
|
+
LOADED.set(dir, null);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const { matrix, rows, dim } = readSafetensors(modelFile);
|
|
155
|
+
const tokenize = makeTokenizer(JSON.parse(readFileSync(tokFile, "utf8")));
|
|
156
|
+
let cfg = {};
|
|
157
|
+
try { cfg = JSON.parse(readFileSync(join(dir, CONFIG_FILE), "utf8")); } catch { /* optional */ }
|
|
158
|
+
const doNormalize = cfg.normalize !== false;
|
|
159
|
+
|
|
160
|
+
const embed = (text) => {
|
|
161
|
+
const ids = tokenize(text);
|
|
162
|
+
const v = new Float32Array(dim);
|
|
163
|
+
if (!ids.length) return v; // zero vector: cosine 0 against everything
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
if (id < 0 || id >= rows) continue;
|
|
166
|
+
const off = id * dim;
|
|
167
|
+
for (let j = 0; j < dim; j++) v[j] += matrix[off + j];
|
|
168
|
+
}
|
|
169
|
+
for (let j = 0; j < dim; j++) v[j] /= ids.length; // mean pool
|
|
170
|
+
if (doNormalize) {
|
|
171
|
+
let norm = 0;
|
|
172
|
+
for (let j = 0; j < dim; j++) norm += v[j] * v[j];
|
|
173
|
+
norm = Math.sqrt(norm);
|
|
174
|
+
if (norm > 0) for (let j = 0; j < dim; j++) v[j] /= norm;
|
|
175
|
+
}
|
|
176
|
+
return v;
|
|
177
|
+
};
|
|
178
|
+
const embedder = { dim, dir, embed };
|
|
179
|
+
LOADED.set(dir, embedder);
|
|
180
|
+
return embedder;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Cosine similarity. Over L2-normalised vectors this is just the dot product, but the
|
|
184
|
+
* full form is kept so unnormalised test fixtures behave. 0 when either vector is zero. */
|
|
185
|
+
export function cosine(a, b) {
|
|
186
|
+
let dot = 0, na = 0, nb = 0;
|
|
187
|
+
const n = Math.min(a.length, b.length);
|
|
188
|
+
for (let i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
189
|
+
if (na === 0 || nb === 0) return 0;
|
|
190
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
191
|
+
}
|
package/src/extract.mjs
CHANGED
|
@@ -18,14 +18,15 @@
|
|
|
18
18
|
// mgx:subclassOf Class → Class (inheritance; internal base resolved, else ext:)
|
|
19
19
|
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
22
|
-
import { dirname, join } from "node:path";
|
|
21
|
+
import { writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
22
|
+
import { basename, dirname, join, parse, resolve, sep } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import * as codegraph from "./codegraph.mjs"; // optional renderToolsCatalog (other agent owns this file)
|
|
25
25
|
import { ingestRepo, LANG_EXTS } from "./extract_lang.mjs";
|
|
26
26
|
import { loadIgnores } from "./walk.mjs";
|
|
27
27
|
import { attachProseTokens, buildProseIndex } from "./prose.mjs";
|
|
28
28
|
import { ingestSchemaDocs } from "./schema-docs.mjs";
|
|
29
|
+
import { foldInSessions, readSessionRecords } from "./sessions.mjs";
|
|
29
30
|
|
|
30
31
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
31
32
|
const AST_SCRIPT = join(here, "extract_ast.py");
|
|
@@ -194,16 +195,36 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
|
|
|
194
195
|
for (const m of modules) {
|
|
195
196
|
for (const d of m.defines || []) {
|
|
196
197
|
if (d.kind === "method" || d.kind === "attribute" || d.kind === "global") continue; // not standalone call targets
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
198
|
+
const register = (name) => {
|
|
199
|
+
if (!nameToPaths.has(name)) nameToPaths.set(name, new Set());
|
|
200
|
+
nameToPaths.get(name).add(m.path);
|
|
201
|
+
if (!nameToSymbolIds.has(name)) nameToSymbolIds.set(name, new Set());
|
|
202
|
+
nameToSymbolIds.get(name).add(fnId(m.path, d.name));
|
|
203
|
+
if (d.kind === "class") {
|
|
204
|
+
if (!classToPaths.has(name)) classToPaths.set(name, new Set());
|
|
205
|
+
classToPaths.get(name).add(m.path);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
register(d.name);
|
|
209
|
+
// Nested types (Java/C#) define dotted names like Outer.Inner — ALSO register the
|
|
210
|
+
// simple name so `new Inner()` / `extends Inner` still resolve. Same Set semantics:
|
|
211
|
+
// a second definition of the simple name makes it ambiguous → dropped, honest.
|
|
212
|
+
if (d.kind === "class" && d.name.includes(".")) {
|
|
213
|
+
const simple = lastIdent(d.name);
|
|
214
|
+
if (simple && simple !== d.name) register(simple);
|
|
204
215
|
}
|
|
205
216
|
}
|
|
206
217
|
}
|
|
218
|
+
// Resolve a class SIMPLE name at a path to the id of its (possibly nested, dotted)
|
|
219
|
+
// define — nameToSymbolIds keeps full ids, so a unique in-path match wins; an exact
|
|
220
|
+
// plain define beats a nested one; else fall back to the literal id (pre-nesting shape).
|
|
221
|
+
const classIdAt = (path, ident) => {
|
|
222
|
+
const pre = `fn:${path}#`;
|
|
223
|
+
const exact = `${pre}${ident}`;
|
|
224
|
+
const ids = [...(nameToSymbolIds.get(ident) || [])].filter((i) => i.startsWith(pre));
|
|
225
|
+
if (ids.includes(exact) || ids.length !== 1) return exact;
|
|
226
|
+
return ids[0];
|
|
227
|
+
};
|
|
207
228
|
|
|
208
229
|
// resolve a module's import candidates to internal module paths
|
|
209
230
|
const internalImports = (m) => {
|
|
@@ -270,6 +291,7 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
|
|
|
270
291
|
if (d.raises?.length) attrs.push({ prop: "seon:throwsException", key: "raises", value: list(d.raises) });
|
|
271
292
|
if (d.catches?.length) attrs.push({ prop: "seon:catchesException", key: "catches", value: list(d.catches) });
|
|
272
293
|
if (d.self_fields?.length) attrs.push({ prop: "seon:accessesField", key: "self_fields", value: list(d.self_fields) });
|
|
294
|
+
if (d.subkind) attrs.push({ prop: "seon:subKind", key: "subkind", value: String(d.subkind) });
|
|
273
295
|
if (d.is_static) attrs.push({ prop: "seon:isStatic", key: "isStatic", value: "true" });
|
|
274
296
|
if (d.is_abstract) attrs.push({ prop: "seon:isAbstract", key: "isAbstract", value: "true" });
|
|
275
297
|
if (d.is_constant) attrs.push({ prop: "seon:isConstant", key: "isConstant", value: "true" });
|
|
@@ -305,10 +327,10 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
|
|
|
305
327
|
const defs = classToPaths.get(ident);
|
|
306
328
|
let object = `ext:${ident}`;
|
|
307
329
|
if (defs && defs.has(m.path)) {
|
|
308
|
-
object =
|
|
330
|
+
object = classIdAt(m.path, ident); // same-module base wins (local name scoping), even if the name is globally ambiguous
|
|
309
331
|
} else if (defs && defs.size === 1) {
|
|
310
332
|
const targetPath = [...defs][0];
|
|
311
|
-
if (imports.has(targetPath)) object =
|
|
333
|
+
if (imports.has(targetPath)) object = classIdAt(targetPath, ident);
|
|
312
334
|
}
|
|
313
335
|
const ikey = `${oid}>${object}`;
|
|
314
336
|
if (seenInherits.has(ikey) || object === oid) continue;
|
|
@@ -376,7 +398,9 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
|
|
|
376
398
|
touchedBy.get(f).push(`git:${short}`);
|
|
377
399
|
touchedAny = true;
|
|
378
400
|
}
|
|
379
|
-
|
|
401
|
+
// !commitIds.has: a merged multi-repo commit list CAN repeat a sha (two clones of
|
|
402
|
+
// the same project indexed under different names) — one Commit individual per sha.
|
|
403
|
+
if (touchedAny && !commitIds.has(c.sha)) {
|
|
380
404
|
commitIds.add(c.sha);
|
|
381
405
|
commitIndividuals.push(commitInd(c.sha, short, c));
|
|
382
406
|
}
|
|
@@ -514,6 +538,7 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
|
|
|
514
538
|
{ prop: "seon:isStatic", note: "@staticmethod/@classmethod" },
|
|
515
539
|
{ prop: "seon:isAbstract", note: "@abstractmethod/@abstractproperty" },
|
|
516
540
|
{ prop: "seon:isConstant", note: "ALL_CAPS module global" },
|
|
541
|
+
{ prop: "seon:subKind", note: "type flavour on a Class define when not a plain class (interface/enum/struct/record); kind stays class" },
|
|
517
542
|
{ prop: "seon:hasAccessModifier", note: "visibility from leading underscore (private/protected); public omitted" },
|
|
518
543
|
{ prop: "seon:hasDoc", note: "first docstring line (capped) — one-line purpose without the body" },
|
|
519
544
|
],
|
|
@@ -591,17 +616,13 @@ function localToolsCatalog(cliPath) {
|
|
|
591
616
|
return lines.join("\n");
|
|
592
617
|
}
|
|
593
618
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
|
|
600
|
-
export async function indexRepository(repoPath, { python = process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3", generatedAt = "", ignores = true } = {}) {
|
|
619
|
+
const DEFAULT_PYTHON = () => process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3";
|
|
620
|
+
|
|
621
|
+
/** One repo's raw extraction — parsers + git, NO graph assembly. Both index modes
|
|
622
|
+
* build on this; multi-repo runs it repo-by-repo so only one repo's raw source
|
|
623
|
+
* (parser/git-log output) is in memory at a time. */
|
|
624
|
+
async function extractRepo(repoPath, { python = DEFAULT_PYTHON(), ignores = true } = {}) {
|
|
601
625
|
const t0 = Date.now();
|
|
602
|
-
// Second pass (PLAN_PROSE_INDEX.md): on by default; SEONIX_PROSE_INDEX=0 disables it
|
|
603
|
-
// (mirrors SEONIX_HISTORY_SYMBOL_DEPTH=0's disable convention above).
|
|
604
|
-
const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
|
|
605
626
|
// `.seonixignore` (opt-out: `ignores:false`, the benchmark rig's flag). The matcher
|
|
606
627
|
// prunes the in-process walkers (extract_lang) at parse time; the module filter below
|
|
607
628
|
// is the AUTHORITATIVE cut — it also covers extractors that walk on their own
|
|
@@ -625,14 +646,24 @@ export async function indexRepository(repoPath, { python = process.env.SEONIX_PY
|
|
|
625
646
|
const tHist = Date.now();
|
|
626
647
|
const symbolHistory = await runGitLogHunks(repoPath, historySymbolDepth());
|
|
627
648
|
const historyMs = Date.now() - tHist;
|
|
649
|
+
return { modules, perLang: langResult.perLang, commits, symbolHistory, baseMs, historyMs };
|
|
650
|
+
}
|
|
628
651
|
|
|
652
|
+
/** Assemble entities from (possibly merged) extractions and write the artifacts
|
|
653
|
+
* (<rootDir>/.seonix/graph.json + TOOLS.md). Shared by both index modes. */
|
|
654
|
+
async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions = [] }) {
|
|
629
655
|
const entities = buildEntities(modules, commits, { generatedAt, symbolHistory, prose: proseEnabled });
|
|
630
656
|
// Schema self-documentation (schema-docs.mjs): static, repo-independent — merges in
|
|
631
657
|
// SchemaClass/SchemaPredicate individuals + backfills entities.classes[].description
|
|
632
658
|
// and entities.vocabulary[].note, so "what does cochange mean" is answerable by the
|
|
633
659
|
// same graph traversal as any other question. Fixed-size, ~0ms marginal cost.
|
|
634
660
|
ingestSchemaDocs(entities);
|
|
635
|
-
|
|
661
|
+
// Chat sessions (sessions.mjs): sessions are runtime observations, not source
|
|
662
|
+
// derivations — they are re-attached AFTER the source-derived build, each recorded
|
|
663
|
+
// entity id re-resolved against the fresh graph (unresolvable → edge dropped and
|
|
664
|
+
// counted on the Session node, never guessed). No sessions → byte-identical output.
|
|
665
|
+
if (sessions.length) foldInSessions(entities, sessions);
|
|
666
|
+
const graphFile = join(rootDir, ".seonix", "graph.json");
|
|
636
667
|
await mkdir(dirname(graphFile), { recursive: true });
|
|
637
668
|
await writeFile(graphFile, JSON.stringify(entities));
|
|
638
669
|
|
|
@@ -641,27 +672,177 @@ export async function indexRepository(repoPath, { python = process.env.SEONIX_PY
|
|
|
641
672
|
const toolsMd = typeof codegraph.renderToolsCatalog === "function"
|
|
642
673
|
? codegraph.renderToolsCatalog(CLI_SCRIPT)
|
|
643
674
|
: localToolsCatalog(CLI_SCRIPT);
|
|
644
|
-
await writeFile(join(
|
|
675
|
+
await writeFile(join(rootDir, ".seonix", "TOOLS.md"), toolsMd);
|
|
676
|
+
return { entities, graphFile };
|
|
677
|
+
}
|
|
645
678
|
|
|
679
|
+
function buildCounts(entities, { modules, languages, commits, proseEnabled, baseMs, historyMs }) {
|
|
646
680
|
const propCount = (prop) => entities.objectProperties.find((g) => g.prop === prop)?.count ?? 0;
|
|
647
681
|
// history budget: the symbol pass should add ≤10% over base extraction time.
|
|
648
682
|
const historyPct = baseMs > 0 ? Math.round((historyMs / baseMs) * 1000) / 10 : 0;
|
|
683
|
+
return {
|
|
684
|
+
modules,
|
|
685
|
+
languages,
|
|
686
|
+
functions: entities.classes.find((c) => c.name === "Function")?.count ?? 0,
|
|
687
|
+
commits,
|
|
688
|
+
edges: entities.objectProperties.reduce((n, g) => n + g.count, 0),
|
|
689
|
+
callsSymbol: propCount("mgx:callsSymbol"),
|
|
690
|
+
touchesSymbol: propCount("mgx:touchesSymbol"),
|
|
691
|
+
historySymbolDepth: historySymbolDepth(),
|
|
692
|
+
proseEnabled,
|
|
693
|
+
proseWords: Object.keys(entities.proseIndex || {}).length,
|
|
694
|
+
baseMs,
|
|
695
|
+
historyMs,
|
|
696
|
+
historyPct,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Index a repository: parse (ast) + git log → entities → write <repo>/.seonix/graph.json
|
|
702
|
+
* (+ a TOOLS.md catalog of the cold tools). Times the base extraction vs the added
|
|
703
|
+
* symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
|
|
704
|
+
* @returns {Promise<{graphFile, counts}>}
|
|
705
|
+
*/
|
|
706
|
+
export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true } = {}) {
|
|
707
|
+
// Second pass (PLAN_PROSE_INDEX.md): on by default; SEONIX_PROSE_INDEX=0 disables it
|
|
708
|
+
// (mirrors SEONIX_HISTORY_SYMBOL_DEPTH=0's disable convention above).
|
|
709
|
+
const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
|
|
710
|
+
const { modules, perLang, commits, symbolHistory, baseMs, historyMs } = await extractRepo(repoPath, { python, ignores });
|
|
711
|
+
const sessions = await readSessionRecords(repoPath); // recorded chat sessions (.seonix/sessions/*.jsonl), [] when none
|
|
712
|
+
const { entities, graphFile } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions });
|
|
649
713
|
return {
|
|
650
714
|
graphFile,
|
|
651
|
-
counts: {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
715
|
+
counts: buildCounts(entities, { modules: modules.length, languages: perLang, commits: commits.length, proseEnabled, baseMs, historyMs }),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── multi-repository indexing ────────────────────────────────────────────────
|
|
720
|
+
// n repos → ONE merged graph at <out_root>/.seonix/. Single-path mode above is
|
|
721
|
+
// untouched (no prefix, artifacts in the repo — golden-compat guarded by
|
|
722
|
+
// test/multi-repo.test.mjs). In multi mode every module id/label/dotted gets the
|
|
723
|
+
// repo's directory basename as a leading component, so ids never collide and a
|
|
724
|
+
// reader can tell repos apart. Cross-repo callsSymbol edges arise only where a
|
|
725
|
+
// symbol name resolves uniquely across the whole merged registry (the existing
|
|
726
|
+
// Set-semantics drop ambiguous names — nothing cross-repo-special is done).
|
|
727
|
+
|
|
728
|
+
/** Deterministic repo-name prefixes for a set of (resolved, deduped) repo paths:
|
|
729
|
+
* the directory basename; when two repos share a basename the FIRST in path sort
|
|
730
|
+
* order keeps the bare name and later ones get `-2`, `-3`, … appended. */
|
|
731
|
+
export function assignRepoPrefixes(repoPaths) {
|
|
732
|
+
const map = new Map();
|
|
733
|
+
const used = new Set();
|
|
734
|
+
for (const rp of [...repoPaths].sort()) {
|
|
735
|
+
const base = basename(rp) || rp;
|
|
736
|
+
let name = base;
|
|
737
|
+
for (let n = 2; used.has(name); n += 1) name = `${base}-${n}`;
|
|
738
|
+
used.add(name);
|
|
739
|
+
map.set(rp, name);
|
|
740
|
+
}
|
|
741
|
+
return map;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Prefix one repo's raw extraction in place: module path/dotted/imports, commit
|
|
745
|
+
* file lists and symbol-history range keys all gain the repo name as a leading
|
|
746
|
+
* component (`mod:<repoName>/<relpath>`, dotted `<repoName>.<dotted>`). Imports
|
|
747
|
+
* are prefixed IDENTICALLY to dotted names so intra-repo import resolution is
|
|
748
|
+
* unchanged and two repos' equal dotted names can never cross-resolve. Commit
|
|
749
|
+
* ids (shas) stay as-is — history edges point at the prefixed module ids. */
|
|
750
|
+
export function applyRepoPrefix({ modules, commits, symbolHistory }, prefix) {
|
|
751
|
+
for (const m of modules) {
|
|
752
|
+
m.path = `${prefix}/${m.path}`;
|
|
753
|
+
if (m.dotted) m.dotted = `${prefix}.${m.dotted}`;
|
|
754
|
+
if (m.imports?.length) m.imports = m.imports.map((i) => `${prefix}.${i}`);
|
|
755
|
+
}
|
|
756
|
+
for (const c of commits) c.files = (c.files || []).map((f) => `${prefix}/${f}`);
|
|
757
|
+
for (const c of symbolHistory) {
|
|
758
|
+
c.ranges = Object.fromEntries(Object.entries(c.ranges || {}).map(([p, r]) => [`${prefix}/${p}`, r]));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Default merged-artifact root: the deepest common ancestor DIRECTORY of the repo
|
|
763
|
+
* paths (segment-wise, so /x/foo and /x/foobar meet at /x, not /x/foo). Falls back
|
|
764
|
+
* to `cwd` when the ancestor is the filesystem root. */
|
|
765
|
+
export function defaultOutRoot(repoPaths, cwd = process.cwd()) {
|
|
766
|
+
const segs = repoPaths.map((p) => resolve(p).split(sep));
|
|
767
|
+
const first = segs[0];
|
|
768
|
+
let depth = Math.min(...segs.map((s) => s.length));
|
|
769
|
+
let i = 0;
|
|
770
|
+
while (i < depth && segs.every((s) => s[i] === first[i])) i += 1;
|
|
771
|
+
const ancestor = first.slice(0, i).join(sep) || sep;
|
|
772
|
+
return ancestor === parse(ancestor).root ? cwd : ancestor;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Discovery for the estate case: every immediate child directory of `multiRoot`
|
|
776
|
+
* that carries a `.git` (dir OR file — worktrees/submodules have .git files) is a
|
|
777
|
+
* repo. Dot-dirs are ignored outright; plain child dirs without .git are returned
|
|
778
|
+
* as `skipped` so the caller can log them. */
|
|
779
|
+
export async function discoverRepos(multiRoot) {
|
|
780
|
+
const root = resolve(multiRoot);
|
|
781
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
782
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
783
|
+
const repos = [];
|
|
784
|
+
const skipped = [];
|
|
785
|
+
for (const e of entries) {
|
|
786
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
787
|
+
try {
|
|
788
|
+
await stat(join(root, e.name, ".git"));
|
|
789
|
+
repos.push(join(root, e.name));
|
|
790
|
+
} catch {
|
|
791
|
+
skipped.push(e.name);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return { repos, skipped };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Index n repositories into ONE merged graph at <outRoot>/.seonix/. Repos are
|
|
799
|
+
* extracted sequentially (only one repo's raw parser/git output held at a time;
|
|
800
|
+
* the accumulated PARSED module list is small) and merged through a single
|
|
801
|
+
* buildEntities pass, so the unique-name registry spans all repos.
|
|
802
|
+
* `log` (optional) receives one progress line per repo.
|
|
803
|
+
* @returns {Promise<{graphFile, outRoot, repos, counts}>}
|
|
804
|
+
*/
|
|
805
|
+
export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, outRoot = "", log = () => {} } = {}) {
|
|
806
|
+
const paths = [...new Set((repoPaths || []).map((p) => resolve(p)))];
|
|
807
|
+
if (!paths.length) throw new Error("indexRepositories requires at least one repo path");
|
|
808
|
+
const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
|
|
809
|
+
const prefixes = assignRepoPrefixes(paths);
|
|
810
|
+
const root = outRoot ? resolve(outRoot) : defaultOutRoot(paths);
|
|
811
|
+
|
|
812
|
+
const allModules = [];
|
|
813
|
+
const allCommits = [];
|
|
814
|
+
const allSymbolHistory = [];
|
|
815
|
+
const languages = {};
|
|
816
|
+
const repos = [];
|
|
817
|
+
let baseMs = 0;
|
|
818
|
+
let historyMs = 0;
|
|
819
|
+
for (const rp of paths) {
|
|
820
|
+
const prefix = prefixes.get(rp);
|
|
821
|
+
const r = await extractRepo(rp, { python, ignores });
|
|
822
|
+
applyRepoPrefix(r, prefix);
|
|
823
|
+
allModules.push(...r.modules);
|
|
824
|
+
allCommits.push(...r.commits);
|
|
825
|
+
allSymbolHistory.push(...r.symbolHistory);
|
|
826
|
+
baseMs += r.baseMs;
|
|
827
|
+
historyMs += r.historyMs;
|
|
828
|
+
for (const [lang, s] of Object.entries(r.perLang)) {
|
|
829
|
+
const agg = languages[lang] || (languages[lang] = { lib: s.lib, files: 0, modules: 0, symbols: 0, failures: 0, ms: 0 });
|
|
830
|
+
agg.files += s.files; agg.modules += s.modules; agg.symbols += s.symbols; agg.failures += s.failures; agg.ms += s.ms;
|
|
831
|
+
}
|
|
832
|
+
repos.push({ path: rp, prefix, modules: r.modules.length, commits: r.commits.length });
|
|
833
|
+
log(`indexed ${prefix} (${rp}): ${r.modules.length} modules, ${r.commits.length} commits in ${r.baseMs + r.historyMs}ms`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// TODO(sessions): multi-repo merges don't fold in the member repos' .seonix/sessions
|
|
837
|
+
// yet — their recorded ids would need the same repo-name prefixing as modules to
|
|
838
|
+
// re-resolve against the merged graph. Deferred; single-path fold-in is the contract.
|
|
839
|
+
const { entities, graphFile } = await assembleAndWrite(root, {
|
|
840
|
+
modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled,
|
|
841
|
+
});
|
|
842
|
+
return {
|
|
843
|
+
graphFile,
|
|
844
|
+
outRoot: root,
|
|
845
|
+
repos,
|
|
846
|
+
counts: buildCounts(entities, { modules: allModules.length, languages, commits: allCommits.length, proseEnabled, baseMs, historyMs }),
|
|
666
847
|
};
|
|
667
848
|
}
|