@polycode-projects/seonix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1313 @@
1
+ // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
2
+ // deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
3
+ // src/extract.mjs):
4
+ //
5
+ // {
6
+ // generated_at, classes: [{name, count, sample[]}],
7
+ // objectProperties: [{predicate, prop, count, examples: [{subject, object,
8
+ // subjectLabel, objectLabel}]}],
9
+ // individuals: [{id, label, class, derived_from: [ref], mentions: [{id, count}],
10
+ // attributes?: [{prop, key, value}]}],
11
+ // }
12
+ //
13
+ // Ported ≈verbatim from marginalia seon-mcp/src/codegraph.mjs (the shipped,
14
+ // tested typed-edge query layer). The only edits: provenance/attestation wording
15
+ // is code-graph-generic (git:<sha> / file:line refs, not memory-node prose), and
16
+ // a renderSearch() is added for the local, deterministic seonix_search.
17
+ //
18
+ // Edge inventory is read DYNAMICALLY from the payload (predicate verb + the closed
19
+ // `prop` token like "mg:imports"); only the kind-classifier for the impact closure
20
+ // hardcodes the relation set.
21
+
22
+ // ---- payload parsing ---------------------------------------------------------
23
+
24
+ export function parseEntities(payload) {
25
+ const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
26
+ const byId = new Map();
27
+ for (const ind of individuals) {
28
+ if (ind && ind.id) byId.set(ind.id, ind);
29
+ }
30
+ const relations = (Array.isArray(payload?.objectProperties) ? payload.objectProperties : [])
31
+ .filter((g) => g && (g.predicate || g.prop))
32
+ .map((g) => {
33
+ const edges = (Array.isArray(g.examples) ? g.examples : []).filter((e) => e && e.subject && e.object);
34
+ return {
35
+ predicate: String(g.predicate || ""),
36
+ prop: g.prop || null,
37
+ count: Number(g.count) || edges.length,
38
+ edges,
39
+ };
40
+ });
41
+ const truncated = relations
42
+ .filter((g) => g.count > g.edges.length)
43
+ .map((g) => ({ predicate: g.predicate, count: g.count, shown: g.edges.length }));
44
+ return {
45
+ individuals,
46
+ byId,
47
+ relations,
48
+ truncated,
49
+ generatedAt: payload?.generated_at || null,
50
+ };
51
+ }
52
+
53
+ // ---- relation-kind classifier (for impact + tests-coverage) -------------------
54
+
55
+ const KINDS = ["imports", "calls", "defines", "tests", "touches", "contains", "inherits", "callsSymbol", "touchesSymbol"];
56
+
57
+ // Closed prop tokens → relation kind. Primary vocabulary is SEON (se-on.org) +
58
+ // our `mgx:` extension; the legacy `mg:` tokens are kept so a stale artifact still
59
+ // classifies. Lower-cased keys.
60
+ const PROP_KIND = {
61
+ // v2.0 faithful tokens (SEONIX realign)
62
+ "mgx:importsnamespace": "imports",
63
+ "mgx:callscoarse": "calls",
64
+ "seon:declaresmethod": "defines",
65
+ "mgx:testscoverage": "tests",
66
+ "mgx:touchedbycommit": "touches",
67
+ "seon:containscodeentity": "contains",
68
+ "seon:hassupertype": "inherits",
69
+ "mgx:changecoupledwith": "cochange",
70
+ "mgx:reexports": "reexports",
71
+ // fine-grained symbol-level edges (Commit→symbol history, fn→fn in-repo calls).
72
+ // These stay SEPARATE kinds from the module-coarse "touches"/"calls" so the impact
73
+ // closure (module-coarse) is unchanged.
74
+ "mgx:touchessymbol": "touchesSymbol",
75
+ "mgx:callssymbol": "callsSymbol",
76
+ // legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
77
+ "seon:usescomplextype": "imports",
78
+ "seon:invokesmethod": "calls",
79
+ "seon:history": "touches",
80
+ "mgx:subclassof": "inherits",
81
+ "mg:imports": "imports",
82
+ "mg:calls": "calls",
83
+ "mg:defines": "defines",
84
+ "mg:tests": "tests",
85
+ "mg:touches": "touches",
86
+ };
87
+
88
+ export function relationKind(group) {
89
+ const prop = String(group?.prop || "").toLowerCase();
90
+ if (PROP_KIND[prop]) return PROP_KIND[prop];
91
+ const pred = String(group?.predicate || "").toLowerCase();
92
+ // symbol-granular fallbacks first, so a near-miss token name still classifies to the
93
+ // fine-grained kind rather than collapsing to module-coarse calls/touches.
94
+ if (/symbol/.test(pred)) {
95
+ if (/\b(call|invoke)/.test(pred)) return "callsSymbol";
96
+ if (/(touch|chang|modif)/.test(pred)) return "touchesSymbol";
97
+ }
98
+ if (/\bimport/.test(pred)) return "imports";
99
+ if (/\b(call|invoke)/.test(pred)) return "calls";
100
+ if (/\b(define|export|declare)/.test(pred)) return "defines";
101
+ if (/\b(test|cover)/.test(pred)) return "tests";
102
+ if (/\b(touch|chang|modif)/.test(pred)) return "touches";
103
+ if (/\bcontain/.test(pred)) return "contains";
104
+ if (/\b(inherit|subclass|extend|specializ)/.test(pred)) return "inherits";
105
+ return null;
106
+ }
107
+
108
+ // ---- symbol resolution (exact → normalised path → substring) ------------------
109
+
110
+ function normPath(s) {
111
+ return String(s || "")
112
+ .trim()
113
+ .toLowerCase()
114
+ .replace(/^\.\//, "")
115
+ .replace(/^\//, "");
116
+ }
117
+
118
+ function basename(p) {
119
+ const parts = normPath(p).split("/");
120
+ return parts[parts.length - 1];
121
+ }
122
+
123
+ // Attestation: a ref prefixed `git:` (a commit that touched the entity) counts as
124
+ // one mention, so better-attested (more-churned) entities rank/render ahead of
125
+ // untouched ones even before per-node mention counts exist.
126
+ const isProvRef = (r) => /^(git|turn):/.test(String(r || ""));
127
+
128
+ export function turnRefCount(ind) {
129
+ return (ind?.derived_from || []).filter(isProvRef).length;
130
+ }
131
+
132
+ export function mentionTotal(ind) {
133
+ const fromMentions = (ind?.mentions || []).reduce((n, m) => n + (Number(m?.count) || 0), 0);
134
+ return fromMentions + turnRefCount(ind);
135
+ }
136
+
137
+ /**
138
+ * Rank individuals against a symbol. Tiers:
139
+ * 100 exact (label or id, case-insensitive)
140
+ * 80 normalised path (path suffix / basename / extension-stripped basename)
141
+ * 50 substring (label contains symbol), minus a length penalty
142
+ * Ties break on mention total (better-attested first), then label length.
143
+ * Returns { match, candidates } — candidates are the runners-up (≤4).
144
+ */
145
+ export function resolveSymbol(graph, symbol) {
146
+ const s = normPath(symbol);
147
+ if (!s) return { match: null, candidates: [] };
148
+ const sBase = basename(s);
149
+ const scored = [];
150
+ for (const ind of graph.individuals) {
151
+ const label = normPath(ind.label);
152
+ const id = String(ind.id || "").toLowerCase();
153
+ let score = 0;
154
+ if (label === s || id === s) score = 100;
155
+ else if (
156
+ label.endsWith(`/${s}`) ||
157
+ basename(label) === sBase ||
158
+ basename(label).replace(/\.[a-z]+$/, "") === sBase
159
+ )
160
+ score = 80;
161
+ else if (label.includes(s)) score = Math.max(10, 50 - (label.length - s.length));
162
+ if (score > 0) scored.push({ ind, score });
163
+ }
164
+ scored.sort(
165
+ (a, b) =>
166
+ b.score - a.score ||
167
+ mentionTotal(b.ind) - mentionTotal(a.ind) ||
168
+ String(a.ind.label).length - String(b.ind.label).length,
169
+ );
170
+ return {
171
+ match: scored[0]?.ind || null,
172
+ candidates: scored.slice(1, 5).map((x) => x.ind),
173
+ };
174
+ }
175
+
176
+ // ---- source site (for seonix_snippet) -------------------------------------------
177
+
178
+ /** Parse a Function/Class individual's `site` attribute ("path:start[-end]") into
179
+ * {path, start, end}, or null if it has none (e.g. a Module). Pure. */
180
+ export function siteOf(ind) {
181
+ const a = (ind?.attributes || []).find((x) => x.key === "site");
182
+ if (!a) return null;
183
+ const m = String(a.value).match(/^(.*):(\d+)(?:-(\d+))?$/);
184
+ if (!m) return null;
185
+ return { path: m[1], start: Number(m[2]), end: m[3] ? Number(m[3]) : Number(m[2]) };
186
+ }
187
+
188
+ // ---- describe ------------------------------------------------------------------
189
+
190
+ function edgesFor(graph, id) {
191
+ const out = [];
192
+ const incoming = [];
193
+ for (const g of graph.relations) {
194
+ const outgoing = g.edges.filter((e) => e.subject === id);
195
+ const inbound = g.edges.filter((e) => e.object === id);
196
+ if (outgoing.length) out.push({ group: g, edges: outgoing });
197
+ if (inbound.length) incoming.push({ group: g, edges: inbound });
198
+ }
199
+ return { out, incoming };
200
+ }
201
+
202
+ function relLabel(g) {
203
+ return g.prop ? `${g.predicate} [${g.prop}]` : g.predicate;
204
+ }
205
+
206
+ // Bounded list rendering — token efficiency is the whole point of the graph, so
207
+ // hub entities must never dump hundreds of edges. Show the first `n`, then a
208
+ // "+K more" tail with the true count.
209
+ function capJoin(items, n, sep = ", ") {
210
+ if (items.length <= n) return items.join(sep);
211
+ return items.slice(0, n).join(sep) + `, +${items.length - n} more`;
212
+ }
213
+
214
+ const DESCRIBE_EDGE_CAP = 30;
215
+ const PROV_CAP = 8;
216
+
217
+ /** Compact plain-text description of one individual — for an agent consumer. */
218
+ export function renderDescribe(graph, ind, { candidates = [] } = {}) {
219
+ const lines = [];
220
+ lines.push(`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`);
221
+
222
+ const refs = (ind.derived_from || []).filter(isProvRef);
223
+ if (refs.length) lines.push(`attestation: touched by ${refs.length} commit(s)`);
224
+
225
+ for (const a of ind.attributes || []) {
226
+ lines.push(`attribute: ${a.key} = ${a.value}${a.prop ? ` [${a.prop}]` : ""}`);
227
+ }
228
+
229
+ const { out, incoming } = edgesFor(graph, ind.id);
230
+ if (!out.length && !incoming.length) {
231
+ lines.push("edges: none in the current artifact");
232
+ } else {
233
+ lines.push("edges:");
234
+ for (const { group, edges } of out) {
235
+ lines.push(` ${relLabel(group)} (${edges.length}) → ${capJoin(edges.map((e) => e.objectLabel || e.object), DESCRIBE_EDGE_CAP)}`);
236
+ }
237
+ for (const { group, edges } of incoming) {
238
+ lines.push(` ← ${relLabel(group)} (${edges.length}) by ${capJoin(edges.map((e) => e.subjectLabel || e.subject), DESCRIBE_EDGE_CAP)}`);
239
+ }
240
+ }
241
+
242
+ const prov = (ind.derived_from || []);
243
+ if (prov.length) {
244
+ lines.push(`provenance: ${capJoin(prov, PROV_CAP)}`);
245
+ }
246
+
247
+ if (candidates.length) {
248
+ lines.push(`other matches: ${candidates.map((c) => `${c.label} (${c.class})`).join(", ")}`);
249
+ }
250
+ if (graph.truncated.length) {
251
+ lines.push(truncationNote(graph));
252
+ }
253
+ return lines.join("\n");
254
+ }
255
+
256
+ function truncationNote(graph) {
257
+ const list = graph.truncated.map((t) => `${t.predicate} (${t.shown}/${t.count})`).join(", ");
258
+ return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
259
+ }
260
+
261
+ // ---- impact (transitive reverse closure over imports/calls) ---------------------
262
+
263
+ /**
264
+ * BFS the REVERSE of imports/calls edges from `ind` — "what would break".
265
+ * Diamonds collapse (a node appears once, at its shortest depth); cycles
266
+ * terminate via the visited set. Each dependent carries the via-predicate and
267
+ * the test modules covering it (subjects of tests-kind edges pointing at it).
268
+ */
269
+ export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
270
+ const dependents = new Map();
271
+ const coveredBy = new Map(); // moduleId → [test labels]
272
+ for (const g of graph.relations) {
273
+ const kind = relationKind(g);
274
+ if (kind === "imports" || kind === "calls") {
275
+ for (const e of g.edges) {
276
+ if (!dependents.has(e.object)) dependents.set(e.object, []);
277
+ dependents.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject, via: g.predicate });
278
+ }
279
+ } else if (kind === "tests") {
280
+ for (const e of g.edges) {
281
+ if (!coveredBy.has(e.object)) coveredBy.set(e.object, []);
282
+ coveredBy.get(e.object).push(e.subjectLabel || e.subject);
283
+ }
284
+ }
285
+ }
286
+
287
+ const levels = []; // [[{id, label, via, tests[]}], …] indexed by depth-1
288
+ const visited = new Set([ind.id]);
289
+ let frontier = [ind.id];
290
+ for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
291
+ const next = [];
292
+ const level = [];
293
+ for (const id of frontier) {
294
+ for (const dep of dependents.get(id) || []) {
295
+ if (visited.has(dep.id)) continue;
296
+ visited.add(dep.id);
297
+ level.push({ ...dep, tests: coveredBy.get(dep.id) || [] });
298
+ next.push(dep.id);
299
+ }
300
+ }
301
+ if (level.length) {
302
+ level.sort((a, b) => String(a.label).localeCompare(String(b.label)));
303
+ levels.push(level);
304
+ }
305
+ frontier = next;
306
+ }
307
+ return levels;
308
+ }
309
+
310
+ const IMPACT_DEPTHS_LISTED = 2; // list members for the first N depths; deeper = counts only
311
+ const IMPACT_PER_DEPTH = 25; // members listed per depth
312
+ const IMPACT_TESTS_PER_DEP = 3; // covering tests listed per dependent
313
+
314
+ export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
315
+ const levels = impactClosure(graph, ind, { maxDepth });
316
+ const lines = [`Impact of changing ${ind.label} (reverse closure over imports/calls edges):`];
317
+ if (!levels.length) {
318
+ lines.push("no dependents found in the current artifact — nothing imports or calls it (or its edges are not in the extracted graph yet).");
319
+ }
320
+ const totalCount = levels.reduce((n, l) => n + l.length, 0);
321
+ // Headline first so the magnitude is clear even when the lists are capped.
322
+ if (levels.length) {
323
+ lines.push(`total: ${totalCount} dependent(s) across ${levels.length} depth level(s) (lists capped for brevity).`);
324
+ }
325
+ levels.forEach((level, i) => {
326
+ if (i >= IMPACT_DEPTHS_LISTED) {
327
+ lines.push(`depth ${i + 1}: ${level.length} more dependent(s) (not listed)`);
328
+ return;
329
+ }
330
+ lines.push(i === 0 ? `depth 1 (${level.length} direct dependents):` : `depth ${i + 1} (${level.length}):`);
331
+ for (const dep of level.slice(0, IMPACT_PER_DEPTH)) {
332
+ const tests = dep.tests.length
333
+ ? `tests: ${capJoin(dep.tests, IMPACT_TESTS_PER_DEP)}`
334
+ : "tests: none recorded";
335
+ lines.push(` - ${dep.label} (${dep.via} it) — ${tests}`);
336
+ }
337
+ if (level.length > IMPACT_PER_DEPTH) lines.push(` …+${level.length - IMPACT_PER_DEPTH} more at depth ${i + 1}`);
338
+ });
339
+ const truncatedStructural = graph.truncated.filter((t) => {
340
+ const kind = relationKind({ predicate: t.predicate });
341
+ return kind === "imports" || kind === "calls" || kind === "tests";
342
+ });
343
+ if (truncatedStructural.length) {
344
+ lines.push(
345
+ "warning: partial edge lists (" +
346
+ truncatedStructural.map((t) => `${t.predicate}: ${t.shown}/${t.count}`).join(", ") +
347
+ ") — this closure may be missing edges. Cross-check critical results with seonix_search.",
348
+ );
349
+ }
350
+ return lines.join("\n");
351
+ }
352
+
353
+ // ---- search (local, deterministic lexical lookup) ------------------------------
354
+
355
+ /** Index subjectId → [defined symbol labels] from the defines relation, once. */
356
+ function definesIndex(graph) {
357
+ const idx = new Map();
358
+ for (const g of graph.relations) {
359
+ if (relationKind(g) !== "defines") continue;
360
+ for (const e of g.edges) {
361
+ if (!idx.has(e.subject)) idx.set(e.subject, []);
362
+ idx.get(e.subject).push(e.objectLabel || e.object);
363
+ }
364
+ }
365
+ return idx;
366
+ }
367
+
368
+ /**
369
+ * Local, deterministic free-text lookup over the typed graph — the offline
370
+ * replacement for marginalia's LLM-backed A2A seonix_search. Finds the MODULE
371
+ * where code lives ("where do template filters / validators live?"): scores
372
+ * modules by query-token hits in the path (strong) plus the count of DEFINED
373
+ * SYMBOLS whose name matches a token (capped so a giant module can't dominate),
374
+ * with a penalty for test modules. Renders each hit compactly with the matching
375
+ * symbols, so the agent can jump straight to seonix_describe. No model calls.
376
+ */
377
+ const SEARCH_LIMIT = 10;
378
+ const SEARCH_SYMBOLS_SHOWN = 8;
379
+ // B012 locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
380
+ // statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
381
+ // token by rarity across modules (inverse module-frequency) so the distinctive term decides. Match
382
+ // identifier COMPONENTS (boundary-aware) so "text" hits utils/text.py but NOT "ci<text>". An EXACT
383
+ // defined-symbol-name hit is the strongest "the code lives here" signal. Deterministic; no models.
384
+ const PATH_W = 3; // token == a path component (django/utils/<text>.py)
385
+ const SYM_W = 2; // token == a component of a defined symbol name
386
+ const EXACT_W = 5; // token == a whole defined symbol name (strongest locate signal)
387
+ const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits count, so a giant
388
+ // bag-of-symbols module (e.g. db/backends features) can't accrete noise
389
+ const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
390
+ const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
391
+ const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
392
+
393
+ /** Split a lowercased path label into boundary components: django/utils/text.py →
394
+ * {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
395
+ function pathComponents(labelLc) {
396
+ return new Set(labelLc.split(/[^a-z0-9]+/).filter(Boolean));
397
+ }
398
+ /** Split an identifier into lowercased components across snake_case AND camelCase boundaries:
399
+ * get_text_list → {get,text,list}; TruncatorLines → {truncator,lines}. */
400
+ function identComponents(name) {
401
+ return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
402
+ }
403
+
404
+ /** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
405
+ * IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
406
+ * swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
407
+ * + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
408
+ * matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
409
+ function scoreModules(graph, tokens) {
410
+ const defIdx = definesIndex(graph);
411
+ // Precompute each module's path components + defined-symbol exact/component sets, once.
412
+ const modules = [];
413
+ for (const ind of graph.individuals) {
414
+ if ((ind.class || "") !== "Module") continue; // "where does this live" → modules
415
+ const label = String(ind.label);
416
+ const labelLc = label.toLowerCase();
417
+ const defines = defIdx.get(ind.id) || [];
418
+ const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
419
+ const symComps = new Set();
420
+ for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
421
+ modules.push({ ind, label, labelLc, defines, symSet, symComps });
422
+ }
423
+ const N = modules.length || 1;
424
+ // Inverse module-frequency: a token in many modules carries little locating signal; a rare one
425
+ // decides. df = modules where the token appears in the path (substring — keeps "filter" matching
426
+ // "defaultfilters"), as a symbol component, or as an exact symbol name. A loose path substring like
427
+ // "text" that hits many modules therefore earns a low weight, so "ci<text>" can't beat utils/text.py.
428
+ // idf = log(1 + N/(1+df)) → ~0 for ubiquitous tokens, large for rare ones.
429
+ const idf = new Map();
430
+ for (const t of tokens) {
431
+ if (idf.has(t)) continue;
432
+ let df = 0;
433
+ for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
434
+ idf.set(t, Math.log(1 + N / (1 + df)));
435
+ }
436
+ const scored = [];
437
+ for (const m of modules) {
438
+ let exactScore = 0, pathScore = 0, matchCount = 0;
439
+ const compWeights = []; // weak symbol-component hits, capped below so big modules can't run away
440
+ for (const t of tokens) {
441
+ const w = idf.get(t) || 0;
442
+ if (!w) continue;
443
+ if (m.symSet.has(t)) { exactScore += w * EXACT_W; matchCount++; } // exact defined-symbol name
444
+ else if (m.symComps.has(t)) { compWeights.push(w); matchCount++; } // a component of a symbol name
445
+ if (m.labelLc.includes(t)) pathScore += w * PATH_W; // path substring (IDF-tamed)
446
+ }
447
+ compWeights.sort((a, b) => b - a);
448
+ let symScore = 0;
449
+ for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
450
+ let score = exactScore + pathScore + symScore;
451
+ if (!score) continue;
452
+ if (isTestLabel(m.label.toLowerCase())) score *= 0.4; // source first; tests still discoverable
453
+ const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
454
+ const density = m.defines.length ? matchCount / m.defines.length : 0;
455
+ scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
456
+ }
457
+ // Import-graph proximity (rescaled): a matched module that imports / is imported by a
458
+ // STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
459
+ // (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
460
+ if (scored.length > 1) {
461
+ const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
462
+ const adj = new Map();
463
+ for (const e of edgesOfKind(graph, "imports")) {
464
+ if (!baseById.has(e.subject) && !baseById.has(e.object)) continue;
465
+ if (!adj.has(e.subject)) adj.set(e.subject, new Set());
466
+ if (!adj.has(e.object)) adj.set(e.object, new Set());
467
+ adj.get(e.subject).add(e.object);
468
+ adj.get(e.object).add(e.subject);
469
+ }
470
+ for (const s of scored) {
471
+ let bestNeighbor = 0;
472
+ for (const nid of adj.get(s.ind.id) || []) bestNeighbor = Math.max(bestNeighbor, baseById.get(nid) || 0);
473
+ s.score += Math.min(bestNeighbor * PROX_FRAC, s.score * PROX_CAP_FRAC);
474
+ }
475
+ }
476
+ // Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
477
+ scored.sort((a, b) => b.score - a.score || b.density - a.density || a.defineCount - b.defineCount || String(a.ind.label).length - String(b.ind.label).length);
478
+ return scored;
479
+ }
480
+
481
+ /** TUNING #3: the ranked module list as plain `{path, score}` (highest-first), using the SAME
482
+ * ranking renderSearch uses (path + symbol + exact-symbol + import-proximity). Lets the rig
483
+ * read the score GAP between rank-1 and rank-2 (which the text renderer hides) so it can keep
484
+ * rank-2 only when it is close. Pure; deterministic. */
485
+ export function searchModulesRanked(graph, query) {
486
+ const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
487
+ if (!tokens.length) return [];
488
+ return scoreModules(graph, tokens).map((s) => ({ path: String(s.ind.label), score: s.score }));
489
+ }
490
+
491
+ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
492
+ const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
493
+ const wantKind = String(kind || "").trim().toLowerCase();
494
+ const decFilter = String(decorator || "").trim().toLowerCase();
495
+ let nameRe = null;
496
+ if (name) {
497
+ try { nameRe = new RegExp(name, "i"); } catch { return `invalid name pattern: ${name}`; }
498
+ }
499
+ // kind= switches to symbol search (functions/classes/methods/attributes); the
500
+ // default (no kind) keeps the module "where does this live" search unchanged.
501
+ if (wantKind && wantKind !== "module") {
502
+ return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
503
+ }
504
+ if (!tokens.length && !nameRe && !decFilter) return "empty query";
505
+ const scored = scoreModules(graph, tokens);
506
+ if (!scored.length) {
507
+ return `no module matches "${query}". Try broader keywords, or seonix_describe <path> if you know where it lives.`;
508
+ }
509
+ const hits = scored.slice(0, limit);
510
+ const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
511
+ for (const { ind, defineCount, matching } of hits) {
512
+ const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
513
+ lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
514
+ }
515
+ lines.push("Then seonix_describe <path> for the full sibling list + typed edges, or seonix_impact <path> for dependents.");
516
+ return lines.join("\n");
517
+ }
518
+
519
+ // ---- §9 read-replacing tools (members / inheritance / architecture / coverage /
520
+ // history / call neighbours). Each answers ONE question in one compact call so
521
+ // the agent need not Read/Grep. All keep the bounded-output discipline. -------
522
+
523
+ /** All edges whose relation classifies to `kind`, flattened across relation groups. */
524
+ function edgesOfKind(graph, kind) {
525
+ const out = [];
526
+ for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
527
+ return out;
528
+ }
529
+
530
+ /** The module id an individual belongs to (itself if a Module; via its site span,
531
+ * else parsed from an `fn:<path>#name` id). Null if it cannot be mapped. */
532
+ function moduleIdOf(graph, ind) {
533
+ if ((ind?.class || "") === "Module") return ind.id;
534
+ const site = siteOf(ind);
535
+ if (site) return `mod:${site.path}`;
536
+ const m = String(ind?.id || "").match(/^fn:(.+)#/);
537
+ return m ? `mod:${m[1]}` : null;
538
+ }
539
+
540
+ function spanTag(site) {
541
+ if (!site) return "";
542
+ const s = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
543
+ return ` [${site.path}:${s}]`;
544
+ }
545
+
546
+ function decoratorOf(ind) {
547
+ return (ind?.attributes || []).find((a) => a.key === "decorators")?.value || "";
548
+ }
549
+
550
+ const MEMBERS_CAP = 40;
551
+ const SUBCLASS_CAP = 40;
552
+ const CALL_CAP = 30;
553
+
554
+ /** A class's methods + attributes (with sites/decorators) in one slice — replaces
555
+ * reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
556
+ export function renderMembers(graph, ind) {
557
+ const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
558
+ const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
559
+ if (!contains.length) {
560
+ lines.push("members: none recorded (empty class, or members not in the extracted graph). Use seonix_describe for its edges.");
561
+ return lines.join("\n");
562
+ }
563
+ const methods = [];
564
+ const attrs = [];
565
+ for (const e of contains) {
566
+ const member = graph.byId.get(e.object);
567
+ const where = spanTag(member ? siteOf(member) : null);
568
+ const dec = member ? decoratorOf(member) : "";
569
+ const entry = `${e.objectLabel || e.object}${where}${dec ? ` @${dec}` : ""}`;
570
+ ((member?.class || "") === "Attribute" ? attrs : methods).push(entry);
571
+ }
572
+ if (methods.length) lines.push(`methods (${methods.length}): ${capJoin(methods, MEMBERS_CAP)}`);
573
+ if (attrs.length) lines.push(`attributes (${attrs.length}): ${capJoin(attrs, MEMBERS_CAP)}`);
574
+ lines.push("Use seonix_snippet <Class.member> for an exact body.");
575
+ return lines.join("\n");
576
+ }
577
+
578
+ const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)?.value || "";
579
+
580
+ /** The mechanical-enrichment signature of a symbol in ONE compact block — params,
581
+ * return annotation, raises/catches, self-fields, flags, decorators, one-line doc —
582
+ * so the agent gets the API surface without reading the body. Deterministic ast facts
583
+ * (kept OUT of seonix_context's lean bundle; this is the targeted tool for them). */
584
+ export function renderSignature(graph, ind) {
585
+ const site = siteOf(ind);
586
+ const lines = [`${ind.label} — ${ind.class || "Entity"}${spanTag(site)}`];
587
+ const params = attrVal(ind, "params");
588
+ const returns = attrVal(ind, "returns");
589
+ if (params || returns || (ind.class || "") === "Method" || (ind.class || "") === "Function") {
590
+ lines.push(`signature: ${ind.label}(${params})${returns ? ` -> ${returns}` : ""}`);
591
+ }
592
+ const flags = [];
593
+ if (attrVal(ind, "isStatic")) flags.push("static");
594
+ if (attrVal(ind, "isAbstract")) flags.push("abstract");
595
+ if (attrVal(ind, "isConstant")) flags.push("constant");
596
+ const vis = attrVal(ind, "visibility");
597
+ if (vis) flags.push(vis);
598
+ if (flags.length) lines.push(`flags: ${flags.join(", ")}`);
599
+ const dec = decoratorOf(ind);
600
+ if (dec) lines.push(`decorators: @${dec.split(", ").join(", @")}`);
601
+ const raises = attrVal(ind, "raises");
602
+ if (raises) lines.push(`raises: ${raises}`);
603
+ const catches = attrVal(ind, "catches");
604
+ if (catches) lines.push(`catches: ${catches}`);
605
+ const fields = attrVal(ind, "self_fields");
606
+ if (fields) lines.push(`self fields: ${fields}`);
607
+ const value = attrVal(ind, "value");
608
+ if (value) lines.push(`value: ${value}`);
609
+ const doc = attrVal(ind, "doc");
610
+ if (doc) lines.push(`doc: ${doc}`);
611
+ if (lines.length === 1) lines.push("(no signature detail recorded for this symbol — likely a module or attribute; use seonix_snippet for its source.)");
612
+ lines.push("Use seonix_snippet for the exact body.");
613
+ return lines.join("\n");
614
+ }
615
+
616
+ /** Forward bases + the transitive reverse inheritance closure (who extends this) —
617
+ * replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
618
+ export function renderSubclasses(graph, ind) {
619
+ const inherits = edgesOfKind(graph, "inherits");
620
+ const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
621
+ const childrenOf = new Map();
622
+ for (const e of inherits) {
623
+ if (!childrenOf.has(e.object)) childrenOf.set(e.object, []);
624
+ childrenOf.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject });
625
+ }
626
+ const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
627
+ lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
628
+ const visited = new Set([ind.id]);
629
+ const levels = [];
630
+ let frontier = [ind.id];
631
+ for (let depth = 1; depth <= 8 && frontier.length; depth += 1) {
632
+ const next = [];
633
+ const level = [];
634
+ for (const id of frontier) {
635
+ for (const c of childrenOf.get(id) || []) {
636
+ if (visited.has(c.id)) continue;
637
+ visited.add(c.id);
638
+ level.push(c.label);
639
+ next.push(c.id);
640
+ }
641
+ }
642
+ if (level.length) {
643
+ level.sort((a, b) => String(a).localeCompare(String(b)));
644
+ levels.push(level);
645
+ }
646
+ frontier = next;
647
+ }
648
+ const total = levels.reduce((n, l) => n + l.length, 0);
649
+ if (!total) {
650
+ lines.push("subclasses: none recorded — nothing extends it in the extracted graph.");
651
+ } else {
652
+ lines.push(`subclasses: ${total} total across ${levels.length} level(s).`);
653
+ levels.forEach((l, i) => lines.push(` depth ${i + 1} (${l.length}): ${capJoin(l, SUBCLASS_CAP)}`));
654
+ }
655
+ return lines.join("\n");
656
+ }
657
+
658
+ const ARCH_PKG_CAP = 25;
659
+ const ARCH_HUB_CAP = 15;
660
+
661
+ /** Package/module tree + the most-imported (hub) modules — replaces reading the dir
662
+ * tree and many files to learn the shape. Optional `pkg` prefix scopes it. */
663
+ export function renderArchitecture(graph, { pkg = "" } = {}) {
664
+ const norm = normPath(pkg);
665
+ const modules = graph.individuals.filter(
666
+ (i) => (i.class || "") === "Module" && (!norm || normPath(i.label).startsWith(norm)),
667
+ );
668
+ if (!modules.length) return norm ? `no modules under "${pkg}".` : "no modules in the graph.";
669
+ const pkgCount = new Map();
670
+ for (const m of modules) {
671
+ const dir = m.label.includes("/") ? m.label.slice(0, m.label.lastIndexOf("/")) : "(root)";
672
+ pkgCount.set(dir, (pkgCount.get(dir) || 0) + 1);
673
+ }
674
+ const inDeg = new Map();
675
+ for (const e of edgesOfKind(graph, "imports")) inDeg.set(e.object, (inDeg.get(e.object) || 0) + 1);
676
+ const modSet = new Set(modules.map((m) => m.id));
677
+ const hubs = [...inDeg.entries()]
678
+ .filter(([id]) => modSet.has(id))
679
+ .sort((a, b) => b[1] - a[1])
680
+ .slice(0, ARCH_HUB_CAP)
681
+ .map(([id, n]) => `${graph.byId.get(id)?.label || id} (${n} importers)`);
682
+ const pkgs = [...pkgCount.entries()].sort((a, b) => b[1] - a[1]);
683
+ const lines = [`Architecture${norm ? ` of ${pkg}` : ""}: ${modules.length} module(s) in ${pkgs.length} package(s).`];
684
+ lines.push(`packages (by module count): ${capJoin(pkgs.map(([d, n]) => `${d} (${n})`), ARCH_PKG_CAP)}`);
685
+ lines.push(hubs.length ? `hub modules (most imported): ${hubs.join(", ")}` : "hub modules: none (no internal imports recorded).");
686
+ return lines.join("\n");
687
+ }
688
+
689
+ const COVERAGE_CAP = 40;
690
+
691
+ /** The test modules covering a symbol/module — from the `tests` (mgx:testsCoverage)
692
+ * relation. Replaces grepping `tests/` for who imports the target. */
693
+ export function renderTestsFor(graph, ind) {
694
+ const modId = moduleIdOf(graph, ind);
695
+ if (!modId) return `cannot map ${ind.label} to a module.`;
696
+ const modLabel = graph.byId.get(modId)?.label || modId;
697
+ const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
698
+ if (!tests.length) return `${modLabel}: no covering tests recorded (no test module imports it).`;
699
+ return `${modLabel}: covered by ${tests.length} test module(s):\n ${capJoin(tests, COVERAGE_CAP, "\n ")}`;
700
+ }
701
+
702
+ /** Source modules with no covering test module — a coverage gap view. Test
703
+ * modules (subjects of test edges, or test-named paths) are excluded. */
704
+ export function renderUntested(graph) {
705
+ const covered = new Set();
706
+ const testModules = new Set();
707
+ for (const e of edgesOfKind(graph, "tests")) {
708
+ covered.add(e.object);
709
+ testModules.add(e.subject);
710
+ }
711
+ const untested = graph.individuals
712
+ .filter(
713
+ (i) =>
714
+ (i.class || "") === "Module" &&
715
+ !testModules.has(i.id) &&
716
+ !isTestLabel(String(i.label).toLowerCase()) &&
717
+ !covered.has(i.id),
718
+ )
719
+ .map((i) => i.label)
720
+ .sort();
721
+ if (!untested.length) return "every source module has at least one covering test module.";
722
+ return `${untested.length} source module(s) with no covering test module:\n ${capJoin(untested, COVERAGE_CAP, "\n ")}`;
723
+ }
724
+
725
+ const HISTORY_CAP = 15;
726
+
727
+ /** Recent commits that touched a symbol's module — from `touches` (seon:history).
728
+ * Replaces `git log -- <file>`. Commits are listed newest-first (git-log order). */
729
+ export function renderHistory(graph, ind) {
730
+ const modId = moduleIdOf(graph, ind);
731
+ if (!modId) return `cannot map ${ind.label} to a module.`;
732
+ const modLabel = graph.byId.get(modId)?.label || modId;
733
+ const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject);
734
+ if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
735
+ return `${modLabel}: touched by ${commits.length} recent commit(s): ${capJoin(commits, HISTORY_CAP)}`;
736
+ }
737
+
738
+ /** Modules that call into the target's module (one hop over `calls`). */
739
+ export function renderCallers(graph, ind) {
740
+ const modId = moduleIdOf(graph, ind);
741
+ if (!modId) return `cannot map ${ind.label} to a module.`;
742
+ const modLabel = graph.byId.get(modId)?.label || modId;
743
+ const callers = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
744
+ if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try seonix_impact for the full reverse closure.`;
745
+ return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
746
+ }
747
+
748
+ /** Modules the target's module calls into (one hop over `calls`). */
749
+ export function renderCallees(graph, ind) {
750
+ const modId = moduleIdOf(graph, ind);
751
+ if (!modId) return `cannot map ${ind.label} to a module.`;
752
+ const modLabel = graph.byId.get(modId)?.label || modId;
753
+ const callees = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object))];
754
+ if (!callees.length) return `${modLabel}: no recorded callees.`;
755
+ return `${modLabel} — calls into ${callees.length} module(s):\n ${capJoin(callees, CALL_CAP, "\n ")}`;
756
+ }
757
+
758
+ // ---- fine-grained in-repo calls (fn→fn, `callsSymbol`) ---------------------------
759
+
760
+ const CALL_HINT_CAP = 8;
761
+
762
+ /** Format one fn→fn callee edge as `name [path:line]` (path:line from the callee's site). */
763
+ function calleeRef(graph, e) {
764
+ const callee = graph.byId.get(e.object);
765
+ const cs = callee ? siteOf(callee) : null;
766
+ return `${e.objectLabel || callee?.label || e.object}${cs ? ` [${cs.path}:${cs.start}]` : ""}`;
767
+ }
768
+
769
+ /** One-line "calls in-repo: name [path:line], …" hint for a function — appended to
770
+ * seonix_snippet and the seonix_context exemplar body so the agent sees the symbol's
771
+ * in-repo call dependencies inline. Empty string when it calls nothing in-repo. Pure. */
772
+ export function callHint(graph, ind) {
773
+ if (!ind?.id) return "";
774
+ const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
775
+ if (!calls.length) return "";
776
+ return `calls in-repo: ${capJoin(calls.map((e) => calleeRef(graph, e)), CALL_HINT_CAP)}`;
777
+ }
778
+
779
+ /** The in-repo symbols a function calls (fn→fn `callsSymbol` edges), with file:line.
780
+ * Cold tool — replaces reading a body to learn its in-repo call graph. */
781
+ export function renderCalls(graph, ind) {
782
+ const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
783
+ if (!calls.length) {
784
+ return `${ind.label} — ${ind.class || "Entity"}: no in-repo calls recorded (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
785
+ }
786
+ const items = calls.map((e) => calleeRef(graph, e));
787
+ return `${ind.label} — ${ind.class || "Entity"} calls ${calls.length} in-repo symbol(s):\n ${capJoin(items, CALL_CAP, "\n ")}`;
788
+ }
789
+
790
+ // ---- commit history with author/date/subject (Commit attributes) ----------------
791
+
792
+ /** One commit rendered as "<sha> <date> <author> — <subject>", from the Commit
793
+ * individual's commitAuthor/commitDate/commitMessage attributes (graceful when absent). */
794
+ function commitLine(graph, commitId, fallbackLabel) {
795
+ const c = graph.byId.get(commitId);
796
+ const sha = c?.label || fallbackLabel || commitId;
797
+ // Tolerate either attribute-key convention (commitAuthor/… or the shorter author/…).
798
+ const date = attrVal(c, "commitDate") || attrVal(c, "date");
799
+ const author = attrVal(c, "commitAuthor") || attrVal(c, "author");
800
+ const msg = attrVal(c, "commitMessage") || attrVal(c, "message");
801
+ const head = [sha, date, author].filter(Boolean).join(" ");
802
+ return msg ? `${head} — ${msg}` : head;
803
+ }
804
+
805
+ /** File history: commits that touched a symbol's MODULE (module-coarse `touches`
806
+ * edges), each with author/date/subject. Newest-first (git-log order preserved). */
807
+ export function renderFileHistory(graph, ind) {
808
+ const modId = moduleIdOf(graph, ind);
809
+ if (!modId) return `cannot map ${ind.label} to a module.`;
810
+ const modLabel = graph.byId.get(modId)?.label || modId;
811
+ const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId);
812
+ if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
813
+ const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
814
+ const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
815
+ return `${modLabel}: touched by ${commits.length} recent commit(s):\n${shown.join("\n")}${tail}`;
816
+ }
817
+
818
+ /** Symbol-granular history: commits whose `touchesSymbol` edge points at THIS symbol's
819
+ * id (method/class/function), each with author/date/subject. Used by method/class history. */
820
+ function renderSymbolHistory(graph, ind) {
821
+ const commits = edgesOfKind(graph, "touchesSymbol").filter((e) => e.object === ind.id);
822
+ if (!commits.length) {
823
+ return `${ind.label} — ${ind.class || "Entity"}: no symbol-level commit history recorded (outside the git-log window, or fine-grained history is not in the extracted graph).`;
824
+ }
825
+ const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
826
+ const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
827
+ return `${ind.label} — ${ind.class || "Entity"}: touched by ${commits.length} commit(s):\n${shown.join("\n")}${tail}`;
828
+ }
829
+
830
+ /** Method history — commits touching a specific method symbol (`touchesSymbol`). */
831
+ export function renderMethodHistory(graph, ind) {
832
+ return renderSymbolHistory(graph, ind);
833
+ }
834
+
835
+ /** Class history — commits touching a specific class symbol (`touchesSymbol`). */
836
+ export function renderClassHistory(graph, ind) {
837
+ return renderSymbolHistory(graph, ind);
838
+ }
839
+
840
+ // ---- symbol search (kind=function/class/method/attribute, with name/decorator filters)
841
+
842
+ const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
843
+
844
+ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
845
+ const targetClass = SYMBOL_CLASSES[kind];
846
+ if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
847
+ const hits = [];
848
+ for (const ind of graph.individuals) {
849
+ if ((ind.class || "") !== targetClass) continue;
850
+ if (nameRe && !nameRe.test(ind.label)) continue;
851
+ if (decFilter && !decoratorOf(ind).toLowerCase().includes(decFilter)) continue;
852
+ const label = String(ind.label).toLowerCase();
853
+ let score = tokens.length ? 0 : 1;
854
+ for (const t of tokens) if (label.includes(t)) score += 5;
855
+ if (tokens.length && !score) continue;
856
+ hits.push({ ind, score });
857
+ }
858
+ if (!hits.length) return `no ${kind} matches the given filters.`;
859
+ hits.sort((a, b) => b.score - a.score || String(a.ind.label).length - String(b.ind.label).length);
860
+ const top = hits.slice(0, limit);
861
+ const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
862
+ for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
863
+ lines.push("Then seonix_snippet <name> for the exact body, or seonix_describe for its edges.");
864
+ return lines.join("\n");
865
+ }
866
+
867
+ // ---- seonix_context: a one-shot "edit bundle" plan (pure; the server adds the file
868
+ // reads). Returns everything needed to add-a-sibling to a module in ONE call,
869
+ // so the agent need not search→describe→snippet→read×N (RepoGraph ego-network
870
+ // idea; LocAgent: structured, replacement-shaped output drives tool adoption).
871
+
872
+ const CONTEXT_SIBLING_CAP = 8; // Lever 1: the bundle is re-billed every turn — keep a few most-relevant siblings, not all.
873
+ const CLASS_MEMBER_CAP = 16; // B007 gap (a): class-internal members shown when the anchor is a class/method.
874
+ const COCHANGE_MID_CAP = 4; // #13: trim the MID bundle's co-change tail (was 8) — re-billed every turn.
875
+ const CONTEXT_TESTS_CAP = 6; // #13: cap the covering-tests list in the bundle.
876
+ const INSERTION_REGION_CAP = 40; // #2: contiguous tail lines shown as the "write your new sibling here" region.
877
+ // #6 task-size thresholds (named, next to the caps above). B1/B6: widened so the COMMON
878
+ // "add a small sibling util / register a filter" task lands at the lean TINY default (a 1-2
879
+ // param helper with a short body), and only genuinely bigger edits top up to MID/LARGE.
880
+ const TINY_MAX_LOC = 12; // TINY: exemplar/anchor body ≤ this many lines …
881
+ const TINY_MAX_ARITY = 2; // … AND ≤ this many params (value, arg) …
882
+ const LARGE_CLASS_MEMBERS = 8; // LARGE: anchor is a method of a class with ≥ this many members ("big class").
883
+ const INLINE_CALLEE_CAP = 3; // LARGE: inline at most this many depth-1 in-repo callee bodies …
884
+ const INLINE_CALLEE_LOC = 120; // … up to this many total lines.
885
+
886
+ const splitDecs = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
887
+ const tokenize = (s) =>
888
+ String(s || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
889
+ const countParams = (p) => { const s = String(p || "").trim(); return s ? s.split(",").map((x) => x.trim()).filter(Boolean).length : 0; };
890
+ const modeOf = (nums) => {
891
+ const freq = new Map();
892
+ let best = nums[0] ?? 0;
893
+ let bestN = 0;
894
+ for (const n of nums) { const c = (freq.get(n) || 0) + 1; freq.set(n, c); if (c > bestN) { bestN = c; best = n; } }
895
+ return best;
896
+ };
897
+
898
+ /** A symbol's structural profile (param count, has-returns/raises, in-repo callee set)
899
+ * — the shape matched by the structural-similarity component of sibling ranking. */
900
+ function profileOf(x) {
901
+ return {
902
+ paramCount: countParams(x?.params),
903
+ hasReturns: Boolean(x?.returns),
904
+ hasRaises: Boolean(x?.raises),
905
+ callees: x?.callees instanceof Set ? x.callees : new Set(),
906
+ };
907
+ }
908
+
909
+ /** When the anchor is a Module (no single anchor symbol), derive the dominant structural
910
+ * pattern across the siblings so the exemplar can be chosen for structural closeness too. */
911
+ function dominantProfile(siblings) {
912
+ if (!siblings.length) return { paramCount: 0, hasReturns: false, hasRaises: false, callees: new Set() };
913
+ const counts = siblings.map((s) => countParams(s.params));
914
+ const retYes = siblings.filter((s) => Boolean(s.returns)).length;
915
+ const raiseYes = siblings.filter((s) => Boolean(s.raises)).length;
916
+ const calleeFreq = new Map();
917
+ for (const s of siblings) for (const c of s.callees || []) calleeFreq.set(c, (calleeFreq.get(c) || 0) + 1);
918
+ const common = new Set([...calleeFreq.entries()].filter(([, n]) => n >= 2).map(([c]) => c));
919
+ return {
920
+ paramCount: modeOf(counts),
921
+ hasReturns: retYes * 2 >= siblings.length,
922
+ hasRaises: raiseYes * 2 >= siblings.length,
923
+ callees: common,
924
+ };
925
+ }
926
+
927
+ /** Structural affinity of a sibling to the target profile — bounded below name-affinity
928
+ * (max 16 < the 50/token name weight), so it only breaks ties within a name/decorator tier. */
929
+ function structuralScore(s, target) {
930
+ if (!target) return 0;
931
+ let score = Math.max(0, 4 - Math.abs(countParams(s.params) - target.paramCount));
932
+ if (Boolean(s.returns) === target.hasReturns) score += 2;
933
+ if (Boolean(s.raises) === target.hasRaises) score += 2;
934
+ const shared = [...(s.callees || [])].filter((c) => target.callees.has(c)).length;
935
+ return score + Math.min(shared, 4) * 2;
936
+ }
937
+
938
+ /** Lever 1: rank siblings by relevance to the anchor so the lean bundle shows the ones
939
+ * worth copying — shared decorator (the module's registration pattern, e.g.
940
+ * @register.filter) > name-affinity (shared tokens) > nearest source position. Pure;
941
+ * mutates a transient `_score` only. */
942
+ function rankSiblings(siblings, { decorators: anchorDecorators = "", label: anchorLabel = "", site: anchorSite = null } = {}, structuralTarget = null) {
943
+ const decCount = new Map();
944
+ for (const s of siblings) for (const d of splitDecs(s.decorators)) decCount.set(d, (decCount.get(d) || 0) + 1);
945
+ let dominant = "";
946
+ let bestCount = 1;
947
+ for (const [d, c] of decCount) if (c > bestCount) { bestCount = c; dominant = d; }
948
+ const anchorDecs = new Set(splitDecs(anchorDecorators));
949
+ const targetDecs = anchorDecs.size ? anchorDecs : new Set(dominant ? [dominant] : []);
950
+ const anchorTokens = new Set(tokenize(anchorLabel));
951
+ const anchorStart = anchorSite?.start ?? null;
952
+ for (const s of siblings) {
953
+ const decMatch = splitDecs(s.decorators).some((d) => targetDecs.has(d)) ? 1 : 0;
954
+ const nameAff = tokenize(s.label).filter((t) => anchorTokens.has(t)).length;
955
+ // #3: structural affinity (param-count / has-returns / has-raises / shared in-repo
956
+ // callees) sits BELOW name-affinity (max 16 < 50) — a tiebreaker within a name tier.
957
+ const struct = structuralScore(s, structuralTarget);
958
+ const pos = anchorStart != null && s.site ? 1 / (1 + Math.abs(s.site.start - anchorStart)) : 0;
959
+ s._score = decMatch * 1000 + nameAff * 50 + struct + pos;
960
+ }
961
+ return [...siblings].sort((a, b) => b._score - a._score || (a.site?.start || 0) - (b.site?.start || 0));
962
+ }
963
+
964
+ /** Structured edit-context for `symbol`'s module: anchor span, RANKED top-level siblings
965
+ * (Function/Class) capped lean, the single closest exemplar (for its FULL body when the
966
+ * anchor itself is a module), registration globals, covering tests, and the insertion line.
967
+ * Pure — no fs. The server reads the module file once to flesh out the snippet + bodies. */
968
+ export function contextPlan(graph, ind) {
969
+ const modId = moduleIdOf(graph, ind);
970
+ const moduleLabel = graph.byId.get(modId)?.label || String(modId || "").replace(/^mod:/, "");
971
+ const defEdges = edgesOfKind(graph, "defines").filter((e) => e.subject === modId);
972
+ // #3: index fn→fn in-repo callees once, so siblings/anchor carry their callee set for
973
+ // structural ranking and the sizeBundle cross-module-call check.
974
+ const calleeMap = new Map();
975
+ for (const e of edgesOfKind(graph, "callsSymbol")) {
976
+ if (!calleeMap.has(e.subject)) calleeMap.set(e.subject, new Set());
977
+ calleeMap.get(e.subject).add(e.object);
978
+ }
979
+ let siblings = [];
980
+ const globals = [];
981
+ let insertion = 0;
982
+ for (const e of defEdges) {
983
+ const mem = graph.byId.get(e.object);
984
+ if (!mem) continue;
985
+ const cls = mem.class || "";
986
+ const site = siteOf(mem);
987
+ if (cls === "GlobalVariable") {
988
+ globals.push({ label: mem.label, value: (mem.attributes || []).find((a) => a.key === "value")?.value || "", site });
989
+ if (site) insertion = Math.max(insertion, site.end);
990
+ } else if (cls === "Function" || cls === "Class") {
991
+ // B007 gap (c): carry each sibling's `raises` + one-line doc so a validator-style
992
+ // task sees the error-contract without reading the body. #3 adds params/returns/callees
993
+ // for structural-similarity ranking.
994
+ siblings.push({
995
+ id: mem.id, label: mem.label, class: cls, site, decorators: decoratorOf(mem),
996
+ raises: attrVal(mem, "raises"), doc: attrVal(mem, "doc"),
997
+ params: attrVal(mem, "params"), returns: attrVal(mem, "returns"),
998
+ callees: calleeMap.get(mem.id) || new Set(),
999
+ });
1000
+ if (site) insertion = Math.max(insertion, site.end);
1001
+ }
1002
+ }
1003
+ const anchorSite = siteOf(ind);
1004
+ const anchor = anchorSite && (ind.class || "") !== "Module"
1005
+ ? {
1006
+ id: ind.id, label: ind.label, class: ind.class || "", site: anchorSite, decorators: decoratorOf(ind),
1007
+ raises: attrVal(ind, "raises"), params: attrVal(ind, "params"), returns: attrVal(ind, "returns"),
1008
+ callees: calleeMap.get(ind.id) || new Set(),
1009
+ }
1010
+ : null;
1011
+ const totalSiblings = siblings.length;
1012
+ // #3: the structural target the exemplar should resemble — the anchor's own shape when
1013
+ // there is one, else the dominant pattern across siblings (module anchor case).
1014
+ const structuralTarget = anchor ? profileOf(anchor) : dominantProfile(siblings);
1015
+ siblings = rankSiblings(siblings, anchor || { label: ind.label }, structuralTarget);
1016
+ // Lever 2: when the anchor is a module (no anchor body shown), surface the single
1017
+ // closest sibling's FULL body as the copy-this exemplar; signatures alone made the
1018
+ // agent fall back to Read (B006). With a function/class anchor its own body suffices.
1019
+ const exemplar = !anchor ? siblings.find((s) => s.site && s.label !== ind.label) || null : null;
1020
+ const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))].slice(0, CONTEXT_TESTS_CAP);
1021
+ const cochange = cochangeNeighbours(graph, modId).slice(0, COCHANGE_MID_CAP);
1022
+ const exports = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object).slice(0, 20);
1023
+ // B007 gap (b): the LITERAL __all__ membership (even unresolved) — the public surface a
1024
+ // new sibling must join to be importable.
1025
+ const allExports = attrVal(graph.byId.get(modId), "all");
1026
+ // B007 gap (a): class-internal members. When the anchor IS a class (or a method of one),
1027
+ // the edit often lives inside that class (e.g. add Truncator.lines), so list its
1028
+ // members with signatures so the agent need not read/grep the class body.
1029
+ const contains = edgesOfKind(graph, "contains");
1030
+ let classOwnerId = null;
1031
+ if ((ind.class || "") === "Class") classOwnerId = ind.id;
1032
+ else if ((ind.class || "") === "Method") classOwnerId = contains.find((e) => e.object === ind.id)?.subject || null;
1033
+ let classMembers = null;
1034
+ if (classOwnerId) {
1035
+ const owner = graph.byId.get(classOwnerId);
1036
+ const members = contains.filter((e) => e.subject === classOwnerId).map((e) => {
1037
+ const m = graph.byId.get(e.object);
1038
+ return {
1039
+ label: e.objectLabel || m?.label || e.object,
1040
+ class: m?.class || "",
1041
+ site: m ? siteOf(m) : null,
1042
+ decorators: m ? decoratorOf(m) : "",
1043
+ params: m ? attrVal(m, "params") : "",
1044
+ returns: m ? attrVal(m, "returns") : "",
1045
+ raises: m ? attrVal(m, "raises") : "",
1046
+ };
1047
+ }).slice(0, CLASS_MEMBER_CAP);
1048
+ classMembers = { className: owner?.label || String(classOwnerId).replace(/^fn:.*#/, ""), members, total: contains.filter((e) => e.subject === classOwnerId).length };
1049
+ }
1050
+ // #2: contiguous insertion region — from the LAST top-level definition (sibling/global,
1051
+ // or the exemplar, which is a sibling) through end-of-module. We give the start line here;
1052
+ // the server extends `end` to the real end-of-file (capped) using the lines it reads.
1053
+ let lastTop = null;
1054
+ for (const s of [...siblings, ...globals]) {
1055
+ if (s.site && (!lastTop || s.site.start > lastTop.start)) lastTop = s.site;
1056
+ }
1057
+ const insertionRegion = lastTop ? { start: lastTop.start, end: lastTop.end } : null;
1058
+ // #6: the focal symbol (anchor when present, else the module's exemplar) drives both the
1059
+ // call hint and the LARGE-tier inlined-callee bodies.
1060
+ const focal = anchor || exemplar;
1061
+ const focalInd = focal?.id ? graph.byId.get(focal.id) : null;
1062
+ const callHintStr = focalInd ? callHint(graph, focalInd) : "";
1063
+ let calleeBodies = [];
1064
+ if (focal?.callees) {
1065
+ for (const cid of focal.callees) {
1066
+ const c = graph.byId.get(cid);
1067
+ const cs = c ? siteOf(c) : null;
1068
+ if (cs) calleeBodies.push({ label: c.label, site: cs });
1069
+ if (calleeBodies.length >= INLINE_CALLEE_CAP) break;
1070
+ }
1071
+ }
1072
+ return {
1073
+ modId, moduleLabel, anchor, siblings, totalSiblings, exemplar, globals, tests, cochange,
1074
+ exports, allExports, classMembers, insertion, insertionRegion, calleeBodies, callHint: callHintStr,
1075
+ siblingCap: CONTEXT_SIBLING_CAP,
1076
+ };
1077
+ }
1078
+
1079
+ // ---- #6 task-size-adaptive bundle (TINY / MID / LARGE) ---------------------------
1080
+
1081
+ /** Which bundle sections a tier emits. TINY is genuinely minimal (header + one short
1082
+ * exemplar body + registration + insertion region + __all__); MID is the full bundle;
1083
+ * LARGE adds inlined depth-1 callee bodies. FULL forces everything on. Pure. */
1084
+ export function bundleMask(tier) {
1085
+ const all = {
1086
+ anchor: true, exemplar: true, registration: true, insertionRegion: true, allExports: true,
1087
+ classMembers: true, siblings: true, reexports: true, tests: true, cochange: true, inlinedCallees: false,
1088
+ };
1089
+ if (tier === "TINY") return { ...all, classMembers: false, siblings: false, reexports: false, tests: false, cochange: false };
1090
+ if (tier === "LARGE" || tier === "FULL") return { ...all, inlinedCallees: true };
1091
+ return all; // MID
1092
+ }
1093
+
1094
+ /** B2: a TRIMMED mask for SECONDARY (related-but-not-primary) digest modules — keep the cheap,
1095
+ * cache-stable signal (registration globals, ranked sibling SIGNATURES, the insertion region,
1096
+ * __all__) but drop the expensive bodies (anchor/exemplar/inlined callees) and the variable
1097
+ * tails (tests/cochange/re-exports/class members). Pure. */
1098
+ export function trimBundleMask(mask) {
1099
+ return {
1100
+ ...mask,
1101
+ anchor: false, exemplar: false, inlinedCallees: false,
1102
+ classMembers: false, reexports: false, tests: false, cochange: false,
1103
+ registration: true, siblings: true, insertionRegion: true, allExports: true,
1104
+ };
1105
+ }
1106
+
1107
+ /** Classify a context plan by task size and return {tier, mask, topup}. B1/B6: lean by
1108
+ * default — START at TINY and escalate ("top-up") one tier ONLY when the lean bundle would
1109
+ * omit something the edit demonstrably needs (no exemplar body, a class/method edit, or a
1110
+ * large/complex target → MID; a cross-module call or a big-class method → LARGE). `topup`
1111
+ * records whether auto-sizing escalated above TINY (surfaced in the digest header). Pure. */
1112
+ export function sizeBundle(plan, graph, { untuned = false } = {}) {
1113
+ const focal = plan.anchor || plan.exemplar;
1114
+ let tier = "TINY";
1115
+ // (a) no exemplar/anchor body to copy → the agent needs the sibling list (MID).
1116
+ const hasExemplarBody = Boolean((plan.anchor && plan.anchor.site) || (plan.exemplar && plan.exemplar.site));
1117
+ if (!hasExemplarBody) tier = "MID";
1118
+ // (b) the edit lives INSIDE a class (class/method anchor with members) → show members (MID).
1119
+ if (plan.classMembers && plan.classMembers.members && plan.classMembers.members.length) tier = "MID";
1120
+ if (focal) {
1121
+ // (c) a large/complex target (long body, many params, or it raises) → MID.
1122
+ const loc = focal.site ? focal.site.end - focal.site.start + 1 : Infinity;
1123
+ const arity = countParams(focal.params);
1124
+ // (c) a long/complex focal escalates TINY→MID. TUNING #1 (B011) gated this on an explicit
1125
+ // symbol anchor (so a long-exemplar MODULE digest stayed TINY); BENCHMARK_011 proved that
1126
+ // trim REGRESSED every model (the trimmed sibling/test tail was load-bearing scaffolding), so
1127
+ // B012 REVERTS it — escalation fires on any long focal, == the B010 digest. The `untuned`
1128
+ // param is now a no-op for sizing (kept so the seonix-b010 control arm's flag still resolves).
1129
+ if (loc > TINY_MAX_LOC || arity > TINY_MAX_ARITY || Boolean(focal.raises)) tier = "MID";
1130
+ // (d) LARGE — only for an EXPLICIT symbol focus (plan.anchor), where inlining the
1131
+ // depth-1 callee bodies / the class shape is worth the tokens: a cross-module call from
1132
+ // the anchor, OR an anchor that is a method of a big class. When the focal is merely a
1133
+ // module-EXEMPLAR (the digest/module-anchor case), a cross-module call does NOT force
1134
+ // LARGE — the exemplar body already shows the call, and MID's signatures suffice; this
1135
+ // keeps the common "register a filter" module bundle lean.
1136
+ let crossModule = false;
1137
+ if (plan.anchor) {
1138
+ for (const cid of focal.callees || []) {
1139
+ const c = graph.byId.get(cid);
1140
+ const cs = c ? siteOf(c) : null;
1141
+ if (cs && cs.path !== plan.moduleLabel) { crossModule = true; break; }
1142
+ }
1143
+ }
1144
+ const bigClassMethod = (plan.anchor?.class || "") === "Method" &&
1145
+ Number(plan.classMembers?.total || plan.classMembers?.members?.length || 0) >= LARGE_CLASS_MEMBERS;
1146
+ if (crossModule || bigClassMethod) tier = "LARGE";
1147
+ } else {
1148
+ tier = "MID"; // no focal symbol at all → not a tiny add; keep the fuller bundle.
1149
+ }
1150
+ return { tier, mask: bundleMask(tier), topup: tier !== "TINY" };
1151
+ }
1152
+
1153
+ /** B2: order SECONDARY digest modules by relevance to the PRIMARY (first) module — import
1154
+ * adjacency (either direction, incl. coarse calls) outranks change-coupling weight; ties keep
1155
+ * the caller's input order (stable, deterministic). Returns the candidate labels reordered.
1156
+ * Pure — no fs. Falls back to the input order when the primary can't be mapped to a module. */
1157
+ export function rankModulesByProximity(graph, primaryLabel, candidateLabels) {
1158
+ const moduleIdFor = (label) => {
1159
+ const { match } = resolveSymbol(graph, label);
1160
+ return match && (match.class || "") === "Module" ? match.id : null;
1161
+ };
1162
+ const pid = moduleIdFor(primaryLabel);
1163
+ if (!pid) return [...candidateLabels];
1164
+ const adjacent = new Set();
1165
+ for (const kind of ["imports", "calls"]) {
1166
+ for (const e of edgesOfKind(graph, kind)) {
1167
+ if (e.subject === pid) adjacent.add(e.object);
1168
+ if (e.object === pid) adjacent.add(e.subject);
1169
+ }
1170
+ }
1171
+ const coWeight = new Map();
1172
+ for (const e of edgesOfKind(graph, "cochange")) {
1173
+ if (e.subject === pid) coWeight.set(e.object, (coWeight.get(e.object) || 0) + (e.weight || 0));
1174
+ else if (e.object === pid) coWeight.set(e.subject, (coWeight.get(e.subject) || 0) + (e.weight || 0));
1175
+ }
1176
+ return candidateLabels
1177
+ .map((label, i) => {
1178
+ const id = moduleIdFor(label);
1179
+ const score = id ? (adjacent.has(id) ? 10 : 0) + (coWeight.get(id) || 0) : 0;
1180
+ return { label, score, i };
1181
+ })
1182
+ .sort((a, b) => b.score - a.score || a.i - b.i)
1183
+ .map((s) => s.label);
1184
+ }
1185
+
1186
+ // ---- #7 seonix_context_more: only the sections a TINY/MID bundle omits ------------
1187
+
1188
+ /** Render ONLY the bundle sections a lean bundle omits (sibling list / class members /
1189
+ * re-exports / __all__ / tests / cochange) for a symbol's module. Pure (no fs). */
1190
+ export function renderContextMore(plan) {
1191
+ const out = [`Additional context for ${plan.moduleLabel} (sections omitted from the lean bundle):`];
1192
+ if (plan.classMembers && plan.classMembers.members.length) {
1193
+ out.push(`\n## members of ${plan.classMembers.className}:`);
1194
+ for (const m of plan.classMembers.members) {
1195
+ const short = String(m.label).split(".").pop();
1196
+ const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
1197
+ const dec = m.decorators ? `@${m.decorators} ` : "";
1198
+ const r = m.raises ? ` raises=${m.raises}` : "";
1199
+ out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
1200
+ }
1201
+ }
1202
+ if (plan.siblings.length) {
1203
+ out.push(`\n## sibling symbols (most relevant first; ${plan.siblings.length} total):`);
1204
+ for (const s of plan.siblings.slice(0, plan.siblingCap)) {
1205
+ const dec = s.decorators ? `@${s.decorators} ` : "";
1206
+ const r = s.raises ? ` raises=${s.raises}` : "";
1207
+ out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${r}`);
1208
+ }
1209
+ if (plan.siblings.length > plan.siblingCap) out.push(` …+${plan.siblings.length - plan.siblingCap} more`);
1210
+ }
1211
+ if (plan.allExports) out.push(`\n## module __all__: ${plan.allExports}`);
1212
+ if (plan.exports && plan.exports.length) out.push(`\n## re-exported symbols: ${plan.exports.join(", ")}`);
1213
+ if (plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
1214
+ if (plan.cochange && plan.cochange.length) {
1215
+ out.push(`\n## usually changed together: ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
1216
+ }
1217
+ if (out.length === 1) out.push("(no omitted sections — the lean bundle already contained everything for this symbol.)");
1218
+ return out.join("\n");
1219
+ }
1220
+
1221
+ // ---- #7 cold-tool catalog (written to <repo>/.seonix/TOOLS.md by the index step) --
1222
+
1223
+ /** Markdown catalog of the COLD tools (everything except the two hot MCP tools): each
1224
+ * with a one-line purpose and the exact Bash invocation via the CLI `cli <tool>` route.
1225
+ * Pure — `cliPath` is the absolute path to bin/cli.mjs the caller wants embedded. */
1226
+ export function renderToolsCatalog(cliPath) {
1227
+ const cold = [
1228
+ ["seonix_describe", "Locate one symbol and list its typed edges (both directions) with provenance.", { symbol: "django/utils/text.py" }],
1229
+ ["seonix_signature", "One symbol's API surface (params, returns, raises/catches, flags, decorators, doc) without the body.", { symbol: "Truncator.chars" }],
1230
+ ["seonix_impact", "Transitive reverse closure over imports/calls — what breaks if a module changes, by depth, with tests.", { module: "django/utils/text.py" }],
1231
+ ["seonix_search", "Free-text/ranked lookup over the code-map to find the right module or symbol.", { query: "template filters", kind: "function" }],
1232
+ ["seonix_members", "A class's methods + attributes (file:line, decorators) in one slice.", { class: "Truncator" }],
1233
+ ["seonix_subclasses", "A class's base classes plus the transitive set of classes that extend it.", { class: "Field" }],
1234
+ ["seonix_architecture", "Package/module map + the most-imported hub modules (optionally scoped to a package).", { package: "django/template" }],
1235
+ ["seonix_exports", "A module's public __all__ surface, each name resolved to the module that defines it.", { module: "django/db/models/__init__.py" }],
1236
+ ["seonix_tests_for", "The test modules covering a symbol or module, from the typed test edges.", { symbol: "django/utils/text.py" }],
1237
+ ["seonix_untested", "Source modules with no covering test module — a coverage-gap view (no arguments).", {}],
1238
+ ["seonix_history", "Recent commits that touched a symbol's module (newest first).", { symbol: "django/utils/text.py" }],
1239
+ ["seonix_file_history", "Commits that touched a symbol's module, each with author / date / subject.", { symbol: "django/utils/text.py" }],
1240
+ ["seonix_method_history", "Commits that touched a specific method symbol (fine-grained), with author / date / subject.", { symbol: "Truncator.chars" }],
1241
+ ["seonix_class_history", "Commits that touched a specific class symbol (fine-grained), with author / date / subject.", { symbol: "Truncator" }],
1242
+ ["seonix_callers", "Modules that call into a symbol's module (one hop).", { symbol: "django/utils/text.py" }],
1243
+ ["seonix_callees", "Modules a symbol's module calls into (one hop).", { symbol: "django/utils/text.py" }],
1244
+ ["seonix_calls", "The in-repo symbols a function calls (fn→fn), each with file:line.", { symbol: "slugify" }],
1245
+ ["seonix_cochanges", "Modules that historically change in the same commit as a symbol's module (git co-change).", { symbol: "django/utils/text.py" }],
1246
+ ["seonix_context_more", "The bundle sections a lean seonix_context omitted (siblings / tests / cochange / class members / re-exports).", { symbol: "django/utils/text.py" }],
1247
+ ];
1248
+ const lines = [
1249
+ "# seonix cold-tool catalog",
1250
+ "",
1251
+ "The two hot tools — `seonix_context` (start here to add/modify code; supports `depth: min|auto|full`) and `seonix_snippet` (exact source of one symbol) — are exposed as MCP tools.",
1252
+ "",
1253
+ "The cold tools below are not registered as MCP tools (to keep the surface small); invoke each via the CLI:",
1254
+ "",
1255
+ ];
1256
+ for (const [name, purpose, args] of cold) {
1257
+ lines.push(`## ${name}`);
1258
+ lines.push(purpose);
1259
+ lines.push("```bash");
1260
+ lines.push(`node ${cliPath} cli ${name} '${JSON.stringify(args)}'`);
1261
+ lines.push("```");
1262
+ lines.push("");
1263
+ }
1264
+ return lines.join("\n");
1265
+ }
1266
+
1267
+ // ---- change-coupling (git co-change) — "what usually changes together" ----------
1268
+
1269
+ /** [{label, weight}] modules co-changed with modId, sorted by count desc. Pure. */
1270
+ function cochangeNeighbours(graph, modId) {
1271
+ const hits = [];
1272
+ for (const e of edgesOfKind(graph, "cochange")) {
1273
+ if (e.subject === modId) hits.push({ label: e.objectLabel || e.object, weight: e.weight || 0 });
1274
+ else if (e.object === modId) hits.push({ label: e.subjectLabel || e.subject, weight: e.weight || 0 });
1275
+ }
1276
+ return hits.sort((a, b) => b.weight - a.weight);
1277
+ }
1278
+
1279
+ const COCHANGE_CAP = 20;
1280
+
1281
+ /** Modules that historically change in the same commit as the target's module. */
1282
+ export function renderCochanges(graph, ind) {
1283
+ const modId = moduleIdOf(graph, ind);
1284
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1285
+ const modLabel = graph.byId.get(modId)?.label || modId;
1286
+ const hits = cochangeNeighbours(graph, modId);
1287
+ if (!hits.length) return `${modLabel}: no change-coupling recorded (rarely co-committed, or outside the git-log window).`;
1288
+ const list = hits.slice(0, COCHANGE_CAP).map((h) => `${h.label} (×${h.weight})`);
1289
+ return `${modLabel} — usually changes together with ${hits.length} module(s) (edit these too):\n ${list.join("\n ")}` +
1290
+ (hits.length > COCHANGE_CAP ? `\n …+${hits.length - COCHANGE_CAP} more` : "");
1291
+ }
1292
+
1293
+ // ---- re-exports / public API (__all__) ------------------------------------------
1294
+
1295
+ const EXPORTS_CAP = 40;
1296
+
1297
+ /** A module's public export surface: each __all__ name → the module that actually
1298
+ * defines it (so re-export hubs like __init__ are explicit). */
1299
+ export function renderExports(graph, ind) {
1300
+ const modId = moduleIdOf(graph, ind);
1301
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1302
+ const modLabel = graph.byId.get(modId)?.label || modId;
1303
+ const edges = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId);
1304
+ if (!edges.length) return `${modLabel}: no public exports recorded (no literal __all__, or none resolved).`;
1305
+ const list = edges.slice(0, EXPORTS_CAP).map((e) => {
1306
+ const origin = graph.byId.get(e.object);
1307
+ const where = origin ? siteOf(origin) : null;
1308
+ const from = where ? ` ← ${where.path}` : "";
1309
+ return `${e.objectLabel || e.object}${from}`;
1310
+ });
1311
+ return `${modLabel} — public API (${edges.length} export(s) via __all__):\n ${list.join("\n ")}` +
1312
+ (edges.length > EXPORTS_CAP ? `\n …+${edges.length - EXPORTS_CAP} more` : "");
1313
+ }