@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/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: args.literal_mention !== false });
90
- const scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
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
- process.stdout.write([header, ...body].join("\n") + "\n");
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
- if (given.length === 0) {
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: !!args.demote_nonprod, // R1a: demote examples//fixtures//test-* paths
281
- callAdjacency: !!args.call_adjacency, // E1a: resolved-call adjacency bonus
282
- implOfInterface: !!args.impl_of_interface, // E1b: C# impl-of-interface boost
283
- beamSearch: !!args.beam_search, // §5.15: multi-ply discriminative expansion
284
- ...(Number.isFinite(Number(args.beam_width)) ? { beamWidth: Number(args.beam_width) } : {}),
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: args.literal_mention !== false,
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 `viz`.\n");
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.6.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
- /* fall through to graph/date order */
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
- /* empty map merge info is best-effort, never fatal */
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
  }