@polycode-projects/seonix 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -23
- package/bin/cli.mjs +71 -17
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +172 -3
- package/src/ask.mjs +703 -79
- package/src/browser.mjs +12 -6
- package/src/chat.mjs +188 -0
- package/src/codegraph.mjs +159 -4
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/prose-nlp.mjs +52 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/timeline.mjs +117 -0
- package/src/viz.mjs +185 -64
package/src/browser.mjs
CHANGED
|
@@ -305,8 +305,10 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
|
|
|
305
305
|
* the page's whole view state already round-trips through the URL (P1) — a plain
|
|
306
306
|
* reload on HEAD change is enough to pick up a re-index without losing the cursor,
|
|
307
307
|
* selection, or query. */
|
|
308
|
-
export function buildBrowserData(tg, { repoUrl = "", repoRef = "main", siteNav = false, live = false, gitHead = "" } = {}) {
|
|
309
|
-
|
|
308
|
+
export function buildBrowserData(tg, { repoUrl = "", repoRef = "main", siteNav = false, live = false, gitHead = "", nav = null } = {}) {
|
|
309
|
+
// `nav` ({name: href}) rebuilds the page's #sitenav; `siteNav` is the legacy
|
|
310
|
+
// flag — stale deployed data keeps its absolute links.
|
|
311
|
+
return { ...tg, repoUrl: String(repoUrl).replace(/\/+$/, ""), repoRef, siteNav: !!siteNav, ...(nav ? { nav } : {}), live: !!live, gitHead };
|
|
310
312
|
}
|
|
311
313
|
|
|
312
314
|
/** The tested temporal core, ready to inline into the page (exports stripped). */
|
|
@@ -430,7 +432,8 @@ function init(tg){
|
|
|
430
432
|
for(const n of tg.nodes)S.byId.set(n.id,n);
|
|
431
433
|
document.title='seon chronograph — '+(tg.meta&&tg.meta.scope||'');
|
|
432
434
|
$('scopelabel').textContent=(tg.meta&&tg.meta.scope?'· '+tg.meta.scope:'')+' · '+tg.commits.length+' commits · '+tg.nodes.length+' nodes'+(tg.live?' · live':'');
|
|
433
|
-
if(tg.
|
|
435
|
+
if(tg.nav){$('sitenav').innerHTML=Object.entries(tg.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
|
|
436
|
+
else if(tg.siteNav)$('sitenav').hidden=false;
|
|
434
437
|
// ---- URL state in (query-as-URL: the whole view is the link) ----
|
|
435
438
|
const st=decodeViewState(location.search);
|
|
436
439
|
if(st.q){S.q=st.q;$('q').value=st.q;S.qSet=matchQuery(tg,st.q);}
|
|
@@ -477,9 +480,12 @@ function syncUrl(){
|
|
|
477
480
|
const full=(keep?'data='+encodeURIComponent(keep)+(qs?'&':''):'')+qs;
|
|
478
481
|
history.replaceState(null,'',full?('?'+full):location.pathname);
|
|
479
482
|
}
|
|
483
|
+
// Legend chips per type GROUP — Function/Method share one chip (legend-only
|
|
484
|
+
// merge; node fills stay distinct), counts summed across the group.
|
|
485
|
+
const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
|
|
480
486
|
function buildChips(){
|
|
481
|
-
$('legend').innerHTML=
|
|
482
|
-
document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',()=>{c.checked?S.enabled.add(
|
|
487
|
+
$('legend').innerHTML=GROUPS.map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'"'+(g.every(t=>S.enabled.has(t))?' checked':'')+'>'+g.map(t=>'<i style="background:'+COLORS[t]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>').join(' ');
|
|
488
|
+
document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',()=>{c.dataset.cls.split(',').forEach(t=>c.checked?S.enabled.add(t):S.enabled.delete(t));render();}));
|
|
483
489
|
}
|
|
484
490
|
function buildCy(tg){
|
|
485
491
|
const els=[];
|
|
@@ -562,7 +568,7 @@ function render(){
|
|
|
562
568
|
const vis={},tot={};
|
|
563
569
|
S.tg.nodes.forEach(n=>{tot[n.type]=(tot[n.type]||0)+1;});
|
|
564
570
|
cy.nodes().forEach(cn=>{if(cn.style('display')==='element'){const t=cn.data('type');vis[t]=(vis[t]||0)+1;}});
|
|
565
|
-
document.querySelectorAll('.cnt').forEach(sp=>{const
|
|
571
|
+
document.querySelectorAll('.cnt').forEach(sp=>{const ks=sp.dataset.cnt.split(',');sp.textContent=ks.reduce((a,k)=>a+(vis[k]||0),0)+'/'+ks.reduce((a,k)=>a+(tot[k]||0),0);});
|
|
566
572
|
// narration: single cursor = the commit's story; two cursors = the range's story
|
|
567
573
|
$('narr').textContent=cmp?narrateRange(S.tg,lo,hi):narrateCommit(S.tg,idx);
|
|
568
574
|
$('qnote').style.display=(S.qSet&&S.qSet.size===0)?'block':'none';
|
package/src/chat.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// chat.mjs — `seonix chat`: an interactive prompt over the mechanical seonix_ask
|
|
2
|
+
// engine (PLAN_MECHANICAL_CHAT.md). Every non-`/exit` line is a query dispatched
|
|
3
|
+
// through dispatchTool("seonix_ask", …) — the EXACT path bin/cli.mjs's `cli
|
|
4
|
+
// <toolName>` fallback already exposes — so chat is the same engine as
|
|
5
|
+
// `seonix cli seonix_ask '{"query":"…"}'` with a readline shell around it, zero
|
|
6
|
+
// new coupling to ask.mjs internals.
|
|
7
|
+
//
|
|
8
|
+
// Sessions are logged to <repo>/SESSION_LOG_DIR/session-<uuidv7>.log, appended
|
|
9
|
+
// and flushed per turn so a killed session keeps everything up to its last turn.
|
|
10
|
+
// Alongside it, a STRUCTURED sidecar (.seonix/sessions/session-<uuidv7>.jsonl,
|
|
11
|
+
// sessions.mjs) records each turn's resolved/answered entity ids, and the session
|
|
12
|
+
// is upserted into graph.json per turn as a first-class `Session` individual with
|
|
13
|
+
// mgx:asksAbout edges — chat sessions are temporal graph data, like commits.
|
|
14
|
+
//
|
|
15
|
+
// Future work (deliberately NOT wired for v1): the ask engine's structured
|
|
16
|
+
// envelope carries selected-node context hooks (the viewer's contextId); chat is
|
|
17
|
+
// single-shot stateless queries only — no cross-turn state, no context id.
|
|
18
|
+
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { createWriteStream } from "node:fs";
|
|
21
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
22
|
+
import { createInterface } from "node:readline/promises";
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
24
|
+
import { dispatchTool } from "./server.mjs";
|
|
25
|
+
import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
|
|
26
|
+
import { parseEntities } from "./codegraph.mjs";
|
|
27
|
+
import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
28
|
+
import * as defaultSource from "./source.mjs";
|
|
29
|
+
|
|
30
|
+
/** Where session logs live, relative to the target repo. `.seonix/` is the repo's
|
|
31
|
+
* one artifact directory (gitignored, machine-local) — flip this single constant
|
|
32
|
+
* if the operator prefers a different location. */
|
|
33
|
+
export const SESSION_LOG_DIR = ".seonix";
|
|
34
|
+
|
|
35
|
+
export const PROMPT = "seon> ";
|
|
36
|
+
|
|
37
|
+
/** dispatchTool("seonix_ask", …) returns the prose answer plus a delimited
|
|
38
|
+
* machine-readable envelope (server.mjs §6.2); the TUI shows the prose only. */
|
|
39
|
+
const ASK_ENVELOPE_DELIM = "\n\n---seonix_ask---\n";
|
|
40
|
+
|
|
41
|
+
/** UUID v7 (RFC 9562): 48-bit big-endian unix-ms timestamp, then version/variant
|
|
42
|
+
* bits over crypto-random tail — time-sortable, unlike crypto.randomUUID()'s v4. */
|
|
43
|
+
export function uuidv7(now = Date.now()) {
|
|
44
|
+
const b = randomBytes(16);
|
|
45
|
+
b.writeUIntBE(now, 0, 6); // 48-bit unix-ms timestamp, big-endian
|
|
46
|
+
b[6] = (b[6] & 0x0f) | 0x70; // version 7
|
|
47
|
+
b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
|
|
48
|
+
const h = b.toString("hex");
|
|
49
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Mirror bin/cli.mjs's configFor: an explicit repo pins the artifact path; no
|
|
53
|
+
* repo falls back to the cwd/env-derived default. */
|
|
54
|
+
function configFor(repoPath) {
|
|
55
|
+
return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One chat turn: query → { answer, logLines, record }. Pure of any TTY/stream
|
|
60
|
+
* concerns so tests exercise it directly. A grammar miss is a normal answer (the
|
|
61
|
+
* engine's own hint text); a thrown ToolError becomes the answer too — a turn
|
|
62
|
+
* never crashes the session.
|
|
63
|
+
*
|
|
64
|
+
* `record` is the structured sidecar entry (sessions.mjs): `answeredIds` are the
|
|
65
|
+
* entity ids the answer cited, straight from the engine's machine envelope;
|
|
66
|
+
* `resolvedIds` is the engine's own resolveObject hit for the parsed object term
|
|
67
|
+
* (public ask.mjs surface, lazily imported and failure-tolerated so concurrent
|
|
68
|
+
* evolution of the engine can never crash a turn — worst case the sidecar records
|
|
69
|
+
* fewer ids, never wrong ones).
|
|
70
|
+
*/
|
|
71
|
+
export async function runTurn(query, { config, source = defaultSource, graph = null } = {}) {
|
|
72
|
+
let answer;
|
|
73
|
+
let envelope = null;
|
|
74
|
+
try {
|
|
75
|
+
const text = await dispatchTool("seonix_ask", { query }, { config, source });
|
|
76
|
+
const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
|
|
77
|
+
answer = content;
|
|
78
|
+
if (envJson) { try { envelope = JSON.parse(envJson); } catch { envelope = null; } }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
answer = String(e?.message || e);
|
|
81
|
+
}
|
|
82
|
+
// A grammar miss has parsed:null → stays []. An empty-RESULT query (object resolved,
|
|
83
|
+
// no edges) still records the resolved subject — that IS the asksAbout signal.
|
|
84
|
+
let resolvedIds = [];
|
|
85
|
+
if (graph && envelope?.parsed?.object) {
|
|
86
|
+
try {
|
|
87
|
+
const { resolveObject } = await import("./ask.mjs");
|
|
88
|
+
const r = resolveObject(graph, envelope.parsed.object);
|
|
89
|
+
if (r?.match?.id && !r.ambiguous) resolvedIds = [r.match.id];
|
|
90
|
+
} catch { /* tolerated — see docblock */ }
|
|
91
|
+
}
|
|
92
|
+
const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
93
|
+
const ts = new Date().toISOString();
|
|
94
|
+
const record = { type: "turn", ts, query, resolvedIds, answeredIds, miss: envelope ? !!envelope.miss : true };
|
|
95
|
+
const logLines = [ts, `> ${query}`, answer, ""];
|
|
96
|
+
return { answer, logLines, record };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The interactive shell. Streams are injectable so tests run scripted sessions
|
|
101
|
+
* without a TTY. Throws the source layer's own ToolError (pointing at
|
|
102
|
+
* `seonix cli index_repository`) when the repo has no graph artifact.
|
|
103
|
+
* Returns { logFile, sidecarFile, turns } once the session ends.
|
|
104
|
+
*/
|
|
105
|
+
export async function runChat({
|
|
106
|
+
repoPath,
|
|
107
|
+
input = process.stdin,
|
|
108
|
+
output = process.stdout,
|
|
109
|
+
source = defaultSource,
|
|
110
|
+
env = process.env,
|
|
111
|
+
} = {}) {
|
|
112
|
+
const repo = repoPath || process.cwd();
|
|
113
|
+
const config = configFor(repoPath);
|
|
114
|
+
|
|
115
|
+
// Load the graph once up front — the banner needs the module count, and a
|
|
116
|
+
// missing/empty artifact should fail HERE with the server's own helpful error,
|
|
117
|
+
// not on the first question.
|
|
118
|
+
const graph = parseEntities(await source.fetchEntities(config));
|
|
119
|
+
const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
120
|
+
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
121
|
+
|
|
122
|
+
const sessionId = uuidv7();
|
|
123
|
+
const logDir = join(repo, SESSION_LOG_DIR);
|
|
124
|
+
const sessionsDir = join(repo, SESSIONS_DIR_REL);
|
|
125
|
+
await mkdir(logDir, { recursive: true });
|
|
126
|
+
await mkdir(sessionsDir, { recursive: true });
|
|
127
|
+
const logFile = join(logDir, `session-${sessionId}.log`);
|
|
128
|
+
const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
|
|
129
|
+
const stream = createWriteStream(logFile, { flags: "a" });
|
|
130
|
+
const sidecar = createWriteStream(sidecarFile, { flags: "a" });
|
|
131
|
+
// Awaited writes: each chunk is handed to the OS before the loop continues, so a
|
|
132
|
+
// killed session keeps everything up to the last completed turn — in both files.
|
|
133
|
+
const flush = (s, text) =>
|
|
134
|
+
new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
|
|
135
|
+
const writeLog = (text) => flush(stream, text);
|
|
136
|
+
const writeSidecar = (obj) => flush(sidecar, JSON.stringify(obj) + "\n");
|
|
137
|
+
|
|
138
|
+
const startIso = new Date().toISOString();
|
|
139
|
+
await writeLog(`# seonix chat ${version} — session started ${startIso} — repo ${repo}\n\n`);
|
|
140
|
+
await writeSidecar({ type: "session", id: sessionId, started: startIso, repo, seonixVersion: version });
|
|
141
|
+
|
|
142
|
+
// Read-time graph upsert (sessions.mjs): after every turn, the session becomes /
|
|
143
|
+
// stays a first-class Session individual in graph.json (crash-safe: turn n is in
|
|
144
|
+
// the graph before turn n+1 runs). Best-effort by design — a re-index or vanished
|
|
145
|
+
// artifact mid-session must degrade the recording, never kill the chat.
|
|
146
|
+
const turnRecords = [];
|
|
147
|
+
const upsertGraph = async (ended) => {
|
|
148
|
+
if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
|
|
149
|
+
try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
|
|
150
|
+
catch { /* best-effort — see above */ }
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
154
|
+
output.write(dim(`seonix chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`) + "\n");
|
|
155
|
+
output.write(dim("ask a question about this codebase — /exit to leave") + "\n");
|
|
156
|
+
|
|
157
|
+
const rl = createInterface({ input, output, prompt: PROMPT });
|
|
158
|
+
rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
|
|
159
|
+
let closed = false;
|
|
160
|
+
rl.on("close", () => { closed = true; });
|
|
161
|
+
const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
|
|
162
|
+
|
|
163
|
+
let turns = 0;
|
|
164
|
+
prompt();
|
|
165
|
+
for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
|
|
166
|
+
const line = raw.trim();
|
|
167
|
+
if (line === "/exit") break;
|
|
168
|
+
if (line) {
|
|
169
|
+
const { answer, logLines, record } = await runTurn(line, { config, source, graph });
|
|
170
|
+
output.write(answer + "\n");
|
|
171
|
+
await writeLog(logLines.join("\n") + "\n");
|
|
172
|
+
await writeSidecar(record);
|
|
173
|
+
turnRecords.push(record);
|
|
174
|
+
await upsertGraph(record.ts);
|
|
175
|
+
turns += 1;
|
|
176
|
+
}
|
|
177
|
+
prompt();
|
|
178
|
+
}
|
|
179
|
+
rl.close();
|
|
180
|
+
|
|
181
|
+
const endIso = new Date().toISOString();
|
|
182
|
+
await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
|
|
183
|
+
await writeSidecar({ type: "end", ts: endIso });
|
|
184
|
+
await upsertGraph(endIso);
|
|
185
|
+
await new Promise((resolve) => stream.end(resolve));
|
|
186
|
+
await new Promise((resolve) => sidecar.end(resolve));
|
|
187
|
+
return { logFile, sidecarFile, turns };
|
|
188
|
+
}
|
package/src/codegraph.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lookupByProseTokens } from "./prose.mjs";
|
|
2
|
+
import { cosine } from "./embed.mjs";
|
|
2
3
|
|
|
3
4
|
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
4
5
|
// deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
|
|
@@ -466,6 +467,49 @@ const PROSE_PROX_FRAC = 0.2;
|
|
|
466
467
|
const PROSE_PROX_CAP_FRAC = 0.35;
|
|
467
468
|
const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
|
|
468
469
|
|
|
470
|
+
// PLAN_SEON_TUNING.md §7.5 finding 1 / §7.6(5a) (opt-in via literalMention, 2026-07-02): the query
|
|
471
|
+
// tokenizer split(/[^a-z0-9_]+/) DESTROYS a literal dotted module reference present verbatim in
|
|
472
|
+
// task text — "django.utils.http" scatters into {django,utils,http}, tokens so common across
|
|
473
|
+
// 2,931 modules that utils/http.py ranked 41 on B016's domain-filter — while every Module
|
|
474
|
+
// individual carries an unread `dotted` attribute. The lever scans the RAW query (threaded through
|
|
475
|
+
// as opts.rawQuery by searchModulesRanked) for whole, boundary-checked occurrences of each
|
|
476
|
+
// module's `dotted` name and repo-relative path (label). Boundary rule: a match flanked by an
|
|
477
|
+
// identifier/dotted/path continuation char ([a-z0-9_./]) does not count — which is also
|
|
478
|
+
// longest-match-wins for free: a package __init__'s dotted prefix ("django.utils" inside
|
|
479
|
+
// "django.utils.http") is followed by ".", so only the full module's own name fires (the two
|
|
480
|
+
// __init__.py prefix artifacts the 2026-07-02 review flagged). Specificity floor: a candidate
|
|
481
|
+
// with fewer than LIT_MIN_COMPONENTS dot/slash components never fires (a bare "utils" — or
|
|
482
|
+
// "django.utils" — must not). A hit adds a bounded BASE-score component weighted like the
|
|
483
|
+
// exact-symbol channel (LIT_W = EXACT_W per component IDF, top-LIT_COMP_CAP components like
|
|
484
|
+
// SYM_MATCH_CAP), then capped at LIT_CAP_FRAC × the strongest base score — the FRAC/CAP shape of
|
|
485
|
+
// the proximity families, anchored to the query's own best lexical evidence: a verbatim mention
|
|
486
|
+
// can lift a module INTO the top ranks but can never become an unbounded override. Applied
|
|
487
|
+
// BEFORE the proximity families so a mentioned module also donates adjacency like any other
|
|
488
|
+
// strong match. Only modules that already matched lexically are eligible (a mentioned module
|
|
489
|
+
// always is — its path components are query tokens by construction), preserving the levers'
|
|
490
|
+
// shared no-new-candidates safety scope.
|
|
491
|
+
const LIT_W = EXACT_W; // per-component weight — a verbatim module mention is the strongest locate signal
|
|
492
|
+
const LIT_MIN_COMPONENTS = 3; // "django.utils.http" fires; "django.utils"/"utils" never do
|
|
493
|
+
const LIT_COMP_CAP = 4; // like SYM_MATCH_CAP: only the top-K highest-IDF components accrue
|
|
494
|
+
const LIT_FRAC = 1.0; // bonus = min(litWeight × this, maxBase × LIT_CAP_FRAC)
|
|
495
|
+
const LIT_CAP_FRAC = 0.9; // … so a mention approaches — never dwarfs — the best lexical score
|
|
496
|
+
|
|
497
|
+
// PLAN_SEON_TUNING.md §7.6(5b) (opt-in via embedRank + an injected embedder, 2026-07-02): static-
|
|
498
|
+
// embedding re-rank — the deterministic "near-LLM" lever. The caller loads embed.mjs's
|
|
499
|
+
// potion-base-8M table (loadEmbedder(); null when the one-time-fetch weights are absent) and
|
|
500
|
+
// passes it as opts.embedder, keeping this module pure (no fs here; the flag no-ops with a
|
|
501
|
+
// one-time stderr note when the embedder is missing, so CI never needs the 30 MB artifact).
|
|
502
|
+
// Per-module text = path components + defined symbol names + doc first-lines — all read from the
|
|
503
|
+
// graph, never from source — embedded lazily and cached per process (EMB_CACHE, WeakMap-keyed on
|
|
504
|
+
// the graph). Cosine(query, module) becomes the same bounded FRAC/CAP nudge as the proximity
|
|
505
|
+
// families: only re-ranks modules that ALREADY matched lexically, never introduces a candidate.
|
|
506
|
+
const EMB_FRAC = 0.2;
|
|
507
|
+
const EMB_CAP_FRAC = 0.35;
|
|
508
|
+
const EMB_TEXT_SYMBOL_CAP = 64; // bound the per-module text: top defines …
|
|
509
|
+
const EMB_TEXT_DOC_CAP = 12; // … and doc first-lines (a giant module can't grow an unbounded text)
|
|
510
|
+
const EMB_CACHE = new WeakMap(); // graph -> { embedder, texts, vecs: Map<moduleId, Float32Array> }
|
|
511
|
+
let embedWarned = false;
|
|
512
|
+
|
|
469
513
|
// PLAN_SEON_TUNING.md §5.15 "discriminative multi-hop expansion" (opt-in via beamSearch):
|
|
470
514
|
// generalizes the R1a/E1a/E1b family's single fixed-type, single-hop nudge into an adaptive,
|
|
471
515
|
// multi-PLY expansion. Terminology follows Wikipedia's "Beam search" and Lowerre & Reddy, "The
|
|
@@ -501,6 +545,31 @@ const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
|
|
|
501
545
|
const BEAM_PLIES = 2; // hops of expansion
|
|
502
546
|
const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
|
|
503
547
|
|
|
548
|
+
/** embedRank: per-module embeddable text — path components + defined symbol names + doc
|
|
549
|
+
* first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
|
|
550
|
+
* caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
|
|
551
|
+
function moduleEmbedTexts(graph) {
|
|
552
|
+
const texts = new Map(); // moduleId -> text
|
|
553
|
+
const defIdx = definesIndex(graph);
|
|
554
|
+
const docs = new Map(); // moduleId -> [doc first-lines]
|
|
555
|
+
for (const ind of graph.individuals) {
|
|
556
|
+
const doc = (ind.attributes || []).find((a) => a.key === "doc")?.value;
|
|
557
|
+
if (!doc) continue;
|
|
558
|
+
const modId = (ind.class || "") === "Module" ? ind.id : moduleIdOf(graph, ind);
|
|
559
|
+
if (!modId) continue;
|
|
560
|
+
let arr = docs.get(modId);
|
|
561
|
+
if (!arr) docs.set(modId, (arr = []));
|
|
562
|
+
if (arr.length < EMB_TEXT_DOC_CAP) arr.push(String(doc).split("\n")[0]);
|
|
563
|
+
}
|
|
564
|
+
for (const ind of graph.individuals) {
|
|
565
|
+
if ((ind.class || "") !== "Module") continue;
|
|
566
|
+
const parts = String(ind.label).split(/[^a-zA-Z0-9_]+/).filter(Boolean);
|
|
567
|
+
const syms = (defIdx.get(ind.id) || []).slice(0, EMB_TEXT_SYMBOL_CAP);
|
|
568
|
+
texts.set(ind.id, [...parts, ...syms, ...(docs.get(ind.id) || [])].join(" "));
|
|
569
|
+
}
|
|
570
|
+
return texts;
|
|
571
|
+
}
|
|
572
|
+
|
|
504
573
|
/** Split a lowercased path label into boundary components: django/utils/text.py →
|
|
505
574
|
* {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
|
|
506
575
|
function pathComponents(labelLc) {
|
|
@@ -604,7 +673,7 @@ function beamExpand(graph, scored, beamWidth) {
|
|
|
604
673
|
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
605
674
|
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
606
675
|
function scoreModules(graph, tokens, opts = {}) {
|
|
607
|
-
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false } = opts;
|
|
676
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
608
677
|
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
609
678
|
const defIdx = definesIndex(graph);
|
|
610
679
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
@@ -617,7 +686,12 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
617
686
|
const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
|
|
618
687
|
const symComps = new Set();
|
|
619
688
|
for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
|
|
620
|
-
|
|
689
|
+
// literalMention only: the Module's `dotted` attribute (mgx:dotted) — the verbatim form a
|
|
690
|
+
// task statement uses ("django.utils.http"); "" when absent. Gated so OFF does zero work.
|
|
691
|
+
const dotted = literalMention
|
|
692
|
+
? String((ind.attributes || []).find((a) => a.key === "dotted")?.value || "").toLowerCase()
|
|
693
|
+
: "";
|
|
694
|
+
modules.push({ ind, label, labelLc, defines, symSet, symComps, dotted });
|
|
621
695
|
}
|
|
622
696
|
const N = modules.length || 1;
|
|
623
697
|
// Inverse module-frequency: a token in many modules carries little locating signal; a rare one
|
|
@@ -654,6 +728,50 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
654
728
|
const density = m.defines.length ? matchCount / m.defines.length : 0;
|
|
655
729
|
scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
|
|
656
730
|
}
|
|
731
|
+
// §7.5/§7.6(5a) literalMention (opt-in): verbatim dotted-name/path mentions in the RAW query —
|
|
732
|
+
// see the LIT_* constants' comment above for the full design. Runs before the proximity
|
|
733
|
+
// families so a mentioned module donates adjacency like any other strong match.
|
|
734
|
+
if (literalMention && rawQuery && scored.length) {
|
|
735
|
+
const rawLc = String(rawQuery).toLowerCase();
|
|
736
|
+
const continues = (ch) => ch != null && /[a-z0-9_./]/.test(ch);
|
|
737
|
+
// Whole, boundary-checked occurrence of `cand` in the raw query (see boundary rule above).
|
|
738
|
+
const mentioned = (cand) => {
|
|
739
|
+
for (let i = rawLc.indexOf(cand); i !== -1; i = rawLc.indexOf(cand, i + 1)) {
|
|
740
|
+
if (!continues(rawLc[i - 1]) && !continues(rawLc[i + cand.length])) return true;
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
};
|
|
744
|
+
// IDF for a candidate's components: normally already in the map (they are query tokens by
|
|
745
|
+
// construction when tokens came from this same raw query); computed-and-cached otherwise
|
|
746
|
+
// (a caller passing mismatched tokens/rawQuery must not crash or skew).
|
|
747
|
+
const idfOf = (t) => {
|
|
748
|
+
if (!idf.has(t)) {
|
|
749
|
+
let df = 0;
|
|
750
|
+
for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
|
|
751
|
+
idf.set(t, Math.log(1 + N / (1 + df)));
|
|
752
|
+
}
|
|
753
|
+
return idf.get(t);
|
|
754
|
+
};
|
|
755
|
+
const byModId = new Map(modules.map((m) => [m.ind.id, m]));
|
|
756
|
+
let maxBase = 0;
|
|
757
|
+
for (const s of scored) maxBase = Math.max(maxBase, s.score);
|
|
758
|
+
for (const s of scored) {
|
|
759
|
+
const m = byModId.get(s.ind.id);
|
|
760
|
+
if (!m) continue;
|
|
761
|
+
let litWeight = 0; // best single matched candidate (dotted vs path share components anyway)
|
|
762
|
+
for (const cand of new Set([m.dotted, m.labelLc])) {
|
|
763
|
+
if (!cand) continue;
|
|
764
|
+
if (cand.split(/[./]+/).filter(Boolean).length < LIT_MIN_COMPONENTS) continue; // specificity floor
|
|
765
|
+
if (!mentioned(cand)) continue;
|
|
766
|
+
// IDF-weight the candidate's tokens (same tokenizer as the query), highest first.
|
|
767
|
+
const weights = [...new Set(cand.split(/[^a-z0-9_]+/).filter(Boolean))].map(idfOf).sort((a, b) => b - a);
|
|
768
|
+
let w = 0;
|
|
769
|
+
for (let i = 0; i < Math.min(weights.length, LIT_COMP_CAP); i++) w += weights[i] * LIT_W;
|
|
770
|
+
litWeight = Math.max(litWeight, w);
|
|
771
|
+
}
|
|
772
|
+
if (litWeight) s.score += Math.min(litWeight * LIT_FRAC, maxBase * LIT_CAP_FRAC);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
657
775
|
// Import-graph proximity (rescaled): a matched module that imports / is imported by a
|
|
658
776
|
// STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
|
|
659
777
|
// (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
|
|
@@ -751,6 +869,38 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
751
869
|
}
|
|
752
870
|
}
|
|
753
871
|
}
|
|
872
|
+
// §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank — see the EMB_* constants'
|
|
873
|
+
// comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
|
|
874
|
+
// this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
|
|
875
|
+
// failure (the 30 MB weights are a local opt-in fetch, not a test/CI dependency).
|
|
876
|
+
if (embedRank) {
|
|
877
|
+
if (!opts.embedder) {
|
|
878
|
+
if (!embedWarned) {
|
|
879
|
+
embedWarned = true;
|
|
880
|
+
process.stderr.write("seonix: embedRank requested but no embedder available (weights not fetched? see `npm run refs:embeddings`) — flag is a no-op\n");
|
|
881
|
+
}
|
|
882
|
+
} else if (scored.length) {
|
|
883
|
+
const embedder = opts.embedder;
|
|
884
|
+
let cache = EMB_CACHE.get(graph);
|
|
885
|
+
if (!cache || cache.embedder !== embedder) {
|
|
886
|
+
cache = { embedder, texts: moduleEmbedTexts(graph), vecs: new Map() };
|
|
887
|
+
EMB_CACHE.set(graph, cache);
|
|
888
|
+
}
|
|
889
|
+
const qv = embedder.embed(rawQuery || tokens.join(" "));
|
|
890
|
+
let maxBase = 0;
|
|
891
|
+
for (const s of scored) maxBase = Math.max(maxBase, s.score);
|
|
892
|
+
for (const s of scored) {
|
|
893
|
+
let v = cache.vecs.get(s.ind.id);
|
|
894
|
+
if (!v) {
|
|
895
|
+
v = embedder.embed(cache.texts.get(s.ind.id) || String(s.ind.label));
|
|
896
|
+
cache.vecs.set(s.ind.id, v);
|
|
897
|
+
}
|
|
898
|
+
const sim = Math.max(0, cosine(qv, v)); // negative similarity never penalises
|
|
899
|
+
if (!sim) continue;
|
|
900
|
+
s.score += Math.min(sim * maxBase * EMB_FRAC, s.score * EMB_CAP_FRAC);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
754
904
|
// §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
|
|
755
905
|
if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
|
|
756
906
|
// Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
|
|
@@ -766,9 +916,14 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
766
916
|
* SELECTION that consumes this gap is OFF by default in run.mjs/selectModules — it over-injected
|
|
767
917
|
* on some tasks. The shipped default takes the top-2 instead. */
|
|
768
918
|
export function searchModulesRanked(graph, query, opts = {}) {
|
|
769
|
-
const
|
|
919
|
+
const raw = String(query || "");
|
|
920
|
+
const tokens = raw.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
770
921
|
if (!tokens.length) return [];
|
|
771
|
-
|
|
922
|
+
// literalMention needs the query BEFORE tokenization (the tokenizer destroys the dotted refs
|
|
923
|
+
// it matches on) and embedRank embeds the raw phrasing; threaded only when a flag that
|
|
924
|
+
// consumes it is on, so the OFF path is provably unchanged.
|
|
925
|
+
const effOpts = (opts.literalMention || opts.embedRank) ? { ...opts, rawQuery: raw } : opts;
|
|
926
|
+
return scoreModules(graph, tokens, effOpts).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
772
927
|
}
|
|
773
928
|
|
|
774
929
|
// B016 R1b, promoted to the shipped default (2026-07-02): positive in every measured cell across
|
package/src/cs_treesitter.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const endLine = (n) => n.endPosition.row + 1;
|
|
|
32
32
|
const fieldText = (n, f) => n.childForFieldName(f)?.text || "";
|
|
33
33
|
|
|
34
34
|
const TYPE_DECLS = new Set(["class_declaration", "interface_declaration", "struct_declaration", "record_declaration", "enum_declaration"]);
|
|
35
|
+
const SUBKIND = { interface_declaration: "interface", struct_declaration: "struct", record_declaration: "record", enum_declaration: "enum" };
|
|
35
36
|
|
|
36
37
|
function collectCalls(node) {
|
|
37
38
|
const out = new Set();
|
|
@@ -41,6 +42,12 @@ function collectCalls(node) {
|
|
|
41
42
|
if (n.type === "invocation_expression") {
|
|
42
43
|
const fn = n.childForFieldName("function");
|
|
43
44
|
if (fn) out.add(fn.text.replace(/\s+/g, "").slice(0, 80));
|
|
45
|
+
} else if (n.type === "object_creation_expression") {
|
|
46
|
+
// `new X(...)` → bare "X" (generics/qualifiers stripped); implicit `new(...)`
|
|
47
|
+
// is a different node type and stays excluded (matches the Roslyn backend).
|
|
48
|
+
const t = n.childForFieldName("type");
|
|
49
|
+
const nm = t ? t.text.replace(/\s+/g, "").replace(/<.*$/s, "").split(".").pop() : "";
|
|
50
|
+
if (nm) out.add(nm.slice(0, 80));
|
|
44
51
|
}
|
|
45
52
|
for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
|
|
46
53
|
}
|
|
@@ -58,6 +65,50 @@ function modifiersText(node) {
|
|
|
58
65
|
}
|
|
59
66
|
const visFrom = (mods) => mods.includes("private") ? "private" : mods.includes("protected") ? "protected" : "";
|
|
60
67
|
|
|
68
|
+
/** Emit one type declaration (and, recursively, its nested types as Outer.Inner —
|
|
69
|
+
* matching the Roslyn backend's qualified names). kind stays "class"; the flavour
|
|
70
|
+
* (interface/struct/record/enum) lands in `subkind`. */
|
|
71
|
+
function emitType(n, prefix, defines, exports) {
|
|
72
|
+
const simple = fieldText(n, "name");
|
|
73
|
+
if (!simple) return;
|
|
74
|
+
const cname = prefix ? `${prefix}.${simple}` : simple;
|
|
75
|
+
const mods = modifiersText(n);
|
|
76
|
+
const bases = [];
|
|
77
|
+
const baseList = n.childForFieldName("bases") || n.namedChildren.find((c) => c.type === "base_list");
|
|
78
|
+
if (baseList) for (let i = 0; i < baseList.namedChildCount; i++) bases.push(baseList.namedChild(i).text.slice(0, 80));
|
|
79
|
+
defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: [],
|
|
80
|
+
...(SUBKIND[n.type] ? { subkind: SUBKIND[n.type] } : {}),
|
|
81
|
+
...(visFrom(mods) ? { visibility: visFrom(mods) } : {}) });
|
|
82
|
+
if (mods.includes("public")) exports.add(cname);
|
|
83
|
+
const body = n.childForFieldName("body");
|
|
84
|
+
if (!body) return;
|
|
85
|
+
for (let i = 0; i < body.namedChildCount; i++) {
|
|
86
|
+
const mem = body.namedChild(i);
|
|
87
|
+
if (TYPE_DECLS.has(mem.type)) {
|
|
88
|
+
emitType(mem, cname, defines, exports); // nested type → Outer.Inner
|
|
89
|
+
} else if (mem.type === "enum_member_declaration") {
|
|
90
|
+
const mn = fieldText(mem, "name") || mem.text;
|
|
91
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [], is_constant: true });
|
|
92
|
+
} else if (mem.type === "method_declaration" || mem.type === "constructor_declaration" || mem.type === "local_function_statement") {
|
|
93
|
+
const mn = fieldText(mem, "name") || cname;
|
|
94
|
+
const mmods = modifiersText(mem);
|
|
95
|
+
const params = (mem.childForFieldName("parameters")?.text || "").replace(/\s+/g, " ").replace(/^\(|\)$/g, "").slice(0, 160);
|
|
96
|
+
const returns = (mem.childForFieldName("returns") || mem.childForFieldName("type"))?.text?.slice(0, 80) || "";
|
|
97
|
+
defines.push({ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
|
|
98
|
+
decorators: [], params, returns, calls: collectCalls(mem),
|
|
99
|
+
...(mmods.includes("static") ? { is_static: true } : {}),
|
|
100
|
+
...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}) });
|
|
101
|
+
} else if (mem.type === "property_declaration") {
|
|
102
|
+
const mn = fieldText(mem, "name");
|
|
103
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
104
|
+
} else if (mem.type === "field_declaration") {
|
|
105
|
+
const v = mem.descendantsOfType?.("variable_declarator")?.[0];
|
|
106
|
+
const mn = v ? fieldText(v, "name") || v.text : "";
|
|
107
|
+
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
61
112
|
export async function extractFile(absPath, root) {
|
|
62
113
|
const text = await readFile(absPath, "utf8").catch(() => null);
|
|
63
114
|
const path = relPath(root, absPath);
|
|
@@ -84,41 +135,9 @@ export async function extractFile(absPath, root) {
|
|
|
84
135
|
} else if ((n.type === "namespace_declaration" || n.type === "file_scoped_namespace_declaration") && !primaryNs) {
|
|
85
136
|
primaryNs = fieldText(n, "name");
|
|
86
137
|
} else if (TYPE_DECLS.has(n.type)) {
|
|
87
|
-
|
|
88
|
-
if (cname) {
|
|
89
|
-
const mods = modifiersText(n);
|
|
90
|
-
const bases = [];
|
|
91
|
-
const baseList = n.childForFieldName("bases") || n.namedChildren.find((c) => c.type === "base_list");
|
|
92
|
-
if (baseList) for (let i = 0; i < baseList.namedChildCount; i++) bases.push(baseList.namedChild(i).text.slice(0, 80));
|
|
93
|
-
defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: [],
|
|
94
|
-
...(visFrom(mods) ? { visibility: visFrom(mods) } : {}) });
|
|
95
|
-
if (mods.includes("public")) exports.add(cname);
|
|
96
|
-
const body = n.childForFieldName("body");
|
|
97
|
-
if (body) {
|
|
98
|
-
for (let i = 0; i < body.namedChildCount; i++) {
|
|
99
|
-
const mem = body.namedChild(i);
|
|
100
|
-
if (mem.type === "method_declaration" || mem.type === "constructor_declaration" || mem.type === "local_function_statement") {
|
|
101
|
-
const mn = fieldText(mem, "name") || cname;
|
|
102
|
-
const mmods = modifiersText(mem);
|
|
103
|
-
const params = (mem.childForFieldName("parameters")?.text || "").replace(/\s+/g, " ").replace(/^\(|\)$/g, "").slice(0, 160);
|
|
104
|
-
const returns = (mem.childForFieldName("returns") || mem.childForFieldName("type"))?.text?.slice(0, 80) || "";
|
|
105
|
-
defines.push({ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
|
|
106
|
-
decorators: [], params, returns, calls: collectCalls(mem),
|
|
107
|
-
...(mmods.includes("static") ? { is_static: true } : {}),
|
|
108
|
-
...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}) });
|
|
109
|
-
} else if (mem.type === "property_declaration") {
|
|
110
|
-
const mn = fieldText(mem, "name");
|
|
111
|
-
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
112
|
-
} else if (mem.type === "field_declaration") {
|
|
113
|
-
const v = mem.descendantsOfType?.("variable_declarator")?.[0];
|
|
114
|
-
const mn = v ? fieldText(v, "name") || v.text : "";
|
|
115
|
-
if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
// don't descend into the type body again for nested types beyond members
|
|
120
|
-
}
|
|
138
|
+
emitType(n, "", defines, exports);
|
|
121
139
|
}
|
|
140
|
+
// descend, but not back into a type body (emitType recurses for nested types)
|
|
122
141
|
if (!TYPE_DECLS.has(n.type)) for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
|
|
123
142
|
}
|
|
124
143
|
|
|
@@ -137,4 +156,8 @@ export async function ingest(root, { ignore = null } = {}) {
|
|
|
137
156
|
return { modules, failures, fileCount: files.length };
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
export function toolAvailable() {
|
|
160
|
+
try { loadTreeSitter(); return true; } catch { return false; }
|
|
161
|
+
}
|
|
162
|
+
|
|
140
163
|
export const meta = { id: "treesitter-cs", language: "c#", lib: "tree-sitter-c-sharp", exts: EXTS };
|