@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/src/chat.mjs CHANGED
@@ -41,14 +41,19 @@ import { join } from "node:path";
41
41
  import { createWriteStream } from "node:fs";
42
42
  import { mkdir, readFile } from "node:fs/promises";
43
43
  import { createInterface } from "node:readline/promises";
44
- import { randomBytes } from "node:crypto";
45
44
  import { spawnSync } from "node:child_process";
46
45
  import { dispatchTool } from "./server.mjs";
47
46
  import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
48
47
  import { parseEntities } from "./codegraph.mjs";
49
48
  import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
49
+ import { uuidv7 } from "./uuid.mjs";
50
+ import { createTelemetry } from "./telemetry.mjs";
50
51
  import * as defaultSource from "./source.mjs";
51
52
 
53
+ // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
54
+ // here because callers/tests still import it from chat.mjs.
55
+ export { uuidv7 };
56
+
52
57
  /** Where session logs live, relative to the target repo. `.seonix/` is the repo's
53
58
  * one artifact directory (gitignored, machine-local) — flip this single constant
54
59
  * if the operator prefers a different location. */
@@ -131,15 +136,74 @@ function countableKinds(graph) {
131
136
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
132
137
  }
133
138
 
139
+ // ---- misspelled count-noun repair (self-contained; no ask-engine coupling) ----
140
+ // The count path intercepts "how many X" BEFORE the ask engine, so a typo'd count noun
141
+ // ("how many clases") would otherwise never reach the ask engine's own spell/fuzzy repair
142
+ // and land on the honest "I can't count" message — whereas a structural-query typo, routed
143
+ // to ask.mjs, IS corrected. We repair it HERE instead of falling through, because chat's
144
+ // ask.mjs imports are LAZY-by-contract (file docblock) and answerCount is synchronous: a
145
+ // small bounded edit-distance over the COUNT_NOUNS keys keeps the count path self-
146
+ // contained and never crashes a turn on the engine's concurrent evolution.
147
+
148
+ /** Bounded Damerau-Levenshtein (optimal string alignment: sub/ins/del + adjacent
149
+ * transposition) with an early row-minimum exit — a self-contained copy of the ask
150
+ * engine's own bounded distance (ask.mjs editDistance), deliberately kept local so the
151
+ * count path carries no static dependency on the ask engine. Returns the distance, or
152
+ * max+1 as soon as it provably exceeds `max`. */
153
+ function editDistance(a, b, max) {
154
+ if (a === b) return 0;
155
+ if (Math.abs(a.length - b.length) > max) return max + 1;
156
+ let prev2 = null;
157
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
158
+ for (let i = 1; i <= a.length; i += 1) {
159
+ const cur = [i];
160
+ let rowMin = i;
161
+ for (let j = 1; j <= b.length; j += 1) {
162
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
163
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
164
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + cost);
165
+ cur[j] = v;
166
+ if (v < rowMin) rowMin = v;
167
+ }
168
+ if (rowMin > max) return max + 1;
169
+ prev2 = prev;
170
+ prev = cur;
171
+ }
172
+ return prev[b.length];
173
+ }
174
+
175
+ const COUNT_NOUN_KEYS = Object.keys(COUNT_NOUNS);
176
+
177
+ /** Repair a misspelled count noun to its individual `class` via a bounded edit-distance
178
+ * match over the COUNT_NOUNS keys — 1 edit for short tokens, 2 for longer, the same
179
+ * curated budget the ask engine's fuzzy tier uses. Accepts a hit ONLY when every
180
+ * closest-within-bound key maps to the SAME class, so "classe" (1 edit from both "class"
181
+ * AND "classes", both → Class) resolves cleanly while a genuine cross-class tie is refused
182
+ * (→ the honest "I can't count" message). Exact matches never reach here — resolveObject's
183
+ * own discipline: a real ambiguity is stated, never guessed. Returns the class or null. */
184
+ function fuzzyCountClass(noun) {
185
+ const bound = noun.length <= 5 ? 1 : 2;
186
+ let best = bound + 1;
187
+ let classes = new Set();
188
+ for (const key of COUNT_NOUN_KEYS) {
189
+ const d = editDistance(noun, key, bound);
190
+ if (d > bound) continue;
191
+ if (d < best) { best = d; classes = new Set([COUNT_NOUNS[key]]); }
192
+ else if (d === best) classes.add(COUNT_NOUNS[key]);
193
+ }
194
+ return best <= bound && classes.size === 1 ? [...classes][0] : null;
195
+ }
196
+
134
197
  /** Recognise a count/aggregate question and answer it from the graph header, or
135
198
  * null if it isn't one (→ fall through to seonix_ask). "how many X [are there]",
136
- * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
199
+ * "count [the] X", "number of X". A misspelled kind is repaired by a bounded fuzzy
200
+ * match (fuzzyCountClass); a still-unknown kind lists what it CAN count. */
137
201
  export function answerCount(graph, query) {
138
202
  if (!graph) return null;
139
203
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
140
204
  if (!m) return null;
141
205
  const noun = m[1].toLowerCase();
142
- const cls = COUNT_NOUNS[noun];
206
+ const cls = COUNT_NOUNS[noun] || fuzzyCountClass(noun);
143
207
  if (!cls) {
144
208
  return `I can't count "${noun}". I count: ${countableKinds(graph).join(", ")}. ` +
145
209
  `Try "how many classes are there".`;
@@ -438,17 +502,6 @@ export function gitToplevel(cwd = process.cwd()) {
438
502
  return null;
439
503
  }
440
504
 
441
- /** UUID v7 (RFC 9562): 48-bit big-endian unix-ms timestamp, then version/variant
442
- * bits over crypto-random tail — time-sortable, unlike crypto.randomUUID()'s v4. */
443
- export function uuidv7(now = Date.now()) {
444
- const b = randomBytes(16);
445
- b.writeUIntBE(now, 0, 6); // 48-bit unix-ms timestamp, big-endian
446
- b[6] = (b[6] & 0x0f) | 0x70; // version 7
447
- b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx
448
- const h = b.toString("hex");
449
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
450
- }
451
-
452
505
  /** Mirror bin/cli.mjs's configFor: an explicit repo pins the artifact path; no
453
506
  * repo falls back to the cwd/env-derived default. */
454
507
  function configFor(repoPath) {
@@ -694,6 +747,11 @@ export async function runChat({
694
747
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
695
748
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
696
749
 
750
+ // Opt-in telemetry (default OFF → null → the loop's `tel?.record` is a no-op, and
751
+ // nothing is written). The conversational session log + sidecar above stay the
752
+ // authoritative chat record; this is the machine-readable query telemetry.
753
+ const tel = createTelemetry({ env, config, surface: "chat" });
754
+
697
755
  const sessionId = uuidv7();
698
756
  const logDir = join(repo, SESSION_LOG_DIR);
699
757
  const sessionsDir = join(repo, SESSIONS_DIR_REL);
@@ -766,6 +824,13 @@ export async function runChat({
766
824
  await writeLog(logLines.join("\n") + "\n");
767
825
  await writeSidecar(record);
768
826
  turnRecords.push(record);
827
+ // One telemetry line per dispatched turn (OFF by default → no-op). query.raw is
828
+ // the user's line; `tool` the slash-command if any; count the cited entity ids.
829
+ tel?.record({
830
+ tool: record.command,
831
+ query: { raw: line },
832
+ response: { count: (record.answeredIds || []).length, node_ids: record.answeredIds || [] },
833
+ });
769
834
  await upsertGraph(record.ts);
770
835
  turns += 1;
771
836
  rl.setPrompt(promptFor(focus));
package/src/codegraph.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
2
2
  import { cosine } from "./embed.mjs";
3
+ import { expandGraphPayload } from "./graph-format.mjs";
3
4
 
4
5
  // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
5
6
  // deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
@@ -25,6 +26,12 @@ import { cosine } from "./embed.mjs";
25
26
  // ---- payload parsing ---------------------------------------------------------
26
27
 
27
28
  export function parseEntities(payload) {
29
+ // Defensive A6 passthrough: source.mjs already expands the interned v2 wire form, but
30
+ // scripts that JSON.parse graph.json and call parseEntities directly (tune-search, the
31
+ // rank gate) land here first — expand is idempotent on an already in-memory payload
32
+ // (format absent → passthrough). The `typeof` guard keeps this working inside viz's browser
33
+ // bundle: askSource() strips the graph-format import, and that askData is already expanded.
34
+ if (typeof expandGraphPayload !== "undefined") payload = expandGraphPayload(payload);
28
35
  const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
29
36
  const byId = new Map();
30
37
  for (const ind of individuals) {
package/src/config.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // artifact `cli index_repository` wrote there — no config needed.
9
9
 
10
10
  import { join } from "node:path";
11
+ import { storeFileFor } from "./store.mjs";
11
12
 
12
13
  export const DEFAULT_GRAPH_REL = join(".seonix", "graph.json");
13
14
 
@@ -24,5 +25,10 @@ export function loadConfig(env = process.env, cwd = process.cwd()) {
24
25
  const graphFile = env.SEONIX_GRAPH_FILE && env.SEONIX_GRAPH_FILE.trim()
25
26
  ? env.SEONIX_GRAPH_FILE.trim()
26
27
  : join(cwd, DEFAULT_GRAPH_REL);
27
- return { graphFile };
28
+ // C12 (opt-in): SEONIX_STORE=sqlite selects the resident node:sqlite store for the
29
+ // LOAD path (source.mjs). Explicit opt-in only — auto-when-present is rejected because
30
+ // it would silently flip benchmark runs. Unset → store is null and nothing sqlite loads.
31
+ const store = env.SEONIX_STORE && env.SEONIX_STORE.trim() === "sqlite" ? "sqlite" : null;
32
+ const storeFile = store ? storeFileFor(graphFile) : null;
33
+ return { graphFile, store, storeFile };
28
34
  }