@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/store.mjs ADDED
@@ -0,0 +1,282 @@
1
+ // store.mjs — OPT-IN resident node:sqlite store for the graph artifact (C12).
2
+ //
3
+ // The graph.json artifact is always authoritative; this store is a DERIVED,
4
+ // rebuildable, machine-local companion (`.seonix/graph.db`) that a reader can
5
+ // load instead of parsing the whole JSON blob. It is gated behind
6
+ // `SEONIX_STORE=sqlite` at BOTH ends (build + load): unset, nothing here runs,
7
+ // no `node:sqlite` is imported, and no graph.db is written — so the default
8
+ // (benchmark) path is byte-identical.
9
+ //
10
+ // Correctness contract (the reason for every column below): the payload
11
+ // reconstructed by `readPayload` must be DEEP-EQUAL to the JSON-loaded payload
12
+ // AND JSON.stringify to the same bytes — because the digest byte-identity the
13
+ // benchmark measures depends on exact ARRAY ORDER (individuals, relation groups,
14
+ // per-group edges) and on the LABEL EXCEPTIONS that are not derivable from the
15
+ // endpoints (an `inherits` edge's objectLabel is the raw base expression, and
16
+ // its object can be an `ext:<Base>` id that is NOT a node; reexports/callsSymbol/
17
+ // touchesSymbol carry non-derivable labels too). Hence: `ord` columns everywhere,
18
+ // per-edge slabel/olabel stored verbatim, and an `ids` interning table that
19
+ // covers non-node endpoints.
20
+ //
21
+ // node:sqlite is imported LAZILY (`await import("node:sqlite")`) only inside the
22
+ // functions below, so merely importing this module (config.mjs does, eagerly)
23
+ // never triggers the SQLite ExperimentalWarning on a default path.
24
+ //
25
+ // C13 (deferred) will add indexed query handles (getNode / edgesFor / searchProse)
26
+ // that read individual rows/edges without materializing the whole graph; this pass
27
+ // only implements the full load path + the transactional rebuild.
28
+
29
+ import { createHash } from "node:crypto";
30
+ import { readFile, rename, rm } from "node:fs/promises";
31
+
32
+ export const FORMAT = "seonix-store";
33
+ export const SCHEMA_VERSION = 1; // bump → old stores read as stale → JSON fallback → rebuild
34
+
35
+ // Typed load failures — callers (source.mjs) catch these and silently fall back to
36
+ // graph.json, emitting a one-line stderr hint. Never thrown on a gate-off path.
37
+ export class StoreStale extends Error { constructor(m) { super(m); this.name = "StoreStale"; } }
38
+ export class StoreCorrupt extends Error { constructor(m) { super(m); this.name = "StoreCorrupt"; } }
39
+
40
+ // Edge keys with dedicated columns; anything else on an edge is round-tripped via `extra`.
41
+ const STD_EDGE_KEYS = new Set(["subject", "object", "subjectLabel", "objectLabel", "weight"]);
42
+ // Top-level payload keys that are DECOMPOSED into their own tables; every other
43
+ // top-level key is stored verbatim in the meta `envelope` blob.
44
+ const DECOMPOSED_KEYS = new Set(["individuals", "objectProperties", "proseIndex"]);
45
+
46
+ /** Derive the store path from the JSON artifact path: `<...>.json` → `<...>.db`
47
+ * (any other suffix just gains `.db`). Single source of truth for the mapping. */
48
+ export function storeFileFor(graphFile) {
49
+ return /\.json$/i.test(graphFile) ? graphFile.replace(/\.json$/i, ".db") : `${graphFile}.db`;
50
+ }
51
+
52
+ const DDL = `
53
+ CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT);
54
+ CREATE TABLE ids (sid INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL);
55
+ CREATE TABLE nodes (sid INTEGER PRIMARY KEY, ord INTEGER NOT NULL, class TEXT, label TEXT, json TEXT NOT NULL);
56
+ CREATE TABLE relations (rel INTEGER PRIMARY KEY, ord INTEGER NOT NULL, predicate TEXT, prop TEXT, count INTEGER);
57
+ CREATE TABLE edges (rel INTEGER NOT NULL, ord INTEGER NOT NULL, s INTEGER NOT NULL, o INTEGER NOT NULL, slabel TEXT, olabel TEXT, w INTEGER, extra TEXT);
58
+ CREATE INDEX edges_by_rel ON edges(rel, ord);
59
+ `;
60
+
61
+ async function unlinkQuiet(...paths) {
62
+ for (const p of paths) { try { await rm(p, { force: true }); } catch { /* best effort */ } }
63
+ }
64
+
65
+ /**
66
+ * Build the store from an in-memory `entities` payload, transactionally, and swap it
67
+ * into place atomically. Writes to `<dbPath>.tmp-<pid>-<rand>` under one BEGIN/COMMIT,
68
+ * checkpoints the WAL into the main file, then renames over `dbPath`.
69
+ *
70
+ * @param {string} dbPath
71
+ * @param {object} payload the entities object (exactly as written to graph.json)
72
+ * @param {{sourceStat?: import("node:fs").Stats, sourceText?: string}} [opts]
73
+ * sourceStat = fs.stat of the graph.json just written (freshness = bytes+mtimeMs);
74
+ * sourceText = the exact JSON bytes (used for source_sha256; else JSON.stringify(payload)).
75
+ */
76
+ export async function writeStore(dbPath, payload, { sourceStat, sourceText } = {}) {
77
+ const json = sourceText ?? JSON.stringify(payload);
78
+ const sha = createHash("sha256").update(json).digest("hex");
79
+ const sourceBytes = sourceText !== undefined ? Buffer.byteLength(sourceText) : (sourceStat?.size ?? Buffer.byteLength(json));
80
+ const sourceMtimeMs = sourceStat ? Math.round(sourceStat.mtimeMs) : 0;
81
+
82
+ const keyOrder = Object.keys(payload);
83
+ const envelope = {};
84
+ for (const k of keyOrder) if (!DECOMPOSED_KEYS.has(k)) envelope[k] = payload[k];
85
+ const individuals = Array.isArray(payload.individuals) ? payload.individuals : [];
86
+ const groups = Array.isArray(payload.objectProperties) ? payload.objectProperties : [];
87
+ const proseIndex = payload.proseIndex ?? {};
88
+
89
+ // Intern every id: node ids first (individuals order), then any edge endpoint not yet
90
+ // seen — this is what covers non-node endpoints like `ext:<Base>` inherits targets.
91
+ const idMap = new Map();
92
+ const sidOf = (id) => {
93
+ let sid = idMap.get(id);
94
+ if (sid === undefined) { sid = idMap.size + 1; idMap.set(id, sid); }
95
+ return sid;
96
+ };
97
+ for (const ind of individuals) sidOf(ind.id);
98
+ for (const g of groups) for (const e of (g.examples || [])) { sidOf(e.subject); sidOf(e.object); }
99
+
100
+ const tmp = `${dbPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
101
+ await unlinkQuiet(tmp, `${tmp}-wal`, `${tmp}-shm`);
102
+
103
+ const { DatabaseSync } = await import("node:sqlite");
104
+ const db = new DatabaseSync(tmp);
105
+ try {
106
+ db.exec("PRAGMA page_size = 8192");
107
+ db.exec("PRAGMA journal_mode = WAL");
108
+ db.exec("PRAGMA synchronous = NORMAL");
109
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
110
+ db.exec(DDL);
111
+
112
+ db.exec("BEGIN");
113
+
114
+ const metaStmt = db.prepare("INSERT INTO meta(k, v) VALUES(?, ?)");
115
+ metaStmt.run("format", FORMAT);
116
+ metaStmt.run("version", String(SCHEMA_VERSION));
117
+ metaStmt.run("source_sha256", sha);
118
+ metaStmt.run("source_bytes", String(sourceBytes));
119
+ metaStmt.run("source_mtime_ms", String(sourceMtimeMs));
120
+ metaStmt.run("envelope", JSON.stringify(envelope));
121
+ metaStmt.run("key_order", JSON.stringify(keyOrder));
122
+ metaStmt.run("prose_index", JSON.stringify(proseIndex));
123
+
124
+ const idStmt = db.prepare("INSERT INTO ids(sid, id) VALUES(?, ?)");
125
+ for (const [id, sid] of idMap) idStmt.run(sid, id);
126
+
127
+ const nodeStmt = db.prepare("INSERT INTO nodes(sid, ord, class, label, json) VALUES(?, ?, ?, ?, ?)");
128
+ individuals.forEach((ind, ord) => {
129
+ nodeStmt.run(idMap.get(ind.id), ord, ind.class ?? null, ind.label ?? null, JSON.stringify(ind));
130
+ });
131
+
132
+ const relStmt = db.prepare("INSERT INTO relations(rel, ord, predicate, prop, count) VALUES(?, ?, ?, ?, ?)");
133
+ const edgeStmt = db.prepare("INSERT INTO edges(rel, ord, s, o, slabel, olabel, w, extra) VALUES(?, ?, ?, ?, ?, ?, ?, ?)");
134
+ groups.forEach((g, gord) => {
135
+ const rel = gord;
136
+ relStmt.run(rel, gord, g.predicate ?? null, g.prop ?? null,
137
+ Number.isFinite(g.count) ? g.count : (g.examples || []).length);
138
+ (g.examples || []).forEach((e, eord) => {
139
+ const extraKeys = Object.keys(e).filter((k) => !STD_EDGE_KEYS.has(k));
140
+ const extra = extraKeys.length
141
+ ? JSON.stringify(Object.fromEntries(extraKeys.map((k) => [k, e[k]])))
142
+ : null;
143
+ edgeStmt.run(rel, eord, idMap.get(e.subject), idMap.get(e.object),
144
+ e.subjectLabel ?? null, e.objectLabel ?? null,
145
+ e.weight ?? null, extra);
146
+ });
147
+ });
148
+
149
+ db.exec("COMMIT");
150
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
151
+ } finally {
152
+ db.close();
153
+ }
154
+
155
+ // Swap into place: drop any stale destination WAL sidecars (a reader must never apply
156
+ // an old -wal to the new main file), then rename. Derived + rebuildable, so the tiny
157
+ // non-atomic window is acceptable — a torn read throws StoreCorrupt → JSON fallback.
158
+ await unlinkQuiet(`${dbPath}-wal`, `${dbPath}-shm`);
159
+ await rename(tmp, dbPath);
160
+ await unlinkQuiet(`${tmp}-wal`, `${tmp}-shm`);
161
+ return dbPath;
162
+ }
163
+
164
+ function readMeta(db) {
165
+ const meta = {};
166
+ for (const row of db.prepare("SELECT k, v FROM meta").all()) meta[row.k] = row.v;
167
+ return meta;
168
+ }
169
+
170
+ /**
171
+ * Load and reconstruct the payload from the store. Throws StoreStale (schema bump or
172
+ * source-file drift) or StoreCorrupt (bad/absent file, malformed rows) — the caller
173
+ * catches either and falls back to graph.json.
174
+ *
175
+ * @param {string} dbPath
176
+ * @param {{expectStat?: import("node:fs").Stats}} [opts] expectStat = fs.stat of the
177
+ * live graph.json; freshness requires bytes + mtimeMs to match what writeStore stamped.
178
+ * @returns {Promise<object>} the reconstructed entities payload (deep-equal + stringify-equal).
179
+ */
180
+ export async function readPayload(dbPath, { expectStat } = {}) {
181
+ const { DatabaseSync } = await import("node:sqlite");
182
+ let db;
183
+ try { db = new DatabaseSync(dbPath, { readOnly: true }); }
184
+ catch (e) { throw new StoreCorrupt(`cannot open store ${dbPath}: ${e?.message || e}`); }
185
+ try {
186
+ // A single guard: schema/freshness gates throw StoreStale/StoreCorrupt to signal
187
+ // "fall back to JSON"; any RAW error (e.g. sqlite "file is not a database" from a
188
+ // clobbered file, a malformed row) is normalized to StoreCorrupt so the caller's
189
+ // catch treats it identically.
190
+ let out;
191
+ try {
192
+ const meta = readMeta(db);
193
+ if (meta.format !== FORMAT) throw new StoreCorrupt(`not a ${FORMAT} database`);
194
+ let userVersion;
195
+ try { userVersion = Number(db.prepare("PRAGMA user_version").get().user_version); }
196
+ catch { userVersion = NaN; }
197
+ if (Number(meta.version) !== SCHEMA_VERSION || userVersion !== SCHEMA_VERSION) {
198
+ throw new StoreStale(`store schema v${meta.version}/${userVersion} != v${SCHEMA_VERSION}`);
199
+ }
200
+ if (expectStat) {
201
+ if (Number(meta.source_bytes) !== expectStat.size ||
202
+ Number(meta.source_mtime_ms) !== Math.round(expectStat.mtimeMs)) {
203
+ throw new StoreStale("graph.json changed since the store was built");
204
+ }
205
+ }
206
+
207
+ const idById = new Map();
208
+ for (const row of db.prepare("SELECT sid, id FROM ids").all()) idById.set(row.sid, row.id);
209
+ const idOf = (sid) => {
210
+ const id = idById.get(sid);
211
+ if (id === undefined) throw new StoreCorrupt(`edge endpoint sid ${sid} missing from ids table`);
212
+ return id;
213
+ };
214
+
215
+ const individuals = db.prepare("SELECT json FROM nodes ORDER BY ord").all()
216
+ .map((r) => JSON.parse(r.json));
217
+
218
+ const edgeStmt = db.prepare("SELECT s, o, slabel, olabel, w, extra FROM edges WHERE rel = ? ORDER BY ord");
219
+ const objectProperties = db.prepare("SELECT rel, predicate, prop, count FROM relations ORDER BY ord").all()
220
+ .map((g) => ({
221
+ predicate: g.predicate,
222
+ prop: g.prop,
223
+ count: g.count,
224
+ examples: edgeStmt.all(g.rel).map((e) => {
225
+ // Key order MUST match the builder: subject, object, subjectLabel, objectLabel,
226
+ // then weight (cochange only), then any extra keys.
227
+ const edge = { subject: idOf(e.s), object: idOf(e.o), subjectLabel: e.slabel, objectLabel: e.olabel };
228
+ if (e.w !== null && e.w !== undefined) edge.weight = e.w;
229
+ if (e.extra) Object.assign(edge, JSON.parse(e.extra));
230
+ return edge;
231
+ }),
232
+ }));
233
+
234
+ const envelope = JSON.parse(meta.envelope ?? "{}");
235
+ const proseIndex = JSON.parse(meta.prose_index ?? "{}");
236
+ const keyOrder = JSON.parse(meta.key_order ?? "[]");
237
+
238
+ out = {};
239
+ for (const k of keyOrder) {
240
+ if (k === "individuals") out.individuals = individuals;
241
+ else if (k === "objectProperties") out.objectProperties = objectProperties;
242
+ else if (k === "proseIndex") out.proseIndex = proseIndex;
243
+ else out[k] = envelope[k];
244
+ }
245
+ } catch (e) {
246
+ if (e instanceof StoreStale || e instanceof StoreCorrupt) throw e;
247
+ throw new StoreCorrupt(`store reconstruction failed: ${e?.message || e}`);
248
+ }
249
+ return out;
250
+ } finally {
251
+ db.close();
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Verify a store is intact AND matches the current graph.json at `sourcePath`: schema
257
+ * version ok, `source_sha256` equals the live file's hash, and a full readPayload
258
+ * succeeds. Best-effort boolean (never throws) — used by `cli store_rebuild` to report.
259
+ */
260
+ export async function verifyStore(dbPath, sourcePath) {
261
+ try {
262
+ const text = await readFile(sourcePath, "utf8");
263
+ const sha = createHash("sha256").update(text).digest("hex");
264
+ const { DatabaseSync } = await import("node:sqlite");
265
+ const db = new DatabaseSync(dbPath, { readOnly: true });
266
+ try {
267
+ const meta = readMeta(db);
268
+ if (meta.format !== FORMAT) return false;
269
+ if (Number(meta.version) !== SCHEMA_VERSION) return false;
270
+ const uv = Number(db.prepare("PRAGMA user_version").get().user_version);
271
+ if (uv !== SCHEMA_VERSION) return false;
272
+ if (meta.source_sha256 !== sha) return false;
273
+ } finally {
274
+ db.close();
275
+ }
276
+ // structural integrity: a full reconstruction round-trips without throwing
277
+ await readPayload(dbPath);
278
+ return true;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
@@ -0,0 +1,241 @@
1
+ // Post-index summary (A1): a deterministic, human- AND machine-readable account of
2
+ // what an index run actually ingested — repos, per-class individual counts, edge
3
+ // counts, the language/extension mix, per-repo × per-extension MODULE counts, hub
4
+ // modules, history depth, and graph size vs the V8 string cap.
5
+ //
6
+ // The point is to stop the "misread 7,345 Functions as the symbol total" failure:
7
+ // SUMMARY.md/summary.json spell out the real totals next to the class breakdown, and
8
+ // the B9 verify-loop consumes summary.json (esp. the per-repo × per-ext counts) to
9
+ // compute indexing coverage.
10
+ //
11
+ // `buildSummary(entities, stats)` is a PURE function (no fs/process/Date) → a plain
12
+ // object (format:1). `renderSummaryMd` turns that object into markdown.
13
+ // `writeSummaryArtifacts` is the only IO — it writes summary.json + SUMMARY.md via
14
+ // temp+rename. These are NEW files; nothing here touches graph.json bytes.
15
+
16
+ import { writeFile, rename, mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+
19
+ // V8's hard maximum string length (chars). A graph.json larger than this can't be
20
+ // JSON.parse'd back into one string — the failure mode the wh estate hit at 80%.
21
+ // chars ≈ bytes for the ASCII-dominant graph, so we report the ratio labelled "~".
22
+ export const V8_STRING_CAP = 536_870_888;
23
+
24
+ // Module-level git history is collected to this depth by default (mirrors
25
+ // extract.mjs GIT_LOG_COMMITS). Used only to classify the history tier for the
26
+ // summary — kept local so summary.mjs has no import cycle back into extract.mjs.
27
+ const HISTORY_FULL_DEPTH = 300;
28
+
29
+ /** The file extension of a module path/label ("repo-a/pkg/a.py" → ".py"), lower-cased.
30
+ * A leading-dot basename (".env") or an extension-less path → "" (rendered "(none)"). */
31
+ function extOf(path) {
32
+ const s = String(path || "");
33
+ const slash = s.lastIndexOf("/");
34
+ const base = slash >= 0 ? s.slice(slash + 1) : s;
35
+ const dot = base.lastIndexOf(".");
36
+ return dot > 0 ? base.slice(dot).toLowerCase() : "";
37
+ }
38
+
39
+ /** A deterministic tier label from the effective module-history depth. */
40
+ function historyTier(nameDepth) {
41
+ if (!nameDepth) return "NONE";
42
+ return nameDepth < HISTORY_FULL_DEPTH ? "SHALLOW" : "FULL";
43
+ }
44
+
45
+ /** A new object with keys sorted ascending (stable JSON / MD output). */
46
+ const sortObj = (o) =>
47
+ Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
48
+
49
+ /** Thousands-separated integer, locale-independent (deterministic). */
50
+ const fmt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
51
+
52
+ /**
53
+ * Undirected degree over the module-coarse import+call edges, restricted to modules
54
+ * under `mod:<prefix>/` (or all `mod:` nodes when `prefix` is empty). Returns the top
55
+ * `top` as [{id, label, degree}], deterministic tiebreak = degree DESC then id ASC.
56
+ */
57
+ export function hubModules(entities, { prefix = "", top = 5 } = {}) {
58
+ const want = prefix ? `mod:${prefix}/` : "mod:";
59
+ const labelById = new Map();
60
+ for (const i of entities.individuals || []) {
61
+ if (i.class === "Module") labelById.set(i.id, i.label);
62
+ }
63
+ const degree = new Map();
64
+ const bump = (id) => degree.set(id, (degree.get(id) || 0) + 1);
65
+ for (const g of entities.objectProperties || []) {
66
+ if (g.predicate !== "imports" && g.predicate !== "calls") continue;
67
+ for (const e of g.examples || []) { bump(e.subject); bump(e.object); }
68
+ }
69
+ const rows = [];
70
+ for (const [id, deg] of degree) {
71
+ if (!id.startsWith(want)) continue;
72
+ rows.push({ id, label: labelById.get(id) ?? id.slice(4), degree: deg });
73
+ }
74
+ rows.sort((a, b) => (b.degree - a.degree) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
75
+ return rows.slice(0, top);
76
+ }
77
+
78
+ /**
79
+ * Build the summary object (format:1) — PURE and deterministic (same inputs →
80
+ * deep-equal output every time). `entities` is the assembled graph payload;
81
+ * `stats` carries the run-level facts the graph itself doesn't record:
82
+ * { mode:'single'|'multi',
83
+ * repos: [{ name, modules, commits, historyDepth:{name,symbol}, gitErrors:[{pass,message}] }],
84
+ * skipped:[{ name, reason }],
85
+ * languages:{ <lang>:{files,modules,symbols,…} },
86
+ * graphBytes: number }
87
+ */
88
+ export function buildSummary(entities, stats) {
89
+ const {
90
+ mode = "single", repos = [], skipped = [], languages = {}, graphBytes = 0,
91
+ } = stats || {};
92
+
93
+ // per-class individual counts (the fix for the "7,345 = total?" misread)
94
+ const classes = {};
95
+ for (const c of entities.classes || []) classes[c.name] = c.count;
96
+
97
+ // edge counts by predicate + grand total
98
+ const byPredicate = {};
99
+ let edgeTotal = 0;
100
+ for (const g of entities.objectProperties || []) {
101
+ byPredicate[g.predicate] = g.count;
102
+ edgeTotal += g.count;
103
+ }
104
+
105
+ // module → extension attribution, both overall and per-repo. In multi mode a
106
+ // module's owning repo is the leading `<name>/` path component; in single mode
107
+ // every module belongs to the one repo.
108
+ const repoNames = repos.map((r) => r.name);
109
+ const topExt = {};
110
+ const perRepoExt = new Map(); // name -> { ext -> count }
111
+ const ensure = (n) => { if (!perRepoExt.has(n)) perRepoExt.set(n, {}); return perRepoExt.get(n); };
112
+ for (const m of entities.individuals || []) {
113
+ if (m.class !== "Module") continue;
114
+ const label = m.label || m.id.slice(4);
115
+ const ext = extOf(label) || "(none)";
116
+ topExt[ext] = (topExt[ext] || 0) + 1;
117
+ const owner = mode === "single"
118
+ ? (repoNames[0] ?? null)
119
+ : (repoNames.find((n) => label.startsWith(`${n}/`)) ?? null);
120
+ if (owner != null) { const b = ensure(owner); b[ext] = (b[ext] || 0) + 1; }
121
+ }
122
+
123
+ // language mix — the stable {files,modules,symbols} subset of perLang
124
+ const languageMix = {};
125
+ for (const [lang, s] of Object.entries(languages)) {
126
+ languageMix[lang] = {
127
+ files: s.files || 0, modules: s.modules || 0, symbols: s.symbols || 0,
128
+ };
129
+ }
130
+
131
+ // effective history depth for the run = the deepest pass across repos
132
+ const depths = repos.map((r) => r.historyDepth || { name: 0, symbol: 0 });
133
+ const nameDepth = depths.length ? Math.max(...depths.map((d) => d.name || 0)) : 0;
134
+ const symbolDepth = depths.length ? Math.max(...depths.map((d) => d.symbol || 0)) : 0;
135
+
136
+ const repoRows = repos.map((r) => ({
137
+ name: r.name,
138
+ modules: r.modules || 0,
139
+ commits: r.commits || 0,
140
+ historyDepth: r.historyDepth || { name: 0, symbol: 0 },
141
+ gitErrors: (r.gitErrors || []).map((e) => ({ pass: e.pass, message: e.message })),
142
+ hubs: hubModules(entities, { prefix: mode === "multi" ? r.name : "", top: 5 }),
143
+ // per-repo × per-extension MODULE counts — the coverage unit B9 consumes
144
+ extensions: sortObj(perRepoExt.get(r.name) || {}),
145
+ }));
146
+
147
+ return {
148
+ format: 1,
149
+ mode,
150
+ graph: {
151
+ bytes: graphBytes,
152
+ capChars: V8_STRING_CAP,
153
+ capPct: `~${((graphBytes / V8_STRING_CAP) * 100).toFixed(1)}%`,
154
+ },
155
+ history: { tier: historyTier(nameDepth), depth: { name: nameDepth, symbol: symbolDepth } },
156
+ classes,
157
+ edges: { total: edgeTotal, byPredicate },
158
+ languages: languageMix,
159
+ extensions: sortObj(topExt),
160
+ repos: repoRows,
161
+ skipped: skipped.map((s) => ({ name: s.name, reason: s.reason })),
162
+ };
163
+ }
164
+
165
+ /** Render the summary object as human markdown. Includes one row per ingested repo
166
+ * and one per skipped dir (with its reason); a repo whose git history failed shows a
167
+ * FAILED history cell. */
168
+ export function renderSummaryMd(summary) {
169
+ const lines = [];
170
+ lines.push("# seonix index summary", "");
171
+ lines.push(`- mode: ${summary.mode}`);
172
+ lines.push(`- graph: ${fmt(summary.graph.bytes)} bytes (${summary.graph.capPct} of the V8 string cap)`);
173
+ lines.push(`- history: tier=${summary.history.tier} depth name=${summary.history.depth.name} symbol=${summary.history.depth.symbol}`);
174
+ const totalInds = Object.values(summary.classes).reduce((a, b) => a + b, 0);
175
+ lines.push(`- individuals: ${fmt(totalInds)} total`);
176
+ const classBits = Object.entries(summary.classes).map(([k, v]) => `${k} ${fmt(v)}`).join(", ");
177
+ lines.push(`- classes: ${classBits || "(none)"}`);
178
+ lines.push(`- edges: ${fmt(summary.edges.total)} total`);
179
+ lines.push("");
180
+
181
+ lines.push("## Repositories", "");
182
+ lines.push("| repo | modules | commits | history |");
183
+ lines.push("| --- | --- | --- | --- |");
184
+ for (const r of summary.repos) {
185
+ const hist = r.gitErrors.length
186
+ ? `FAILED: ${r.gitErrors.map((e) => e.message).join("; ")}`
187
+ : (r.historyDepth.name ? `ok (name=${r.historyDepth.name} symbol=${r.historyDepth.symbol})` : "none");
188
+ lines.push(`| ${r.name} | ${fmt(r.modules)} | ${fmt(r.commits)} | ${hist} |`);
189
+ }
190
+ for (const s of summary.skipped) {
191
+ lines.push(`| ${s.name} (skipped) | — | — | reason: ${s.reason} |`);
192
+ }
193
+ lines.push("");
194
+
195
+ if (Object.keys(summary.extensions).length) {
196
+ lines.push("## Module extensions", "");
197
+ for (const [ext, n] of Object.entries(summary.extensions)) lines.push(`- ${ext}: ${fmt(n)}`);
198
+ lines.push("");
199
+ }
200
+
201
+ lines.push("## Modules by repo × extension", "");
202
+ for (const r of summary.repos) {
203
+ const bits = Object.entries(r.extensions).map(([e, n]) => `${e} ${fmt(n)}`).join(", ") || "(none)";
204
+ lines.push(`- ${r.name}: ${bits}`);
205
+ }
206
+ lines.push("");
207
+
208
+ lines.push("## Hub modules", "");
209
+ for (const r of summary.repos) {
210
+ const bits = r.hubs.map((h) => `${h.label} (${h.degree})`).join(", ") || "(none)";
211
+ lines.push(`- ${r.name}: ${bits}`);
212
+ }
213
+ lines.push("");
214
+
215
+ if (Object.keys(summary.languages).length) {
216
+ lines.push("## Languages", "");
217
+ for (const [lang, s] of Object.entries(summary.languages)) {
218
+ lines.push(`- ${lang}: ${fmt(s.modules)} modules, ${fmt(s.symbols)} symbols, ${fmt(s.files)} files`);
219
+ }
220
+ lines.push("");
221
+ }
222
+
223
+ return lines.join("\n");
224
+ }
225
+
226
+ /** Write summary.json + SUMMARY.md into `artifactDir` (the `.seonix` dir), each via a
227
+ * temp file + atomic rename so a reader never sees a half-written artifact. */
228
+ export async function writeSummaryArtifacts(artifactDir, summary) {
229
+ await mkdir(artifactDir, { recursive: true });
230
+ const jsonPath = join(artifactDir, "summary.json");
231
+ const mdPath = join(artifactDir, "SUMMARY.md");
232
+ await writeViaTmp(jsonPath, `${JSON.stringify(summary, null, 2)}\n`);
233
+ await writeViaTmp(mdPath, renderSummaryMd(summary));
234
+ return { jsonPath, mdPath };
235
+ }
236
+
237
+ async function writeViaTmp(path, content) {
238
+ const tmp = `${path}.tmp-${process.pid}`;
239
+ await writeFile(tmp, content);
240
+ await rename(tmp, path);
241
+ }
@@ -0,0 +1,90 @@
1
+ // telemetry.mjs — OPT-IN, fire-and-forget query telemetry (PLAN_SEONIX_TELEMETRY.md).
2
+ //
3
+ // The measurement contract is absolute: telemetry is OFF by default and the OFF path
4
+ // must be BYTE-IDENTICAL — no file, no stdout/stderr change, ~zero cost. createTelemetry
5
+ // returns NULL when disabled, so every call site is a single falsy check: `tel?.record(…)`.
6
+ //
7
+ // When enabled it appends ONE JSONL line per event to `<graphDir>/seonix-<id>.log`
8
+ // (i.e. next to graph.json, in the repo's `.seonix/` artifact dir). Writes are
9
+ // fire-and-forget with a swallowed catch: telemetry must NEVER throw, block, or write
10
+ // to stdout/stderr (stdout is the MCP transport). A structural redact() keeps file
11
+ // CONTENTS out of the log — it records only ids/paths/scores/sizes/counts.
12
+
13
+ import { appendFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { uuidv7 } from "./uuid.mjs";
16
+
17
+ /** Field names whose VALUES are (or embed) raw source and must never be logged. */
18
+ const DROP_KEYS = new Set(["text", "content", "snippet"]);
19
+ /** String fields longer than this are truncated — except query.raw (the user's own
20
+ * question, which is the correlation key and is not file content). */
21
+ const MAX_STR = 500;
22
+
23
+ /** Enabled iff `SEONIX_TELEMETRY==="1"` OR `[telemetry] enabled=true` in the toml.
24
+ * The env wins BOTH directions: `SEONIX_TELEMETRY==="0"` force-disables even when the
25
+ * toml turns it on. Default OFF (anything but "1"/"0" in the env falls through to toml). */
26
+ export function telemetryEnabled(env = process.env, toml = null) {
27
+ const e = env?.SEONIX_TELEMETRY;
28
+ if (e === "1") return true;
29
+ if (e === "0") return false;
30
+ return toml?.telemetry?.enabled === true;
31
+ }
32
+
33
+ /** The invocation id that correlates a log ↔ a host process. A host (the bench rig,
34
+ * a wrapping agent) may stamp `SEONIX_INVOCATION_ID` so its trace/record and this log
35
+ * share one id; absent, we mint a fresh time-sortable uuidv7. */
36
+ export function invocationId(env = process.env) {
37
+ return (env?.SEONIX_INVOCATION_ID && String(env.SEONIX_INVOCATION_ID)) || uuidv7();
38
+ }
39
+
40
+ /** Structural redaction: drop any field named text/content/snippet at any depth, and
41
+ * truncate string fields longer than MAX_STR — EXCEPT `query.raw`. Records only
42
+ * ids/paths/scores/sizes/counts, never file contents. Pure; returns a fresh value. */
43
+ export function redact(value, path = "") {
44
+ if (typeof value === "string") {
45
+ if (path === "query.raw") return value; // the correlation key — kept whole
46
+ return value.length > MAX_STR ? value.slice(0, MAX_STR) : value;
47
+ }
48
+ if (Array.isArray(value)) return value.map((v) => redact(v, path));
49
+ if (value && typeof value === "object") {
50
+ const out = {};
51
+ for (const [k, v] of Object.entries(value)) {
52
+ if (DROP_KEYS.has(k)) continue;
53
+ out[k] = redact(v, path ? `${path}.${k}` : k);
54
+ }
55
+ return out;
56
+ }
57
+ return value;
58
+ }
59
+
60
+ /**
61
+ * Build a telemetry sink for one surface, or NULL when telemetry is disabled (the
62
+ * common OFF path — the caller's `tel?.record(…)` then costs one falsy check).
63
+ *
64
+ * When enabled, returns { id, file, record(fields) }. `record` stamps the schema-v1
65
+ * envelope ({v, id, seq, ts, surface}) onto the redacted, sparse caller fields and
66
+ * appends ONE JSONL line, fire-and-forget with a swallowed catch. `seq` is strictly
67
+ * increasing per sink. Record schema v1 fields (all sparse/optional — only include
68
+ * what a surface actually has):
69
+ * { v, id, seq, ts, surface, tool,
70
+ * query: { raw, family, ambiguous, candidates },
71
+ * response: { node_ids, scores, count, truncated, tier, topup },
72
+ * perf: { ms_total, ms_load, cold_load, graph_modules, graph_edges },
73
+ * quality: {},
74
+ * cost: { returned_chars, returned_tokens_est, cache_channel } }
75
+ */
76
+ export function createTelemetry({ env = process.env, config, toml = null, surface } = {}) {
77
+ if (!telemetryEnabled(env, toml)) return null;
78
+ const id = invocationId(env);
79
+ const file = join(dirname(config.graphFile), `seonix-${id}.log`);
80
+ let seq = 0;
81
+ const record = (fields = {}) => {
82
+ try {
83
+ const line = { v: 1, id, seq: seq++, ts: new Date().toISOString(), surface, ...redact(fields) };
84
+ // Fire-and-forget: never await, never let a write error surface. Telemetry must
85
+ // not block a query or corrupt the MCP stdout transport.
86
+ appendFile(file, JSON.stringify(line) + "\n").catch(() => {});
87
+ } catch { /* redaction/serialisation must never throw a caller's turn */ }
88
+ };
89
+ return { id, file, record };
90
+ }
package/src/timeline.mjs CHANGED
@@ -15,7 +15,7 @@ import { repoOfId, assignRepo, isMultiRepo } from "./temporal.mjs";
15
15
  const esc = (s) =>
16
16
  String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
17
17
 
18
- export function extractTimeline(graph) {
18
+ export function extractTimeline(graph, { limit = 0 } = {}) {
19
19
  const commits = [];
20
20
  const sessions = [];
21
21
  const churnByShort = new Map(); // short sha -> modules touched
@@ -66,7 +66,9 @@ export function extractTimeline(graph) {
66
66
  }
67
67
  const entries = [...commits, ...sessions];
68
68
  entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
69
- return entries;
69
+ // limit > 0 keeps only the newest N entries (the tail of the oldest→newest sort);
70
+ // 0 (the default) returns everything. The ARRAY return type is unchanged either way.
71
+ return limit > 0 ? entries.slice(-limit) : entries;
70
72
  }
71
73
 
72
74
  // Per-repo badge palette (multi-repo timelines only) — hues distinct from the default
@@ -78,10 +80,16 @@ const repoColorMap = (repos) => new Map(repos.map((r, i) => [r, REPO_PALETTE[i %
78
80
  /** `nav` is the shared {name: href} nav object the viz CLI computes from the
79
81
  * actual sibling output paths — header links render ONLY from its entries, so
80
82
  * a page generated without siblings never carries dead links. */
81
- export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", generatedAt = "", nav = null } = {}) {
83
+ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", generatedAt = "", nav = null, totalCommits = null } = {}) {
82
84
  const base = repoUrl.replace(/\/+$/, "");
83
85
  const sessionCount = commits.filter((c) => c.type === "session").length;
84
86
  const commitCount = commits.length - sessionCount;
87
+ // When the timeline was truncated (extractTimeline {limit}), the caller passes the
88
+ // TRUE Commit total so the header reads "newest <n> of <total> commits". Absent, or
89
+ // not actually a truncation (total ≤ rendered), the header is byte-unchanged.
90
+ const commitLabel = totalCommits != null && totalCommits > commitCount
91
+ ? `newest ${commitCount} of ${totalCommits} commits`
92
+ : `${commitCount} commits`;
85
93
  const maxTouched = Math.max(1, ...commits.map((c) => c.touched));
86
94
  // P4 cross-repo: extractTimeline tags each commit of a MERGED graph with its `repo`.
87
95
  // When present, entries are labelled and colour-badged by repo on one global time
@@ -147,7 +155,7 @@ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", ge
147
155
  li.sess{border-left:3px solid #e0af68;padding-left:5px} li.sess code,li.sess .m{color:#e0af68}
148
156
  </style></head><body>
149
157
  <header>
150
- <span><b>seonix</b> commit timeline — ${commitCount} commits${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}${multi ? ` · ${repos.length} repos` : ""}</span>${multi ? `\n <span class="repolegend">${repoLegend}</span>` : ""}
158
+ <span><b>seonix</b> commit timeline — ${commitLabel}${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}${multi ? ` · ${repos.length} repos` : ""}</span>${multi ? `\n <span class="repolegend">${repoLegend}</span>` : ""}
151
159
  ${navLinks}
152
160
  ${base ? `<a href="${base}" target="_blank" rel="noopener">repository ↗</a>` : ""}
153
161
  <span class="meta">bars = modules touched per commit · generated from the seonix graph artifact${generatedAt ? ` (${esc(generatedAt.slice(0, 10))})` : ""}</span>