@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/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
- if (!nameToPaths.has(d.name)) nameToPaths.set(d.name, new Set());
198
- nameToPaths.get(d.name).add(m.path);
199
- if (!nameToSymbolIds.has(d.name)) nameToSymbolIds.set(d.name, new Set());
200
- nameToSymbolIds.get(d.name).add(fnId(m.path, d.name));
201
- if (d.kind === "class") {
202
- if (!classToPaths.has(d.name)) classToPaths.set(d.name, new Set());
203
- classToPaths.get(d.name).add(m.path);
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 = fnId(m.path, ident); // same-module base wins (local name scoping), even if the name is globally ambiguous
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 = fnId(targetPath, ident);
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
- if (touchedAny) {
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
- * Index a repository: parse (ast) + git log → entities → write <repo>/.seonix/graph.json
596
- * (+ a TOOLS.md catalog of the cold tools). Times the base extraction vs the added
597
- * symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
598
- * @returns {Promise<{graphFile, counts}>}
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
- const graphFile = join(repoPath, ".seonix", "graph.json");
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(repoPath, ".seonix", "TOOLS.md"), toolsMd);
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
- modules: modules.length,
653
- languages: langResult.perLang,
654
- functions: entities.classes.find((c) => c.name === "Function")?.count ?? 0,
655
- commits: commits.length,
656
- edges: entities.objectProperties.reduce((n, g) => n + g.count, 0),
657
- callsSymbol: propCount("mgx:callsSymbol"),
658
- touchesSymbol: propCount("mgx:touchesSymbol"),
659
- historySymbolDepth: historySymbolDepth(),
660
- proseEnabled,
661
- proseWords: Object.keys(entities.proseIndex || {}).length,
662
- baseMs,
663
- historyMs,
664
- historyPct,
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
  }