@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// graph-format.mjs — the on-disk graph wire format (A6: id-interned "v2").
|
|
2
|
+
//
|
|
3
|
+
// SERIALIZATION ONLY. The in-memory `entities` shape every reader/renderer consumes
|
|
4
|
+
// is unchanged: `{ generated_at, prefixes, vocabulary, classes, objectProperties:
|
|
5
|
+
// [{predicate, prop, count, examples:[{subject, object, subjectLabel, objectLabel,
|
|
6
|
+
// weight?}]}], individuals:[{id, label, class, …}], proseIndex, … }`. This module only
|
|
7
|
+
// changes how that object is written to / read back from graph.json.
|
|
8
|
+
//
|
|
9
|
+
// WHY. Every edge stores its endpoints twice (an id string AND that endpoint's label),
|
|
10
|
+
// so a large graph spends most of its bytes re-quoting a few thousand ids/labels that
|
|
11
|
+
// already appear verbatim in `individuals`. v2 interns them: `individuals` order IS the
|
|
12
|
+
// id table (index 0 = individuals[0].id, …), each edge is a flat int pair into that
|
|
13
|
+
// table, and a label is stored ONLY when it diverges from the endpoint node's own label
|
|
14
|
+
// (the "R0" default rule). The bytes an edge no longer carries are exactly the redundant
|
|
15
|
+
// ones. Expansion shares the SAME id/label string reference from `individuals`, so the
|
|
16
|
+
// in-memory graph also dedupes those strings (the interning win realized at read time).
|
|
17
|
+
//
|
|
18
|
+
// EXCEPTION LABELS (the only labels v2 stores, per the graph's edge builders in
|
|
19
|
+
// extract.mjs — see STRATEGY_ADVISOR.log a6-label-exceptions):
|
|
20
|
+
// - inherits (seon:hasSuperType) objectLabel = the RAW base expression (e.g. `Base[T]`,
|
|
21
|
+
// `argparse.Action`), which diverges from the resolved base node's label — and the
|
|
22
|
+
// object may be a NON-NODE `ext:<ident>` endpoint with no individual at all.
|
|
23
|
+
// - contains (seon:containsCodeEntity) objectLabel = the member short name, vs the
|
|
24
|
+
// member node's full dotted label.
|
|
25
|
+
// - callsSymbol (mgx:callsSymbol) objectLabel = the callee's last identifier, which can
|
|
26
|
+
// diverge from the callee node's full label.
|
|
27
|
+
// - cochange (mgx:changeCoupledWith) carries a numeric `weight` no id/label can encode.
|
|
28
|
+
// The encoder does not special-case predicates: it stores any label that differs from R0
|
|
29
|
+
// and any weight present, which subsumes all of the above and is correct by construction.
|
|
30
|
+
//
|
|
31
|
+
// SAFETY. A corrupt v2 artifact (an endpoint index past the id table) must NEVER silently
|
|
32
|
+
// mis-resolve to the wrong node — `expandGraphPayload` THROWS, naming the group + offset.
|
|
33
|
+
|
|
34
|
+
export const GRAPH_FORMAT_CURRENT = 2;
|
|
35
|
+
|
|
36
|
+
/** Build a Map id → first individual with that id (the id table lookup), and a Map id →
|
|
37
|
+
* its array index. Order is authoritative: index N is the Nth individual. */
|
|
38
|
+
function indexIndividuals(individuals) {
|
|
39
|
+
const byId = new Map();
|
|
40
|
+
const idToIndex = new Map();
|
|
41
|
+
for (let i = 0; i < individuals.length; i += 1) {
|
|
42
|
+
const id = individuals[i]?.id;
|
|
43
|
+
if (id == null) continue;
|
|
44
|
+
if (!idToIndex.has(id)) idToIndex.set(id, i);
|
|
45
|
+
if (!byId.has(id)) byId.set(id, individuals[i]);
|
|
46
|
+
}
|
|
47
|
+
return { byId, idToIndex };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Encode an in-memory `entities` payload to the interned v2 wire form.
|
|
52
|
+
* Pure — does NOT mutate `entities` (shallow-copies the top level, replaces
|
|
53
|
+
* `objectProperties`, shares the `individuals` array by reference).
|
|
54
|
+
* `expandGraphPayload(encodeGraphV2(e))` deep-equals `e` BY CONSTRUCTION, and preserves
|
|
55
|
+
* top-level + group + edge key ORDER (so `JSON.stringify` round-trips byte-for-byte).
|
|
56
|
+
*/
|
|
57
|
+
export function encodeGraphV2(entities) {
|
|
58
|
+
const individuals = Array.isArray(entities?.individuals) ? entities.individuals : [];
|
|
59
|
+
const { byId, idToIndex } = indexIndividuals(individuals);
|
|
60
|
+
const base = individuals.length;
|
|
61
|
+
|
|
62
|
+
// Non-node endpoints (the only ones today: `ext:<ident>` inherits bases) get appended
|
|
63
|
+
// to a side table; their index is base + position. First-seen order is stable.
|
|
64
|
+
const extIds = [];
|
|
65
|
+
const extIndex = new Map();
|
|
66
|
+
const indexOf = (id) => {
|
|
67
|
+
if (idToIndex.has(id)) return idToIndex.get(id);
|
|
68
|
+
if (extIndex.has(id)) return extIndex.get(id);
|
|
69
|
+
const idx = base + extIds.length;
|
|
70
|
+
extIds.push(id);
|
|
71
|
+
extIndex.set(id, idx);
|
|
72
|
+
return idx;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const groups = Array.isArray(entities?.objectProperties) ? entities.objectProperties : [];
|
|
76
|
+
const encodedGroups = groups.map((g) => {
|
|
77
|
+
const examples = Array.isArray(g?.examples) ? g.examples : [];
|
|
78
|
+
const pairs = [];
|
|
79
|
+
const labels = {};
|
|
80
|
+
const weights = {};
|
|
81
|
+
examples.forEach((e, i) => {
|
|
82
|
+
pairs.push(indexOf(e.subject), indexOf(e.object));
|
|
83
|
+
// R0 default = the endpoint node's own label; store only the divergences.
|
|
84
|
+
const sDefault = byId.get(e.subject)?.label;
|
|
85
|
+
const oDefault = byId.get(e.object)?.label;
|
|
86
|
+
if (e.subjectLabel !== sDefault) labels[`${i}s`] = e.subjectLabel;
|
|
87
|
+
if (e.objectLabel !== oDefault) labels[`${i}o`] = e.objectLabel;
|
|
88
|
+
if (e.weight !== undefined) weights[i] = e.weight;
|
|
89
|
+
});
|
|
90
|
+
// Preserve group key order (predicate, prop, count) so round-trip is byte-stable;
|
|
91
|
+
// omit empty label/weight maps so a label-free graph stays lean.
|
|
92
|
+
const out = { predicate: g.predicate, prop: g.prop, count: g.count, pairs };
|
|
93
|
+
if (Object.keys(labels).length) out.labels = labels;
|
|
94
|
+
if (Object.keys(weights).length) out.weights = weights;
|
|
95
|
+
return out;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Spread first so every existing top-level key keeps its position (and value, except the
|
|
99
|
+
// two we replace); `format`/`extIds` append. `individuals` re-listed for clarity (same ref).
|
|
100
|
+
return { ...entities, format: GRAPH_FORMAT_CURRENT, individuals, extIds, objectProperties: encodedGroups };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Expand a parsed graph payload back to the in-memory `entities` shape. PASSTHROUGH for
|
|
105
|
+
* any payload whose `format` is not the interned current (v1 / legacy artifacts, and store
|
|
106
|
+
* payloads which are already the in-memory shape) — returns it untouched. For a v2 payload,
|
|
107
|
+
* reconstructs each edge, sharing the id/label string references from `individuals`.
|
|
108
|
+
* THROWS (never silently mis-resolves) on an endpoint index outside the id + ext table.
|
|
109
|
+
*/
|
|
110
|
+
export function expandGraphPayload(payload) {
|
|
111
|
+
if (!payload || payload.format !== GRAPH_FORMAT_CURRENT) return payload;
|
|
112
|
+
|
|
113
|
+
const individuals = Array.isArray(payload.individuals) ? payload.individuals : [];
|
|
114
|
+
const extIds = Array.isArray(payload.extIds) ? payload.extIds : [];
|
|
115
|
+
const base = individuals.length;
|
|
116
|
+
const { byId } = indexIndividuals(individuals);
|
|
117
|
+
|
|
118
|
+
const idAt = (idx, where) => {
|
|
119
|
+
if (idx >= 0 && idx < base) return individuals[idx].id;
|
|
120
|
+
const e = idx - base;
|
|
121
|
+
if (e >= 0 && e < extIds.length) return extIds[e];
|
|
122
|
+
throw new Error(
|
|
123
|
+
`seonix graph-format v2: corrupt endpoint index ${idx} at ${where} `
|
|
124
|
+
+ `(id table has ${base} individuals + ${extIds.length} extIds)`,
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const groups = Array.isArray(payload.objectProperties) ? payload.objectProperties : [];
|
|
129
|
+
const objectProperties = groups.map((g) => {
|
|
130
|
+
const pairs = Array.isArray(g?.pairs) ? g.pairs : [];
|
|
131
|
+
const labels = g?.labels || {};
|
|
132
|
+
const weights = g?.weights || {};
|
|
133
|
+
const examples = [];
|
|
134
|
+
for (let i = 0; i < pairs.length; i += 2) {
|
|
135
|
+
const ei = i / 2;
|
|
136
|
+
const subject = idAt(pairs[i], `${g.prop} pair ${ei} (subject)`);
|
|
137
|
+
const object = idAt(pairs[i + 1], `${g.prop} pair ${ei} (object)`);
|
|
138
|
+
const sKey = `${ei}s`;
|
|
139
|
+
const oKey = `${ei}o`;
|
|
140
|
+
const subjectLabel = Object.prototype.hasOwnProperty.call(labels, sKey) ? labels[sKey] : byId.get(subject)?.label;
|
|
141
|
+
const objectLabel = Object.prototype.hasOwnProperty.call(labels, oKey) ? labels[oKey] : byId.get(object)?.label;
|
|
142
|
+
// Canonical edge key order (subject, object, subjectLabel, objectLabel, weight?) matches
|
|
143
|
+
// the writer's, so a re-serialized expanded graph is byte-identical to a v1 write.
|
|
144
|
+
const edge = { subject, object, subjectLabel, objectLabel };
|
|
145
|
+
if (Object.prototype.hasOwnProperty.call(weights, String(ei))) edge.weight = weights[ei];
|
|
146
|
+
examples.push(edge);
|
|
147
|
+
}
|
|
148
|
+
return { predicate: g.predicate, prop: g.prop, count: g.count, examples };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Restore the in-memory shape: original top-level keys in order, minus the v2-only ones.
|
|
152
|
+
const out = { ...payload, individuals, objectProperties };
|
|
153
|
+
delete out.format;
|
|
154
|
+
delete out.extIds;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// graph-ops.mjs (A7) — pure, in-place-free transforms over an assembled `entities`
|
|
2
|
+
// graph. Today: `dropByRepoPrefix`, the removals-only fast path for estate sync.
|
|
3
|
+
//
|
|
4
|
+
// SEMANTIC CAVEAT (why this is a FAST PATH, not the default). buildEntities resolves
|
|
5
|
+
// calls / inherits / bases through a GLOBAL unique-name registry that spans EVERY repo
|
|
6
|
+
// in the merged graph — a callee name defined in exactly one repo resolves; the same
|
|
7
|
+
// name defined in two repos is dropped as ambiguous. So you can NOT reconstruct the
|
|
8
|
+
// full graph by concatenating per-repo sub-graphs, and you can NOT surgically ADD a
|
|
9
|
+
// repo without possibly changing how names resolve elsewhere. REMOVING a repo is the
|
|
10
|
+
// one direction that IS locally safe: dropping a repo's individuals + the edges that
|
|
11
|
+
// touch them yields a graph that is a strict subset of a from-scratch re-index — the
|
|
12
|
+
// only imprecision is that a name which was ambiguous BECAUSE of the removed repo would
|
|
13
|
+
// now resolve, which this function deliberately does NOT re-derive (it only deletes).
|
|
14
|
+
// `syncRepositories` therefore uses the exact union-rebuild by default and reserves
|
|
15
|
+
// this for the removals-only fast path (and future in-place sqlite deltas); a caller
|
|
16
|
+
// choosing it should log that caveat.
|
|
17
|
+
|
|
18
|
+
/** Escape a string for use as a literal inside a RegExp. */
|
|
19
|
+
const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
20
|
+
|
|
21
|
+
/** Filter dropped ids out of a proseIndex (`word -> [id]`), deleting a word whose
|
|
22
|
+
* posting becomes empty. Non-array values (e.g. a nested `seonix:layers` block) pass
|
|
23
|
+
* through untouched — this function only prunes the flat verbatim postings. */
|
|
24
|
+
function filterProse(proseIndex, survivingIds) {
|
|
25
|
+
if (!proseIndex || typeof proseIndex !== "object") return proseIndex;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [word, posting] of Object.entries(proseIndex)) {
|
|
28
|
+
if (Array.isArray(posting)) {
|
|
29
|
+
const kept = posting.filter((id) => survivingIds.has(id));
|
|
30
|
+
if (kept.length) out[word] = kept;
|
|
31
|
+
} else {
|
|
32
|
+
out[word] = posting;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return a NEW `entities` with one repo's contribution removed (the merged-graph
|
|
40
|
+
* removals-only fast path — see the module caveat). `prefix` is a repo name as assigned
|
|
41
|
+
* by assignRepoPrefixes.
|
|
42
|
+
*
|
|
43
|
+
* Drops:
|
|
44
|
+
* - individuals whose id matches `^(mod|fn):<prefix>/` (Module/Function/Method/… nodes);
|
|
45
|
+
* - every objectProperties edge whose subject OR object is one of those dropped ids
|
|
46
|
+
* (each group's `count` recomputed);
|
|
47
|
+
* - a Commit individual ONLY when no surviving `touches`/`touchesSymbol` edge still
|
|
48
|
+
* references it — commit ids are `commit:<sha>` (NOT repo-prefixed) and are SHARED
|
|
49
|
+
* across repos, so a commit that also touched another repo's modules survives;
|
|
50
|
+
* - dropped ids from the proseIndex postings (emptied words deleted).
|
|
51
|
+
*
|
|
52
|
+
* Recomputes classes[].count/sample from the survivors; leaves prefixes/vocabulary/
|
|
53
|
+
* format/generated_at and any other top-level keys untouched. Pure (no mutation of the
|
|
54
|
+
* input).
|
|
55
|
+
*/
|
|
56
|
+
export function dropByRepoPrefix(entities, prefix) {
|
|
57
|
+
const re = new RegExp(`^(mod|fn):${escapeRe(prefix)}/`);
|
|
58
|
+
const individuals = Array.isArray(entities?.individuals) ? entities.individuals : [];
|
|
59
|
+
|
|
60
|
+
// 1. Node ids to drop (never a bare commit: — those are shared and handled below).
|
|
61
|
+
const dropped = new Set();
|
|
62
|
+
for (const ind of individuals) if (re.test(ind.id)) dropped.add(ind.id);
|
|
63
|
+
|
|
64
|
+
// 2. Filter edges; note which commits still have a surviving touches/touchesSymbol.
|
|
65
|
+
const referencedCommits = new Set();
|
|
66
|
+
const objectProperties = (entities.objectProperties || []).map((g) => {
|
|
67
|
+
const examples = (g.examples || []).filter((e) => !dropped.has(e.subject) && !dropped.has(e.object));
|
|
68
|
+
if (g.predicate === "touches" || g.predicate === "touchesSymbol") {
|
|
69
|
+
for (const e of examples) {
|
|
70
|
+
if (typeof e.subject === "string" && e.subject.startsWith("commit:")) referencedCommits.add(e.subject);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { ...g, count: examples.length, examples };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 3. Survivors: drop the prefixed nodes, and any Commit no edge references any more.
|
|
77
|
+
const survivingIndividuals = individuals.filter((ind) => {
|
|
78
|
+
if (dropped.has(ind.id)) return false;
|
|
79
|
+
if (ind.class === "Commit") return referencedCommits.has(ind.id);
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
const survivingIds = new Set(survivingIndividuals.map((i) => i.id));
|
|
83
|
+
|
|
84
|
+
// 4. Prose postings + class counts/samples, all recomputed from the survivors.
|
|
85
|
+
const proseIndex = filterProse(entities.proseIndex, survivingIds);
|
|
86
|
+
const classes = (entities.classes || []).map((c) => {
|
|
87
|
+
const members = survivingIndividuals.filter((i) => i.class === c.name);
|
|
88
|
+
return { ...c, count: members.length, sample: members.slice(0, 3).map((i) => i.label) };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return { ...entities, individuals: survivingIndividuals, objectProperties, classes, proseIndex };
|
|
92
|
+
}
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// manifest.mjs (A7) — the estate manifest: a per-repo fingerprint record that lets
|
|
2
|
+
// `seonix sync` decide which member repos of a multi_root estate actually changed
|
|
3
|
+
// since the last index, so only those are re-extracted.
|
|
4
|
+
//
|
|
5
|
+
// A manifest is `{ format, generated_at, repos: [row] }` written to
|
|
6
|
+
// `<multi_root>/.seonix/manifest.json`. Each row is
|
|
7
|
+
// { name, path, kind: "git"|"gitparent"|"plain",
|
|
8
|
+
// fingerprint: { head?, dirty? } | { walk? },
|
|
9
|
+
// history_depth, indexed_at_head, modules, commits }.
|
|
10
|
+
//
|
|
11
|
+
// FINGERPRINTS (all hashes sha256 via node:crypto):
|
|
12
|
+
// git — a child directory with its OWN .git: { head: rev-parse HEAD,
|
|
13
|
+
// dirty: sha256(git status --porcelain) }.
|
|
14
|
+
// gitparent — a plain child of a multi_root that is ITSELF inside a git working
|
|
15
|
+
// tree (a monorepo subdir): { head: git -C <root> log -1 --format=%H --
|
|
16
|
+
// <subdir>, dirty: sha256(scoped `git status --porcelain -- <subdir>`) }.
|
|
17
|
+
// plain — a child with no git at all: { walk: sha256 of the sorted
|
|
18
|
+
// "relpath\tsize\tmtimeMs" lines over the INDEXER'S OWN walk (same
|
|
19
|
+
// walk/loadIgnores → same SKIP_DIRS/.seonixignore, so the fingerprint's
|
|
20
|
+
// blind spots match the graph's exactly) }.
|
|
21
|
+
//
|
|
22
|
+
// Everything here is pure/deterministic except the fs+git reads a fingerprint needs;
|
|
23
|
+
// `diffManifest` is pure.
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
|
|
28
|
+
import { join, resolve, relative, sep } from "node:path";
|
|
29
|
+
import { walk, loadIgnores } from "./walk.mjs";
|
|
30
|
+
import { LANG_EXTS } from "./extract_lang.mjs";
|
|
31
|
+
|
|
32
|
+
// Every extension the indexer covers (Python + the multi-language front-end); mirrors
|
|
33
|
+
// extract.mjs's INDEXED_EXTS so the plain-kind walk fingerprint sees the same files the
|
|
34
|
+
// graph is built from.
|
|
35
|
+
const INDEXED_EXTS = [".py", ...LANG_EXTS];
|
|
36
|
+
|
|
37
|
+
const sha256 = (s) => createHash("sha256").update(String(s)).digest("hex");
|
|
38
|
+
|
|
39
|
+
/** Run a git subcommand synchronously; never throws. → { code, stdout, stderr }. */
|
|
40
|
+
function git(cwd, args) {
|
|
41
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
42
|
+
return { code: r.status ?? -1, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Classify a candidate estate member. `multiRoot` (optional) enables the "gitparent"
|
|
46
|
+
* case: when the member has no .git of its own BUT the estate root is inside a git
|
|
47
|
+
* working tree, the member is a tracked subdir of that outer repo. Without a
|
|
48
|
+
* `multiRoot`, a non-.git member is always "plain".
|
|
49
|
+
* → { kind, root, subdir } where `root` is the git toplevel (git/gitparent) or the
|
|
50
|
+
* member path (plain) and `subdir` is the member's path relative to `root` (gitparent). */
|
|
51
|
+
export async function classifyRepo(repoPath, { multiRoot } = {}) {
|
|
52
|
+
const p = resolve(repoPath);
|
|
53
|
+
const hasOwnGit = await stat(join(p, ".git")).then(() => true).catch(() => false);
|
|
54
|
+
if (hasOwnGit) return { kind: "git", root: p, subdir: "" };
|
|
55
|
+
if (multiRoot) {
|
|
56
|
+
// Ask git itself for the enclosing repo root + this dir's prefix within it — git
|
|
57
|
+
// reports canonical (realpath'd) paths, sidestepping the macOS /var → /private/var
|
|
58
|
+
// symlink mismatch a manual `relative()` would trip over.
|
|
59
|
+
const top = git(p, ["rev-parse", "--show-toplevel"]);
|
|
60
|
+
const prefix = git(p, ["rev-parse", "--show-prefix"]);
|
|
61
|
+
if (top.code === 0 && top.stdout.trim() && prefix.code === 0) {
|
|
62
|
+
const subdir = prefix.stdout.trim().replace(/\/+$/, "");
|
|
63
|
+
if (subdir) return { kind: "gitparent", root: top.stdout.trim(), subdir };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { kind: "plain", root: p, subdir: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fingerprint one estate member. → { kind, fingerprint } (see the module header). */
|
|
70
|
+
export async function fingerprintRepo(repoPath, { multiRoot, exts = INDEXED_EXTS } = {}) {
|
|
71
|
+
const { kind, root, subdir } = await classifyRepo(repoPath, { multiRoot });
|
|
72
|
+
|
|
73
|
+
if (kind === "git") {
|
|
74
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
75
|
+
const porcelain = git(root, ["status", "--porcelain"]);
|
|
76
|
+
return { kind, fingerprint: { head: head.code === 0 ? head.stdout.trim() : "", dirty: sha256(porcelain.stdout) } };
|
|
77
|
+
}
|
|
78
|
+
if (kind === "gitparent") {
|
|
79
|
+
const head = git(root, ["log", "-1", "--format=%H", "--", subdir]);
|
|
80
|
+
const porcelain = git(root, ["status", "--porcelain", "--", subdir]);
|
|
81
|
+
return { kind, fingerprint: { head: head.code === 0 ? head.stdout.trim() : "", dirty: sha256(porcelain.stdout) } };
|
|
82
|
+
}
|
|
83
|
+
// plain: hash the indexer's own file surface (relpath + size + mtimeMs).
|
|
84
|
+
const ignore = await loadIgnores(repoPath);
|
|
85
|
+
const files = await walk(repoPath, exts, ignore);
|
|
86
|
+
const rows = [];
|
|
87
|
+
for (const abs of files) {
|
|
88
|
+
const st = await stat(abs).catch(() => null);
|
|
89
|
+
if (!st) continue;
|
|
90
|
+
const rel = relative(repoPath, abs).split(sep).join("/");
|
|
91
|
+
rows.push(`${rel}\t${st.size}\t${st.mtimeMs}`);
|
|
92
|
+
}
|
|
93
|
+
rows.sort();
|
|
94
|
+
return { kind, fingerprint: { walk: sha256(rows.join("\n")) } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when two rows describe the same source state (same kind + fingerprint). Absent
|
|
98
|
+
* fingerprint fields compare equal (undefined === undefined), so this works across all
|
|
99
|
+
* three kinds without knowing which fields a kind uses. */
|
|
100
|
+
export function sameFingerprint(a, b) {
|
|
101
|
+
if (!a || !b || a.kind !== b.kind) return false;
|
|
102
|
+
const fa = a.fingerprint || {};
|
|
103
|
+
const fb = b.fingerprint || {};
|
|
104
|
+
return fa.head === fb.head && fa.dirty === fb.dirty && fa.walk === fb.walk;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* PURE categorization of a `current` row set against a `previous` one, keyed by `name`.
|
|
109
|
+
* → { added, changed, removed, unchanged } — arrays of the CURRENT rows (removed carries
|
|
110
|
+
* the PREVIOUS rows, since those names are gone from current).
|
|
111
|
+
*/
|
|
112
|
+
export function diffManifest(current, previous) {
|
|
113
|
+
const prevByName = new Map((previous || []).map((r) => [r.name, r]));
|
|
114
|
+
const curByName = new Map((current || []).map((r) => [r.name, r]));
|
|
115
|
+
const added = [];
|
|
116
|
+
const changed = [];
|
|
117
|
+
const unchanged = [];
|
|
118
|
+
for (const r of current || []) {
|
|
119
|
+
const p = prevByName.get(r.name);
|
|
120
|
+
if (!p) added.push(r);
|
|
121
|
+
else if (sameFingerprint(r, p)) unchanged.push(r);
|
|
122
|
+
else changed.push(r);
|
|
123
|
+
}
|
|
124
|
+
const removed = (previous || []).filter((r) => !curByName.has(r.name));
|
|
125
|
+
return { added, changed, removed, unchanged };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read `<artifactDir>/manifest.json` → the manifest object, or an empty manifest when
|
|
129
|
+
* absent/unreadable/malformed (so a first sync sees every repo as "added"). */
|
|
130
|
+
export async function readManifest(artifactDir) {
|
|
131
|
+
try {
|
|
132
|
+
const m = JSON.parse(await readFile(join(artifactDir, "manifest.json"), "utf8"));
|
|
133
|
+
return Array.isArray(m?.repos) ? m : { format: 1, repos: [] };
|
|
134
|
+
} catch {
|
|
135
|
+
return { format: 1, repos: [] };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Write `<artifactDir>/manifest.json` via temp+rename (a reader never sees a partial
|
|
140
|
+
* file). Returns the written path. */
|
|
141
|
+
export async function writeManifest(artifactDir, manifest) {
|
|
142
|
+
await mkdir(artifactDir, { recursive: true });
|
|
143
|
+
const path = join(artifactDir, "manifest.json");
|
|
144
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
145
|
+
await writeFile(tmp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
146
|
+
await rename(tmp, path);
|
|
147
|
+
return path;
|
|
148
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
20
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
21
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
22
22
|
import { loadConfig, ToolError } from "./config.mjs";
|
|
23
|
+
import { createTelemetry } from "./telemetry.mjs";
|
|
23
24
|
import * as defaultSource from "./source.mjs";
|
|
24
25
|
import {
|
|
25
26
|
parseEntities,
|
|
@@ -418,13 +419,23 @@ export function buildServer({ config = loadConfig(), source = defaultSource, env
|
|
|
418
419
|
// outside the csv — hot siblings and cold tools alike — is un-dispatchable, because the
|
|
419
420
|
// filter's whole point is a provably single-tool server for the seonix-ask benchmark arm.
|
|
420
421
|
const filtered = served !== TOOLS;
|
|
422
|
+
// Opt-in telemetry, created ONCE per server (default OFF → null → `tel?.record` is a
|
|
423
|
+
// no-op and nothing is written). LOG FILE ONLY — stdout belongs to the MCP transport.
|
|
424
|
+
const tel = createTelemetry({ env, config, surface: "mcp" });
|
|
421
425
|
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
|
|
422
426
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: served }));
|
|
423
427
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
424
428
|
const { name, arguments: args } = req.params;
|
|
429
|
+
const t0 = Date.now();
|
|
425
430
|
try {
|
|
426
431
|
if (filtered && !servedNames.has(name)) throw new ToolError(`unknown tool: ${name}`);
|
|
427
432
|
const text = await dispatchTool(name, args || {}, { config, source });
|
|
433
|
+
tel?.record({
|
|
434
|
+
tool: name,
|
|
435
|
+
response: { count: text ? text.split("\n").length : 0, truncated: /truncated/i.test(text) },
|
|
436
|
+
perf: { ms_total: Date.now() - t0 },
|
|
437
|
+
cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
|
|
438
|
+
});
|
|
428
439
|
return { content: [{ type: "text", text }] };
|
|
429
440
|
} catch (e) {
|
|
430
441
|
const text = e instanceof ToolError ? e.message : `unexpected error: ${e?.message || e}`;
|
package/src/sessions.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
27
27
|
import { join } from "node:path";
|
|
28
|
+
import { encodeGraphV2, expandGraphPayload, GRAPH_FORMAT_CURRENT } from "./graph-format.mjs";
|
|
28
29
|
|
|
29
30
|
export const SESSIONS_DIR_REL = join(".seonix", "sessions");
|
|
30
31
|
|
|
@@ -144,9 +145,13 @@ export function upsertSession(entities, record) {
|
|
|
144
145
|
* may have replaced it mid-session), upsert, write back atomically. Throws on a
|
|
145
146
|
* missing/invalid artifact — the caller treats the append as best-effort. */
|
|
146
147
|
export async function appendSessionToGraph(graphFile, record) {
|
|
147
|
-
const
|
|
148
|
+
const raw = JSON.parse(await readFile(graphFile, "utf8"));
|
|
149
|
+
// A6: read the on-disk format, expand to the in-memory shape, upsert, then WRITE BACK in
|
|
150
|
+
// the SAME format it was read as — so a v2-on-disk graph stays v2, a v1 graph stays v1.
|
|
151
|
+
const wasV2 = raw?.format === GRAPH_FORMAT_CURRENT;
|
|
152
|
+
const entities = expandGraphPayload(raw);
|
|
148
153
|
const res = upsertSession(entities, record);
|
|
149
|
-
await atomicWriteJson(graphFile, entities);
|
|
154
|
+
await atomicWriteJson(graphFile, wasV2 ? encodeGraphV2(entities) : entities);
|
|
150
155
|
return res;
|
|
151
156
|
}
|
|
152
157
|
|
package/src/source.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// stub it); in production it reads the JSON artifact the deterministic indexer
|
|
4
4
|
// wrote to config.graphFile. No network, no model calls.
|
|
5
5
|
|
|
6
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { readFile, stat } from "node:fs/promises";
|
|
7
7
|
import { ToolError } from "./config.mjs";
|
|
8
|
+
import { expandGraphPayload } from "./graph-format.mjs";
|
|
8
9
|
|
|
9
10
|
let cache = null; // { file, payload } — one artifact per process; cheap re-reads.
|
|
10
11
|
|
|
@@ -12,9 +13,34 @@ export function clearCache() {
|
|
|
12
13
|
cache = null;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** C12 (opt-in): try the resident node:sqlite store, verifying freshness against the live
|
|
17
|
+
* graph.json (bytes+mtimeMs). Returns the payload on success, or null to fall back to the
|
|
18
|
+
* JSON read — on a stale/corrupt/absent store (typed throw from readPayload) or when the
|
|
19
|
+
* source JSON can't be stat'd. node:sqlite is imported LAZILY here, so a gate-off load
|
|
20
|
+
* never touches sqlite (no ExperimentalWarning, no store.mjs sqlite import). */
|
|
21
|
+
async function fetchFromStore(config) {
|
|
22
|
+
let expectStat;
|
|
23
|
+
try { expectStat = await stat(config.graphFile); }
|
|
24
|
+
catch { return null; } // no source JSON to verify against → let the JSON path emit its own error
|
|
25
|
+
try {
|
|
26
|
+
const { readPayload, storeFileFor } = await import("./store.mjs");
|
|
27
|
+
const dbPath = config.storeFile || storeFileFor(config.graphFile);
|
|
28
|
+
return await readPayload(dbPath, { expectStat });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
process.stderr.write(`seonix: sqlite store unavailable (${e?.name || e?.code || e?.message || e}); using graph.json\n`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/** Read + parse the local graph artifact. Cached per file for the process. */
|
|
16
36
|
export async function fetchEntities(config) {
|
|
17
37
|
if (cache && cache.file === config.graphFile) return cache.payload;
|
|
38
|
+
// Gate: config.store (from loadConfig) OR the raw env, so callers that build a config
|
|
39
|
+
// WITHOUT loadConfig (e.g. cli's configFor) still honour SEONIX_STORE=sqlite.
|
|
40
|
+
if (config.store === "sqlite" || process.env.SEONIX_STORE === "sqlite") {
|
|
41
|
+
const payload = await fetchFromStore(config);
|
|
42
|
+
if (payload) { cache = { file: config.graphFile, payload }; return payload; }
|
|
43
|
+
}
|
|
18
44
|
let text;
|
|
19
45
|
try {
|
|
20
46
|
text = await readFile(config.graphFile, "utf8");
|
|
@@ -26,9 +52,13 @@ export async function fetchEntities(config) {
|
|
|
26
52
|
}
|
|
27
53
|
let payload;
|
|
28
54
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
55
|
+
// expandGraphPayload transparently reconstructs the in-memory shape from the interned
|
|
56
|
+
// v2 wire form (A6); it is a passthrough for v1 / legacy artifacts. This is the single
|
|
57
|
+
// load choke point for server / chat / viz / cli, so they all read expanded graphs.
|
|
58
|
+
payload = expandGraphPayload(JSON.parse(text));
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof SyntaxError) throw new ToolError(`graph artifact ${config.graphFile} is not valid JSON`);
|
|
61
|
+
throw e; // a corrupt-index throw from expandGraphPayload must surface loudly, not masquerade as bad JSON
|
|
32
62
|
}
|
|
33
63
|
cache = { file: config.graphFile, payload };
|
|
34
64
|
return payload;
|