@polycode-projects/seonix 0.6.0 → 0.7.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 +280 -4
- package/bin/cli.mjs +313 -17
- package/package.json +2 -1
- package/src/ask.mjs +14 -0
- package/src/browser.mjs +16 -7
- package/src/chat.mjs +79 -14
- package/src/codegraph.mjs +7 -0
- package/src/config.mjs +7 -1
- package/src/extract.mjs +405 -48
- package/src/graph-format.mjs +156 -0
- package/src/graph-ops.mjs +92 -0
- package/src/manifest.mjs +148 -0
- package/src/server.mjs +11 -0
- package/src/sessions.mjs +7 -2
- package/src/source.mjs +34 -4
- package/src/store.mjs +282 -0
- package/src/summary.mjs +241 -0
- package/src/telemetry.mjs +90 -0
- package/src/timeline.mjs +12 -4
- package/src/toml-config.mjs +183 -0
- package/src/uuid.mjs +16 -0
- package/src/viz.mjs +72 -12
- package/src/walk.mjs +0 -0
package/bin/cli.mjs
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
// seonix cli index_repository '{"multi_root":"<abs-dir>"}' → estate discovery: every immediate
|
|
14
14
|
// child directory carrying a .git (dir or file) is indexed as a repo, merged into
|
|
15
15
|
// <multi_root>/.seonix/ (discovered/skipped counts logged to stderr)
|
|
16
|
+
// seonix cli sync '{"multi_root":"<abs-dir>"}' → incrementally re-index the estate: fingerprint
|
|
17
|
+
// every child repo (git/gitparent/plain), re-extract ONLY the changed ones (per-repo cache in
|
|
18
|
+
// .seonix/cache/), one union rebuild = exact full-re-index. Unchanged-everything = byte-stable
|
|
19
|
+
// no-op. Same path as index_repository with "sync":true on a multi_root. Writes .seonix/manifest.json
|
|
16
20
|
// seonix cli digest '{"repo_path":"<abs>","modules":[…]}' → architecture map + per-module
|
|
17
21
|
// context bundles to stdout (no-MCP arm)
|
|
18
22
|
// seonix cli digest '{"repo_path":"<abs>","query":"<free text>"}' → same, but auto-locates +
|
|
@@ -36,12 +40,19 @@
|
|
|
36
40
|
// cwd = that repo) loads it by default. No flags, no config files.
|
|
37
41
|
|
|
38
42
|
import { join } from "node:path";
|
|
43
|
+
import { existsSync } from "node:fs";
|
|
44
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
39
46
|
import { startServer } from "../src/server.mjs";
|
|
40
47
|
import { dispatchTool, buildContextBundle } from "../src/server.mjs";
|
|
41
|
-
import { indexRepository, indexRepositories, discoverRepos } from "../src/extract.mjs";
|
|
48
|
+
import { indexRepository, indexRepositories, discoverRepos, syncRepositories } from "../src/extract.mjs";
|
|
49
|
+
import { renderSummaryMd } from "../src/summary.mjs";
|
|
42
50
|
import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
|
|
43
51
|
import * as source from "../src/source.mjs";
|
|
44
52
|
import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
|
|
53
|
+
import { loadTomlConfig, normalizeConfig, mergeEffective, CONFIG_FILE } from "../src/toml-config.mjs";
|
|
54
|
+
import { compileGlobs } from "../src/walk.mjs";
|
|
55
|
+
import { createTelemetry } from "../src/telemetry.mjs";
|
|
45
56
|
|
|
46
57
|
/** Build a config pointed at a specific repo's artifact (for `cli` sub-commands that
|
|
47
58
|
* take a repo_path), or fall back to the cwd-derived default. */
|
|
@@ -56,6 +67,73 @@ function parsePayload(payload) {
|
|
|
56
67
|
catch { return null; }
|
|
57
68
|
}
|
|
58
69
|
|
|
70
|
+
/** Load a repo's seonix.toml as a normalized config, or null. Returns null IMMEDIATELY when
|
|
71
|
+
* `args.config === false` (the BENCH-IMMUNITY flag — an arm's index/locate/digest must NEVER read a
|
|
72
|
+
* subject repo's seonix.toml) OR when no seonix.toml exists. So on the bench path, and with no
|
|
73
|
+
* seonix.toml present, every command is byte-identical to today (existsSync short-circuits before any
|
|
74
|
+
* read). Sits next to configFor. Async: normalizeConfig may read a `repositories` file. */
|
|
75
|
+
async function tomlFor(repoPath, args = {}) {
|
|
76
|
+
if (args && args.config === false) return null;
|
|
77
|
+
const dir = repoPath || process.cwd();
|
|
78
|
+
if (!existsSync(join(dir, CONFIG_FILE))) return null; // absent → shipped defaults, byte-identical
|
|
79
|
+
const raw = await loadTomlConfig(dir); // present-but-invalid THROWS (security: never swallow a misparse)
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
return await normalizeConfig(raw, { configDir: dir });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The unwired-key warning is emitted at most once per process (= once per run).
|
|
85
|
+
let _warnedUnwired = false;
|
|
86
|
+
/** Warn once for every seonix.toml key normalizeConfig recognized but no consumer wires yet. */
|
|
87
|
+
function warnUnwired(toml) {
|
|
88
|
+
if (_warnedUnwired || !toml || !Array.isArray(toml.unwired) || !toml.unwired.length) return;
|
|
89
|
+
_warnedUnwired = true;
|
|
90
|
+
for (const k of toml.unwired) {
|
|
91
|
+
process.stderr.write(`seonix: seonix.toml key ${k} is recognized but not yet implemented — ignored\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Shipped defaults for the tune knobs — the `defaults` source of `config --effective` and the
|
|
96
|
+
* digest/locate consumers. Deliberately small: only knobs with a stable scalar shipped default. */
|
|
97
|
+
const TUNE_DEFAULTS = { tune: { scoreGapK: DEFAULT_SCORE_GAP, literalMention: true } };
|
|
98
|
+
|
|
99
|
+
/** Resolve the digest query-mode tuning knobs, precedence explicit arg > seonix.toml [tune] > default.
|
|
100
|
+
* literal_mention is tri-state (shipped ON; a toml `false` disables; an explicit arg wins either way).
|
|
101
|
+
* score_gap is arg-wins (`false` = disable → null), else the toml default, else DEFAULT_SCORE_GAP.
|
|
102
|
+
* With `toml` null (no file / bench path) both equal today's inline values → byte-identical. */
|
|
103
|
+
function resolveDigestTune(args, toml) {
|
|
104
|
+
const t = (toml && toml.tune) || {};
|
|
105
|
+
const literalMention = args.literal_mention !== undefined
|
|
106
|
+
? args.literal_mention !== false
|
|
107
|
+
: (t.literalMention !== undefined ? t.literalMention !== false : true);
|
|
108
|
+
let scoreGapK;
|
|
109
|
+
if (args.score_gap !== undefined) scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
|
|
110
|
+
else if (t.scoreGapK !== undefined) scoreGapK = t.scoreGapK;
|
|
111
|
+
else scoreGapK = DEFAULT_SCORE_GAP;
|
|
112
|
+
return { literalMention, scoreGapK };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve the locate-phase ranker levers, precedence explicit arg > seonix.toml [tune] > shipped
|
|
116
|
+
* default. With `toml` null (no seonix.toml OR the bench `config:false` path) every value equals
|
|
117
|
+
* today's inline arg-derived value, so the emitted ranking stays byte-identical. */
|
|
118
|
+
function resolveLocateTune(args, toml) {
|
|
119
|
+
const t = (toml && toml.tune) || {};
|
|
120
|
+
const pick = (argVal, tomlVal, dflt) =>
|
|
121
|
+
argVal !== undefined ? !!argVal : (tomlVal !== undefined ? !!tomlVal : dflt);
|
|
122
|
+
const literalMention = args.literal_mention !== undefined
|
|
123
|
+
? args.literal_mention !== false
|
|
124
|
+
: (t.literalMention !== undefined ? t.literalMention !== false : true);
|
|
125
|
+
const out = {
|
|
126
|
+
literalMention,
|
|
127
|
+
demoteNonProd: pick(args.demote_nonprod, t.demoteNonProd, false),
|
|
128
|
+
callAdjacency: pick(args.call_adjacency, t.callAdjacency, false),
|
|
129
|
+
implOfInterface: pick(args.impl_of_interface, t.implOfInterface, false),
|
|
130
|
+
beamSearch: pick(args.beam_search, t.beamSearch, false),
|
|
131
|
+
};
|
|
132
|
+
if (Number.isFinite(Number(args.beam_width))) out.beamWidth = Number(args.beam_width);
|
|
133
|
+
else if (Number.isFinite(Number(t.beamWidth))) out.beamWidth = Number(t.beamWidth);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
59
137
|
const DIGEST_MODULE_CAP = 12; // bound the no-MCP digest — a handful of changed modules
|
|
60
138
|
const DIGEST_SECONDARY_CAP = 2; // B2: at most this many SECONDARY modules get a (trimmed) bundle
|
|
61
139
|
const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
|
|
@@ -77,6 +155,15 @@ const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
|
|
|
77
155
|
async function runDigest(args) {
|
|
78
156
|
const repoPath = args.repo_path;
|
|
79
157
|
if (!repoPath) { process.stderr.write("seonix: digest requires repo_path\n"); process.exit(2); }
|
|
158
|
+
// seonix.toml [tune] defaults the query-mode knobs (arg > seonix.toml > default). Skipped entirely
|
|
159
|
+
// on the bench path (config:false → tomlFor null) and when no seonix.toml exists → byte-identical.
|
|
160
|
+
const toml = await tomlFor(repoPath, args);
|
|
161
|
+
warnUnwired(toml);
|
|
162
|
+
// Opt-in telemetry (default OFF → null → `tel?.record` is a no-op, no file). LOG FILE ONLY —
|
|
163
|
+
// stdout is the digest injected into the no-MCP arm, so telemetry must never touch it. The
|
|
164
|
+
// seonix.toml [telemetry] toggle rides in via `toml`; SEONIX_TELEMETRY still wins both ways.
|
|
165
|
+
const tel = createTelemetry({ env: process.env, config: configFor(repoPath), toml, surface: "cli" });
|
|
166
|
+
const tune = resolveDigestTune(args, toml);
|
|
80
167
|
let modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
|
|
81
168
|
let autoSelected = null; // for the header, when `query` drove selection
|
|
82
169
|
if (!modules.length && args.query) {
|
|
@@ -86,8 +173,8 @@ async function runDigest(args) {
|
|
|
86
173
|
// lockstep with the `seonix_locate` handler so `cli digest '{query}'` ≡ `cli seonix_locate` for the
|
|
87
174
|
// same query. A strict no-op unless the query carries a ≥3-component dotted path / repo-relative
|
|
88
175
|
// path; searchModulesRanked derives rawQuery from the query when literalMention is on.
|
|
89
|
-
const ranked = searchModulesRanked(graph, args.query, { literalMention:
|
|
90
|
-
const scoreGapK =
|
|
176
|
+
const ranked = searchModulesRanked(graph, args.query, { literalMention: tune.literalMention });
|
|
177
|
+
const scoreGapK = tune.scoreGapK;
|
|
91
178
|
modules = selectRankedModules(ranked, { top_k: Number.isFinite(args.top_k) ? args.top_k : 2, scoreGapK }).slice(0, DIGEST_MODULE_CAP);
|
|
92
179
|
autoSelected = modules;
|
|
93
180
|
if (!modules.length) process.stderr.write(`seonix: digest query "${args.query}" matched no modules — empty digest\n`);
|
|
@@ -145,7 +232,16 @@ async function runDigest(args) {
|
|
|
145
232
|
// rig's own parser depends on.
|
|
146
233
|
const header = `# seonix-digest tier=${effTier} topup=${topup} modules=${emitted}`
|
|
147
234
|
+ (autoSelected ? ` selected=${autoSelected.join(",") || "(none)"}` : "");
|
|
148
|
-
|
|
235
|
+
const out = [header, ...body].join("\n") + "\n";
|
|
236
|
+
process.stdout.write(out);
|
|
237
|
+
// One telemetry line for the digest surface: the selected modules + the effective tier/topup the
|
|
238
|
+
// header already carries, plus the injected body size. `tier` here MUST equal the header's tier=.
|
|
239
|
+
tel?.record({
|
|
240
|
+
surface: "digest",
|
|
241
|
+
query: args.query ? { raw: args.query } : undefined,
|
|
242
|
+
response: { count: emitted, node_ids: modules, tier: effTier, topup },
|
|
243
|
+
cost: { returned_chars: out.length, returned_tokens_est: Math.ceil(out.length / 4) },
|
|
244
|
+
});
|
|
149
245
|
}
|
|
150
246
|
|
|
151
247
|
/** Read all of stdin (best-effort; resolves "" if none). */
|
|
@@ -182,6 +278,28 @@ async function hookAugment() {
|
|
|
182
278
|
}
|
|
183
279
|
}
|
|
184
280
|
|
|
281
|
+
/** A7: incremental estate sync. Shared by `cli sync '{multi_root}'` and index_repository's
|
|
282
|
+
* `"sync":true` on a multi_root. Re-extracts only the changed member repos; prints
|
|
283
|
+
* per-category counts to stderr and the summary MD to stdout (byte-stable no-op prints
|
|
284
|
+
* neither a rewrite nor a summary). */
|
|
285
|
+
async function runSync(multiRoot, { ignores, historyDepth }) {
|
|
286
|
+
const res = await syncRepositories(multiRoot, {
|
|
287
|
+
ignores, historyDepth,
|
|
288
|
+
log: (line) => process.stderr.write(`seonix: ${line}\n`),
|
|
289
|
+
});
|
|
290
|
+
if (res.noop) {
|
|
291
|
+
process.stderr.write(`seonix: sync ${multiRoot}: ${res.unchanged.length} repo(s) unchanged — no rewrite\n`);
|
|
292
|
+
process.stdout.write(`# seonix sync: ${res.unchanged.length} repo(s) unchanged (no rewrite)\n`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
process.stderr.write(
|
|
296
|
+
`seonix: sync ${multiRoot}: +${res.added.length} added, ~${res.changed.length} changed, ` +
|
|
297
|
+
`-${res.removed.length} removed, ${res.unchanged.length} unchanged ` +
|
|
298
|
+
`(re-extracted ${res.reextracted.length}) → ${res.graphFile}\n`,
|
|
299
|
+
);
|
|
300
|
+
process.stdout.write(`${renderSummaryMd(res.summary)}\n`);
|
|
301
|
+
}
|
|
302
|
+
|
|
185
303
|
async function main() {
|
|
186
304
|
const [mode, sub, payload] = process.argv.slice(2);
|
|
187
305
|
|
|
@@ -200,10 +318,47 @@ async function main() {
|
|
|
200
318
|
process.stderr.write(`seonix: ${given.join(" + ")} are mutually exclusive — pass exactly one\n`);
|
|
201
319
|
process.exit(2);
|
|
202
320
|
}
|
|
203
|
-
|
|
321
|
+
// seonix.toml: config dir = the explicit repo/estate path, else out_root, else cwd (where the
|
|
322
|
+
// file sits). Skipped entirely on the bench path (config:false → tomlFor null → byte-identical).
|
|
323
|
+
const toml = await tomlFor(args.repo_path || args.multi_root || args.out_root || process.cwd(), args);
|
|
324
|
+
warnUnwired(toml);
|
|
325
|
+
// repositories: seonix.toml MAY supply the repo set when NO explicit path arg is given
|
|
326
|
+
// (explicit repo_path/repo_paths/multi_root always win).
|
|
327
|
+
const tomlRepos = (given.length === 0 && toml && Array.isArray(toml.repositories) && toml.repositories.length)
|
|
328
|
+
? toml.repositories : null;
|
|
329
|
+
if (given.length === 0 && !tomlRepos) {
|
|
204
330
|
process.stderr.write("seonix: index_repository requires repo_path, repo_paths or multi_root\n");
|
|
205
331
|
process.exit(2);
|
|
206
332
|
}
|
|
333
|
+
// A5 `history_depth`: unified cap over BOTH git-history passes. 0 = skip history entirely;
|
|
334
|
+
// N>0 = `git log -n N`; absent = today's defaults. Precedence: explicit arg > seonix.toml
|
|
335
|
+
// [index].history_depth > default. Consumed by indexRepository/indexRepositories → extractRepo.
|
|
336
|
+
let historyDepth;
|
|
337
|
+
const rawHistoryDepth = args.history_depth !== undefined ? args.history_depth
|
|
338
|
+
: (toml && toml.index && toml.index.historyDepth !== undefined ? toml.index.historyDepth : undefined);
|
|
339
|
+
if (rawHistoryDepth !== undefined) {
|
|
340
|
+
if (!Number.isInteger(rawHistoryDepth) || rawHistoryDepth < 0) {
|
|
341
|
+
process.stderr.write("seonix: history_depth must be a non-negative integer (0 = skip git history)\n");
|
|
342
|
+
process.exit(2);
|
|
343
|
+
}
|
|
344
|
+
historyDepth = rawHistoryDepth;
|
|
345
|
+
}
|
|
346
|
+
// [index].exclude/secret_exclude → a compiled additive matcher (compileGlobs: ordered, `!`
|
|
347
|
+
// re-includes); [index].languages → the extractor language gate. Both are consumed by
|
|
348
|
+
// extractRepo (via indexRepository/indexRepositories → extraIgnores/languages), so they take
|
|
349
|
+
// effect at index time. Byte-identical with no toml (extraIgnores stays null, languages undefined).
|
|
350
|
+
let extraIgnores = null;
|
|
351
|
+
let tomlLanguages;
|
|
352
|
+
if (toml && toml.index) {
|
|
353
|
+
extraIgnores = compileGlobs([...(toml.index.exclude || []), ...(toml.index.secretExclude || [])]);
|
|
354
|
+
tomlLanguages = toml.index.languages;
|
|
355
|
+
}
|
|
356
|
+
// A7: `"sync":true` on a multi_root routes through the SAME incremental-sync code
|
|
357
|
+
// path as `cli sync` — fingerprint the estate, re-extract only changed members.
|
|
358
|
+
if (args.multi_root && args.sync === true) {
|
|
359
|
+
await runSync(args.multi_root, { ignores: args.ignores !== false, historyDepth });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
207
362
|
const t0 = Date.now();
|
|
208
363
|
const emitSummary = (graphFile, counts, head) => {
|
|
209
364
|
process.stderr.write(
|
|
@@ -218,18 +373,22 @@ async function main() {
|
|
|
218
373
|
};
|
|
219
374
|
|
|
220
375
|
if (args.repo_path) {
|
|
221
|
-
const { graphFile, counts } = await indexRepository(args.repo_path, { ignores: args.ignores !== false });
|
|
376
|
+
const { graphFile, counts, summary } = await indexRepository(args.repo_path, { ignores: args.ignores !== false, historyDepth, extraIgnores, languages: tomlLanguages });
|
|
222
377
|
emitSummary(graphFile, counts, "");
|
|
378
|
+
// A1: the human summary MD goes to STDOUT (bench ignores index stdout — safe;
|
|
379
|
+
// the machine-readable counts stay on stderr above).
|
|
380
|
+
process.stdout.write(`${renderSummaryMd(summary)}\n`);
|
|
223
381
|
return;
|
|
224
382
|
}
|
|
225
383
|
|
|
226
|
-
let repoPaths = args.repo_paths;
|
|
227
|
-
let outRoot = args.out_root || "";
|
|
384
|
+
let repoPaths = args.repo_paths || tomlRepos; // seonix.toml `repositories` when no explicit paths
|
|
385
|
+
let outRoot = args.out_root || (toml && toml.outRoot) || "";
|
|
386
|
+
let skippedRows = []; // [{name, reason}] from multi_root discovery → A1 summary
|
|
228
387
|
if (args.multi_root) {
|
|
229
388
|
const { repos, skipped } = await discoverRepos(args.multi_root);
|
|
230
389
|
process.stderr.write(
|
|
231
390
|
`seonix: multi_root ${args.multi_root}: ${repos.length} repo(s) discovered` +
|
|
232
|
-
(skipped.length ? `; skipped ${skipped.length} non-repo child dir(s): ${skipped.join(", ")}` : "") + "\n",
|
|
391
|
+
(skipped.length ? `; skipped ${skipped.length} non-repo child dir(s): ${skipped.map((s) => s.name).join(", ")}` : "") + "\n",
|
|
233
392
|
);
|
|
234
393
|
if (!repos.length) {
|
|
235
394
|
process.stderr.write("seonix: multi_root contains no repositories (no child directory has a .git)\n");
|
|
@@ -237,17 +396,44 @@ async function main() {
|
|
|
237
396
|
}
|
|
238
397
|
repoPaths = repos;
|
|
239
398
|
outRoot = args.multi_root;
|
|
399
|
+
skippedRows = skipped;
|
|
240
400
|
}
|
|
241
401
|
if (!Array.isArray(repoPaths) || repoPaths.length === 0) {
|
|
242
402
|
process.stderr.write("seonix: repo_paths must be a non-empty array of absolute paths\n");
|
|
243
403
|
process.exit(2);
|
|
244
404
|
}
|
|
245
|
-
const { graphFile, counts, repos } = await indexRepositories(repoPaths, {
|
|
405
|
+
const { graphFile, counts, repos, summary } = await indexRepositories(repoPaths, {
|
|
246
406
|
ignores: args.ignores !== false,
|
|
247
407
|
outRoot,
|
|
408
|
+
historyDepth,
|
|
409
|
+
extraIgnores,
|
|
410
|
+
languages: tomlLanguages,
|
|
411
|
+
skipped: skippedRows,
|
|
248
412
|
log: (line) => process.stderr.write(`seonix: ${line}\n`),
|
|
249
413
|
});
|
|
250
414
|
emitSummary(graphFile, counts, `${repos.length} repos, `);
|
|
415
|
+
process.stdout.write(`${renderSummaryMd(summary)}\n`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// sync mode (A7): `cli sync '{"multi_root":"<abs>"}'` — incrementally re-index an
|
|
420
|
+
// estate, re-extracting only the child repos whose fingerprint changed since the last
|
|
421
|
+
// manifest. exit 2 without multi_root.
|
|
422
|
+
if (sub === "sync") {
|
|
423
|
+
const args = parsePayload(payload);
|
|
424
|
+
if (args === null || !args.multi_root) {
|
|
425
|
+
process.stderr.write("seonix: sync requires a JSON arg with multi_root, e.g. '{\"multi_root\":\"/abs\"}'\n");
|
|
426
|
+
process.exit(2);
|
|
427
|
+
}
|
|
428
|
+
let historyDepth;
|
|
429
|
+
if (args.history_depth !== undefined) {
|
|
430
|
+
if (!Number.isInteger(args.history_depth) || args.history_depth < 0) {
|
|
431
|
+
process.stderr.write("seonix: history_depth must be a non-negative integer (0 = skip git history)\n");
|
|
432
|
+
process.exit(2);
|
|
433
|
+
}
|
|
434
|
+
historyDepth = args.history_depth;
|
|
435
|
+
}
|
|
436
|
+
await runSync(args.multi_root, { ignores: args.ignores !== false, historyDepth });
|
|
251
437
|
return;
|
|
252
438
|
}
|
|
253
439
|
|
|
@@ -273,15 +459,23 @@ async function main() {
|
|
|
273
459
|
process.exit(2);
|
|
274
460
|
}
|
|
275
461
|
const config = configFor(args.repo_path);
|
|
462
|
+
// seonix.toml [tune] defaults the locate levers (arg > seonix.toml > default). Skipped on the
|
|
463
|
+
// bench path (config:false → tomlFor null) and when no seonix.toml exists → byte-identical.
|
|
464
|
+
const toml = await tomlFor(args.repo_path, args);
|
|
465
|
+
warnUnwired(toml);
|
|
466
|
+
// Opt-in telemetry (default OFF → null → no-op, no file). stdout is the ranked list the rig
|
|
467
|
+
// parses, so telemetry is LOG FILE ONLY. seonix.toml [telemetry] rides in via `toml`.
|
|
468
|
+
const tel = createTelemetry({ env: process.env, config, toml, surface: "cli" });
|
|
469
|
+
const tune = resolveLocateTune(args, toml);
|
|
276
470
|
try {
|
|
277
471
|
const graph = parseEntities(await source.fetchEntities(config));
|
|
278
472
|
// B016 recall-lever flags (LOCATE-phase, per-arm; output byte-identical when absent).
|
|
279
473
|
const ranked = searchModulesRanked(graph, args.query || "", {
|
|
280
|
-
demoteNonProd:
|
|
281
|
-
callAdjacency:
|
|
282
|
-
implOfInterface:
|
|
283
|
-
beamSearch:
|
|
284
|
-
...(
|
|
474
|
+
demoteNonProd: tune.demoteNonProd, // R1a: demote examples//fixtures//test-* paths
|
|
475
|
+
callAdjacency: tune.callAdjacency, // E1a: resolved-call adjacency bonus
|
|
476
|
+
implOfInterface: tune.implOfInterface, // E1b: C# impl-of-interface boost
|
|
477
|
+
beamSearch: tune.beamSearch, // §5.15: multi-ply discriminative expansion
|
|
478
|
+
...(tune.beamWidth !== undefined ? { beamWidth: tune.beamWidth } : {}),
|
|
285
479
|
// B018 §8.1.3 literal-mention lever: match verbatim dotted-name/path mentions in the RAW
|
|
286
480
|
// query (which the locate tokenizer destroys). searchModulesRanked derives rawQuery from the
|
|
287
481
|
// query arg when literalMention is on; the rig passes the raw problem as the query, so literal
|
|
@@ -292,10 +486,35 @@ async function main() {
|
|
|
292
486
|
// with no ≥3-component dotted path / repo-relative path, so it never perturbs the cells the
|
|
293
487
|
// headline B018 numbers were measured on. The low-level scoreModules default (codegraph.mjs)
|
|
294
488
|
// stays literalMention=false; the product surface opts in explicitly, right here.
|
|
295
|
-
literalMention:
|
|
489
|
+
literalMention: tune.literalMention,
|
|
296
490
|
...(args.raw_query != null ? { rawQuery: String(args.raw_query) } : {}),
|
|
491
|
+
// A4 (B016 recall lever, SPIRAL): a bounded-radius ego walk from the lexical seeds that MAY
|
|
492
|
+
// surface lexically-invisible modules — the one lever that breaks the lexical ceiling.
|
|
493
|
+
// scoreModules short-circuits on spiral:false (spiralExpand never runs), so with no spiral
|
|
494
|
+
// flag every key below is absent-or-inert and the emitted ranking is byte-identical to the
|
|
495
|
+
// pre-A4 locate surface. Same snake_case→camelCase idiom as demote_nonprod / beam_width.
|
|
496
|
+
spiral: !!args.spiral,
|
|
497
|
+
...(Number.isFinite(Number(args.spiral_depth)) && Number(args.spiral_depth) > 0
|
|
498
|
+
? { spiralDepth: Number(args.spiral_depth) } : {}), // hop radius from the seeds (>0 only)
|
|
499
|
+
...(args.spiral
|
|
500
|
+
// spiralNodeLimit: how many newly-reached nodes the spiral may surface. The CLI surface
|
|
501
|
+
// defaults to 100 — deliberately ABOVE the internal SPIRAL_NODE_LIMIT_DEFAULT of 12 —
|
|
502
|
+
// because `cli seonix_locate` is a RECALL PROBE, not a token budget: surface everything
|
|
503
|
+
// reachable and let the caller/rig trim. `limit` overrides it (positive finite only).
|
|
504
|
+
? { spiralNodeLimit: Number.isFinite(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : 100 }
|
|
505
|
+
: {}),
|
|
297
506
|
});
|
|
298
507
|
process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
|
|
508
|
+
// Telemetry for the locate surface: the ranked count + the top few paths/scores.
|
|
509
|
+
tel?.record({
|
|
510
|
+
surface: "locate",
|
|
511
|
+
query: { raw: String(args.query || "") },
|
|
512
|
+
response: {
|
|
513
|
+
count: ranked.length,
|
|
514
|
+
node_ids: ranked.slice(0, 3).map((r) => r.path),
|
|
515
|
+
scores: ranked.slice(0, 3).map((r) => r.score),
|
|
516
|
+
},
|
|
517
|
+
});
|
|
299
518
|
} catch (e) {
|
|
300
519
|
process.stderr.write(`seonix: ${e?.message || e}\n`);
|
|
301
520
|
process.exit(1);
|
|
@@ -345,6 +564,41 @@ async function main() {
|
|
|
345
564
|
return;
|
|
346
565
|
}
|
|
347
566
|
|
|
567
|
+
// store_rebuild (C12): force-(re)build the opt-in node:sqlite store from the current
|
|
568
|
+
// graph.json. The store is a derived, rebuildable companion — read paths never auto-build
|
|
569
|
+
// it, so this is the explicit way to (re)materialize it after an index or a graph.json edit.
|
|
570
|
+
if (sub === "store_rebuild") {
|
|
571
|
+
const args = parsePayload(payload);
|
|
572
|
+
if (args === null) {
|
|
573
|
+
process.stderr.write("seonix: store_rebuild expects a JSON arg, e.g. '{\"repo_path\":\"/abs\"}'\n");
|
|
574
|
+
process.exit(2);
|
|
575
|
+
}
|
|
576
|
+
const { graphFile } = configFor(args.repo_path);
|
|
577
|
+
const { readFile, stat } = await import("node:fs/promises");
|
|
578
|
+
const { writeStore, storeFileFor, verifyStore } = await import("../src/store.mjs");
|
|
579
|
+
const { expandGraphPayload } = await import("../src/graph-format.mjs");
|
|
580
|
+
try {
|
|
581
|
+
const text = await readFile(graphFile, "utf8");
|
|
582
|
+
// Expand the interned v2 wire form to the classic in-memory shape writeStore builds from
|
|
583
|
+
// (a v1 graph is passthrough). Without this, store_rebuild on a v2 graph.json would build
|
|
584
|
+
// an edge-less store while verifyStore still reports success (the sha matches).
|
|
585
|
+
const payloadObj = expandGraphPayload(JSON.parse(text));
|
|
586
|
+
const dbPath = storeFileFor(graphFile);
|
|
587
|
+
const sourceStat = await stat(graphFile);
|
|
588
|
+
const t0 = Date.now();
|
|
589
|
+
await writeStore(dbPath, payloadObj, { sourceStat, sourceText: text });
|
|
590
|
+
const ok = await verifyStore(dbPath, graphFile);
|
|
591
|
+
process.stderr.write(
|
|
592
|
+
`seonix: store ${ok ? "rebuilt" : "rebuilt (verify FAILED)"} in ${Date.now() - t0}ms → ${dbPath}\n`,
|
|
593
|
+
);
|
|
594
|
+
if (!ok) process.exit(1);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
process.stderr.write(`seonix: store_rebuild failed: ${e?.message || e}\n`);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
348
602
|
// tool-query fallback: any other `cli <toolName> '{…}'` routes to the MCP dispatcher,
|
|
349
603
|
// so "cold" tools are invokable from Bash without an MCP connection.
|
|
350
604
|
if (sub) {
|
|
@@ -354,9 +608,18 @@ async function main() {
|
|
|
354
608
|
process.exit(2);
|
|
355
609
|
}
|
|
356
610
|
const config = configFor(args.repo_path);
|
|
611
|
+
// seonix.toml [telemetry] toggle for the generic tool surface (byte-identical with no toml:
|
|
612
|
+
// tomlFor short-circuits to null on the bench path and when no seonix.toml exists).
|
|
613
|
+
const toml = await tomlFor(args.repo_path, args);
|
|
614
|
+
const tel = createTelemetry({ env: process.env, config, toml, surface: "cli" });
|
|
357
615
|
try {
|
|
358
616
|
const text = await dispatchTool(sub, args, { config });
|
|
359
617
|
process.stdout.write(text + "\n");
|
|
618
|
+
// Telemetry for the generic tool surface: which tool + how many chars it returned.
|
|
619
|
+
tel?.record({
|
|
620
|
+
tool: sub,
|
|
621
|
+
cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
|
|
622
|
+
});
|
|
360
623
|
} catch (e) {
|
|
361
624
|
process.stderr.write(`seonix: ${e?.message || e}\n`);
|
|
362
625
|
process.exit(1);
|
|
@@ -392,13 +655,46 @@ async function main() {
|
|
|
392
655
|
return;
|
|
393
656
|
}
|
|
394
657
|
|
|
658
|
+
// `seonix init [--dotnet] [--force]`: seed a seonix.toml in the cwd from the shipped template
|
|
659
|
+
// (or the dotnet estate template with --dotnet). Refuses to overwrite an existing file (exit 2)
|
|
660
|
+
// unless --force. The write is a raw Buffer copy → byte-equal to the template.
|
|
661
|
+
if (mode === "init") {
|
|
662
|
+
const dotnet = process.argv.includes("--dotnet");
|
|
663
|
+
const force = process.argv.includes("--force");
|
|
664
|
+
const src = fileURLToPath(new URL(`../templates/${dotnet ? "seonix-dotnet.toml" : "seonix.toml"}`, import.meta.url));
|
|
665
|
+
const dest = join(process.cwd(), CONFIG_FILE);
|
|
666
|
+
if (existsSync(dest) && !force) {
|
|
667
|
+
process.stderr.write(`seonix: ${CONFIG_FILE} already exists — pass --force to overwrite\n`);
|
|
668
|
+
process.exit(2);
|
|
669
|
+
}
|
|
670
|
+
await writeFile(dest, await readFile(src));
|
|
671
|
+
process.stdout.write(`seonix: wrote ${dest} — edit it, then \`seonix config --effective\` to review.\n`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// `seonix config --effective [--repo <abs>]`: print the merged {effective, sources} config
|
|
676
|
+
// (sorted keys) for the repo dir (default cwd). Without --effective, print usage and exit 2.
|
|
677
|
+
if (mode === "config") {
|
|
678
|
+
if (!process.argv.includes("--effective")) {
|
|
679
|
+
process.stderr.write("seonix: usage: seonix config --effective [--repo <abs>]\n");
|
|
680
|
+
process.exit(2);
|
|
681
|
+
}
|
|
682
|
+
const ri = process.argv.indexOf("--repo");
|
|
683
|
+
const repo = ri !== -1 && process.argv[ri + 1] ? process.argv[ri + 1] : process.cwd();
|
|
684
|
+
const raw = existsSync(join(repo, CONFIG_FILE)) ? await loadTomlConfig(repo) : null;
|
|
685
|
+
const toml = raw ? await normalizeConfig(raw, { configDir: repo }) : null;
|
|
686
|
+
const merged = mergeEffective({ toml, defaults: TUNE_DEFAULTS });
|
|
687
|
+
process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
395
691
|
if (mode === undefined) {
|
|
396
692
|
await startServer(); // bare invocation → MCP stdio server
|
|
397
693
|
return;
|
|
398
694
|
}
|
|
399
695
|
|
|
400
696
|
process.stderr.write(`seonix: unknown invocation "${process.argv.slice(2).join(" ")}". ` +
|
|
401
|
-
"Use bare (MCP server), `cli index_repository …`, `cli <tool> …`, `chat`, `hook-augment`, or `
|
|
697
|
+
"Use bare (MCP server), `cli index_repository …`, `cli <tool> …`, `chat`, `hook-augment`, `viz`, `init`, or `config --effective`.\n");
|
|
402
698
|
process.exit(2);
|
|
403
699
|
}
|
|
404
700
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/seonix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
47
|
"cytoscape": "^3.30.0",
|
|
48
|
+
"smol-toml": "^1.7.0",
|
|
48
49
|
"tree-sitter": "^0.21.1",
|
|
49
50
|
"tree-sitter-c-sharp": "^0.21.3",
|
|
50
51
|
"tree-sitter-java": "^0.21.0",
|
package/src/ask.mjs
CHANGED
|
@@ -649,6 +649,16 @@ const NEST_SENTINEL = "zzinnerset";
|
|
|
649
649
|
// Filler words dropped at the front of a relative predicate / anaphora filter.
|
|
650
650
|
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
|
|
651
651
|
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
652
|
+
// Determiners that may sit in front of the SUBJECT noun of a subject-relative query
|
|
653
|
+
// ("THE modules that import X", "THOSE classes that extend Base"). Peeled in
|
|
654
|
+
// parseRelationalOrQualified so the marker-gated compositional path catches them exactly
|
|
655
|
+
// as it already catches the bare "modules that import X" — otherwise the leading article
|
|
656
|
+
// keeps the subject noun off position 0, the compositional production bails, and
|
|
657
|
+
// keyword-spot mis-reads the trailing "that" as a subject pronoun (→ a false
|
|
658
|
+
// "needs a selected node" miss). Kept SEPARATE from FRAME_WORDS so peeling a determiner
|
|
659
|
+
// does not by itself flag the clause as a `framed` interrogative (which would turn a
|
|
660
|
+
// trailing unknown adjective into a qualifier miss on its own).
|
|
661
|
+
const LEADING_DETERMINERS = new Set(["the", "a", "an", "these", "those"]);
|
|
652
662
|
|
|
653
663
|
const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
654
664
|
const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
|
|
@@ -916,6 +926,10 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
916
926
|
let i = 0;
|
|
917
927
|
while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
|
|
918
928
|
const framed = i > 0;
|
|
929
|
+
// peel a leading determiner (see LEADING_DETERMINERS) — a bare "the"/"those" in front of
|
|
930
|
+
// the subject noun, so "the modules that import X" reaches the same production as the
|
|
931
|
+
// bare "modules that import X". Not folded into `framed` on purpose.
|
|
932
|
+
while (i < lc.length && LEADING_DETERMINERS.has(lc[i])) i += 1;
|
|
919
933
|
const quals = [];
|
|
920
934
|
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
921
935
|
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
package/src/browser.mjs
CHANGED
|
@@ -92,18 +92,22 @@ const attr = (ind, key) => {
|
|
|
92
92
|
* correct; this fallback makes gitCommitOrder itself repo-set-agnostic and testable.)
|
|
93
93
|
* @param {string} repo
|
|
94
94
|
* @param {Iterable<string>} graphCommitIds
|
|
95
|
-
* @param {{datesBySha?: Map<string,string>|null}} [opts]
|
|
95
|
+
* @param {{datesBySha?: Map<string,string>|null, warn?: (s: string) => void}} [opts]
|
|
96
96
|
*/
|
|
97
|
-
export async function gitCommitOrder(repo, graphCommitIds, { datesBySha = null } = {}) {
|
|
97
|
+
export async function gitCommitOrder(repo, graphCommitIds, { datesBySha = null, warn = (s) => process.stderr.write(s) } = {}) {
|
|
98
98
|
const shas = new Set([...graphCommitIds].map((id) => String(id).replace(/^commit:/, "")));
|
|
99
99
|
let ordered = [];
|
|
100
100
|
try {
|
|
101
|
+
// Bounded: a wedged/huge git never hangs the caller — SIGKILL after 30s, then the
|
|
102
|
+
// graceful date/graph-order fallback below. The failure is WARNED, never silent.
|
|
101
103
|
const { stdout } = await execFileP("git", ["-C", repo, "log", "--format=%H"], {
|
|
102
104
|
maxBuffer: 64 * 1024 * 1024,
|
|
105
|
+
timeout: 30_000,
|
|
106
|
+
killSignal: "SIGKILL",
|
|
103
107
|
});
|
|
104
108
|
ordered = stdout.trim().split("\n").reverse().filter((s) => shas.has(s));
|
|
105
|
-
} catch {
|
|
106
|
-
|
|
109
|
+
} catch (err) {
|
|
110
|
+
warn(`seonix: git log (commit order) failed in ${repo}: ${String(err.message || err)}; using date/graph order\n`);
|
|
107
111
|
}
|
|
108
112
|
if (!ordered.length && datesBySha) {
|
|
109
113
|
// Merged-graph fallback: one commit date axis across every repo (tie-break by sha
|
|
@@ -127,22 +131,27 @@ export async function gitCommitOrder(repo, graphCommitIds, { datesBySha = null }
|
|
|
127
131
|
* got folded in. Best-effort: an empty Map on any git failure, so callers
|
|
128
132
|
* degrade to "no merge info" the same way a HEAD-only index already degrades.
|
|
129
133
|
* @param {string} repo
|
|
134
|
+
* @param {{warn?: (s: string) => void}} [opts]
|
|
130
135
|
* @returns {Promise<Map<string, string[]>>} sha -> parent shas, git's own order
|
|
131
136
|
* (a root commit maps to []).
|
|
132
137
|
*/
|
|
133
|
-
export async function gitCommitParents(repo) {
|
|
138
|
+
export async function gitCommitParents(repo, { warn = (s) => process.stderr.write(s) } = {}) {
|
|
134
139
|
const parents = new Map();
|
|
135
140
|
try {
|
|
141
|
+
// Same bound as gitCommitOrder — merge info is best-effort, but a failure is
|
|
142
|
+
// WARNED (never silent) before degrading to the empty "no merge info" map.
|
|
136
143
|
const { stdout } = await execFileP("git", ["-C", repo, "log", "--format=%H %P"], {
|
|
137
144
|
maxBuffer: 64 * 1024 * 1024,
|
|
145
|
+
timeout: 30_000,
|
|
146
|
+
killSignal: "SIGKILL",
|
|
138
147
|
});
|
|
139
148
|
for (const line of stdout.trim().split("\n")) {
|
|
140
149
|
if (!line) continue;
|
|
141
150
|
const [sha, ...ps] = line.split(" ").filter(Boolean);
|
|
142
151
|
if (sha) parents.set(sha, ps);
|
|
143
152
|
}
|
|
144
|
-
} catch {
|
|
145
|
-
|
|
153
|
+
} catch (err) {
|
|
154
|
+
warn(`seonix: git log (commit parents) failed in ${repo}: ${String(err.message || err)}; merge info unavailable\n`);
|
|
146
155
|
}
|
|
147
156
|
return parents;
|
|
148
157
|
}
|