@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/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".
|
|
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
|
-
|
|
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
|
}
|