@polycode-projects/seonix 0.2.1 → 0.4.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.
@@ -0,0 +1,120 @@
1
+ // nlp-bundle.mjs — package wink-nlp + wink-eng-lite-web-model into ONE browser
2
+ // IIFE that self-registers `window.__seonixNlp = {lemma, posTags}`, the same
3
+ // adapter shape ask-nlp.mjs builds for Node. It is the browser path for the
4
+ // viewer's lemma/POS tier (operator override of the earlier "CLI-only" call).
5
+ //
6
+ // WHY a hand-rolled packager and not a bundler: both packages are pure CJS with
7
+ // ONLY static `require('./relative')` calls and NO Node built-ins (verified), so
8
+ // a ~40-line CommonJS-in-the-browser shim that walks the require graph, wraps each
9
+ // file in a module factory, and emits a tiny `require` closure is enough — no
10
+ // esbuild/rollup dependency, in keeping with the repo's lean-deps rule. The model
11
+ // data files are JSON `require`s, inlined as `module.exports=<json>`.
12
+ //
13
+ // BOUNDARY (mirrors ask-nlp.mjs's): this bundle is used ONLY by the SITE build
14
+ // (viz --data-out --nlp writes it as a same-origin sibling the page lazy-loads)
15
+ // or by an explicit `viz --nlp` on a portable file (inlined). The default local
16
+ // single-file viewer never includes it and keeps its no-external-fetch guarantee.
17
+ // The wink model self-loads via the browser `atob` global (it is a WEB model);
18
+ // nothing here touches the DOM, fs, or the network.
19
+ //
20
+ // The lemma/posTags bodies below MIRROR ask-nlp.mjs's Node adapter deliberately;
21
+ // nlp-bundle.test.mjs pins output parity between the two so they can't drift.
22
+
23
+ import { readFile } from "node:fs/promises";
24
+ import { createRequire } from "node:module";
25
+
26
+ // Every internal require in these two packages is a static string literal (checked
27
+ // against the shipped versions); a lexical scan is therefore exact enough to build
28
+ // the graph, and any request that fails to resolve throws loudly rather than
29
+ // silently dropping a module.
30
+ const REQUIRE_RE = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
31
+
32
+ /** Walk the CJS require graph from `entries` (bare package specifiers), reading
33
+ * every reachable .js/.json file, and return {order, deps, idOf, entryPaths}. */
34
+ async function packGraph(entries, fromUrl) {
35
+ const rootReq = createRequire(fromUrl);
36
+ const idOf = new Map(); // absPath -> integer id (stable, discovery order)
37
+ const order = []; // absPath[]
38
+ const deps = new Map(); // absPath -> { request -> absPath }
39
+ const isJson = (p) => p.endsWith(".json");
40
+ const assign = (p) => { if (!idOf.has(p)) { idOf.set(p, idOf.size); order.push(p); } };
41
+
42
+ async function walk(absPath) {
43
+ if (deps.has(absPath)) return; // visited (also breaks require cycles)
44
+ deps.set(absPath, {});
45
+ assign(absPath);
46
+ if (isJson(absPath)) return; // data leaf, no requires
47
+ const src = await readFile(absPath, "utf8");
48
+ const localReq = createRequire(absPath);
49
+ const map = {};
50
+ REQUIRE_RE.lastIndex = 0;
51
+ for (let m; (m = REQUIRE_RE.exec(src)); ) {
52
+ const request = m[2];
53
+ map[request] = localReq.resolve(request); // throws if unresolvable — loud by design
54
+ }
55
+ deps.set(absPath, map);
56
+ for (const target of Object.values(map)) await walk(target);
57
+ }
58
+
59
+ const entryPaths = entries.map((e) => rootReq.resolve(e));
60
+ for (const e of entryPaths) await walk(e);
61
+ return { order, deps, idOf, entryPaths };
62
+ }
63
+
64
+ /** The adapter body — JS source, MIRRORS ask-nlp.mjs's lemma/posTags. `nlp`/`its`
65
+ * are in scope from the IIFE. Self-registers on window|self|globalThis. */
66
+ const ADAPTER_JS = `
67
+ var G=(typeof window!=='undefined')?window:(typeof self!=='undefined')?self:globalThis;
68
+ G.__seonixNlp={
69
+ lemma:function(word){
70
+ var w=String(word||'');
71
+ try{var out=nlp.readDoc(w).tokens().out(its.lemma);return String(out[0]||w).toLowerCase();}
72
+ catch(e){return w.toLowerCase();}
73
+ },
74
+ posTags:function(words){
75
+ try{
76
+ var toks=nlp.readDoc(words.join(' ')).tokens();
77
+ var texts=toks.out(),tags=toks.out(its.pos),out=[],k=0;
78
+ for(var i=0;i<words.length;i++){
79
+ var w=words[i];
80
+ if(k>=texts.length){out.push(null);continue;}
81
+ out.push(tags[k]);
82
+ var acc=texts[k];k++;
83
+ while(acc.length<w.length&&k<texts.length){acc+=texts[k];k++;}
84
+ }
85
+ return out;
86
+ }catch(e){return words.map(function(){return null;});}
87
+ }
88
+ };`;
89
+
90
+ /** Build the self-contained browser IIFE (a plain JS string, no <script> wrapper).
91
+ * Loading it in a browser sets window.__seonixNlp. Pure w.r.t. installed deps. */
92
+ export async function winkBrowserBundle() {
93
+ const { order, deps, idOf, entryPaths } = await packGraph(
94
+ ["wink-nlp", "wink-eng-lite-web-model"],
95
+ import.meta.url,
96
+ );
97
+ const D = {}; // id -> {request -> id}
98
+ for (const [p, map] of deps) {
99
+ const from = idOf.get(p);
100
+ D[from] = {};
101
+ for (const [req, target] of Object.entries(map)) D[from][req] = idOf.get(target);
102
+ }
103
+ const parts = [];
104
+ parts.push("(function(){\nvar M={},C={};");
105
+ parts.push("function R(id){if(C[id])return C[id].exports;var m=C[id]={exports:{}};M[id](m,m.exports,function(r){return R(D[id][r]);});return m.exports;}");
106
+ parts.push("var D=" + JSON.stringify(D) + ";");
107
+ for (const p of order) {
108
+ const id = idOf.get(p);
109
+ const src = await readFile(p, "utf8");
110
+ parts.push(p.endsWith(".json")
111
+ ? "M[" + id + "]=function(module,exports,require){module.exports=" + src + "\n};"
112
+ : "M[" + id + "]=function(module,exports,require){\n" + src + "\n};");
113
+ }
114
+ parts.push("var winkNLP=R(" + idOf.get(entryPaths[0]) + ");");
115
+ parts.push("var model=R(" + idOf.get(entryPaths[1]) + ");");
116
+ parts.push("var nlp=winkNLP(model),its=nlp.its;");
117
+ parts.push(ADAPTER_JS);
118
+ parts.push("})();");
119
+ return parts.join("\n");
120
+ }
@@ -0,0 +1,52 @@
1
+ // prose-nlp.mjs — the OPTIONAL wink-nlp lemma loader behind prose.mjs's LEMMA layer.
2
+ //
3
+ // Deliberately a SEPARATE loader from ask-nlp.mjs (same createRequire pattern, same
4
+ // deps) rather than an import of it: ask-nlp.mjs belongs to the ask-engine surface
5
+ // and is under active concurrent work — the prose pre-pass must not couple its
6
+ // index-build path to that file's export shape. The ~20 duplicated lines are the
7
+ // decoupling fee; the wink model itself is loaded lazily and at most once per
8
+ // process either way.
9
+ //
10
+ // BOUNDARY (same as ask-nlp.mjs, hard): Node-only, never inlined into the viewer
11
+ // bundle. prose.mjs is itself never inlined by viz.mjs's askSource(), so nothing
12
+ // browser-side can reach this module. wink-nlp + wink-eng-lite-web-model are CJS —
13
+ // loaded via createRequire, lazily, with failure cached as null: a checkout without
14
+ // the optional deps simply builds no lemma layer (honestly absent), it never throws.
15
+ //
16
+ // Determinism: wink's lemmatiser is a fixed trained model with no sampling — the
17
+ // same token always yields the same lemma across runs and processes, which is what
18
+ // lets the lemma layer meet the "byte-identical proseIndex across builds" contract.
19
+
20
+ import { createRequire } from "node:module";
21
+
22
+ let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
23
+
24
+ /** Lazily build a `lemma(word) -> string` function, or null when wink isn't loadable.
25
+ * Results are memoized per token: the layer build lemmatises each unique vocabulary
26
+ * token once, not once per posting. */
27
+ export function proseLemma() {
28
+ if (cached !== undefined) return cached;
29
+ try {
30
+ const require = createRequire(import.meta.url);
31
+ const winkNLP = require("wink-nlp");
32
+ const model = require("wink-eng-lite-web-model");
33
+ const nlp = winkNLP(model);
34
+ const its = nlp.its;
35
+ const memo = new Map();
36
+ cached = (word) => {
37
+ const w = String(word || "");
38
+ if (memo.has(w)) return memo.get(w);
39
+ let out;
40
+ try {
41
+ out = String(nlp.readDoc(w).tokens().out(its.lemma)[0] || w).toLowerCase();
42
+ } catch {
43
+ out = w.toLowerCase();
44
+ }
45
+ memo.set(w, out);
46
+ return out;
47
+ };
48
+ } catch {
49
+ cached = null;
50
+ }
51
+ return cached;
52
+ }
package/src/prose.mjs CHANGED
@@ -143,3 +143,45 @@ export function lookupByProseTokens(proseIndex, query, { limit = 10 } = {}) {
143
143
  .slice(0, limit)
144
144
  .map(([id, score]) => ({ id, score }));
145
145
  }
146
+
147
+ /** OPT-IN read accessor for codegraph.mjs's `proseLayers` locate signal (this file's index
148
+ * build is unchanged — this only READS the layers the pre-pass already wrote). Given a query
149
+ * `token`, return the individual ids reachable through the NORMALISED prose layers
150
+ * (spell-corrected / canonical-schema-term / stem / lemma) stored under
151
+ * `proseIndex["seonix:layers"]` — the same normalised layers ask.mjs's resolveObject consults,
152
+ * here surfaced for the locate SCORER so a task-text word that only overlaps a module via a
153
+ * normalised form still resolves.
154
+ *
155
+ * Layer shape consumed (an inverted index keyed by the NORMALISED token, mirroring the verbatim
156
+ * top level, just normalised):
157
+ * proseIndex["seonix:layers"] = { <layerName>: { <normalisedToken>: [id, …] }, … }
158
+ * A posting may be a plain id array or `{ ids: [...] }` — both are tolerated. The raw query
159
+ * token is looked up directly against every layer's keys, so a token whose surface form is
160
+ * already a canonical/stem/lemma/spell-corrected key hits; the accessor never itself normalises
161
+ * the query (it owns no normaliser — those live in the concurrent ask/prose-nlp surface), so it
162
+ * can never disagree with the build's normalisation, only under-fire safely.
163
+ *
164
+ * Returns { ids, via }: `ids` a deduped, sorted (stable/deterministic) id list; `via` the
165
+ * sorted layer names that produced them, joined with "+", for a scorer's provenance — or null
166
+ * when nothing hit. Absent / malformed / pre-layers `proseIndex` → { ids: [], via: null }: a
167
+ * safe no-op, so the opt-in flag degrades to nothing on a graph indexed before layers existed.
168
+ * Accepts either a `proseIndex` object or a parsed graph (reads its `.proseIndex`). */
169
+ export function proseLayerHits(proseIndex, token) {
170
+ const src = proseIndex && (proseIndex["seonix:layers"] ? proseIndex : proseIndex.proseIndex);
171
+ const layers = src && src["seonix:layers"];
172
+ const t = String(token || "").toLowerCase();
173
+ const empty = { ids: [], via: null };
174
+ if (!t || !layers || typeof layers !== "object") return empty;
175
+ const ids = new Set();
176
+ const via = new Set();
177
+ for (const name of Object.keys(layers)) {
178
+ const layer = layers[name];
179
+ if (!layer || typeof layer !== "object") continue;
180
+ const posting = layer[t];
181
+ const list = Array.isArray(posting) ? posting : Array.isArray(posting?.ids) ? posting.ids : null;
182
+ if (!list?.length) continue;
183
+ for (const id of list) ids.add(id);
184
+ via.add(name);
185
+ }
186
+ return ids.size ? { ids: [...ids].sort(), via: [...via].sort().join("+") } : empty;
187
+ }
@@ -49,6 +49,12 @@ export const CLASS_DOCS = Object.freeze([
49
49
  "A single recorded git commit. Carries author/date/message attributes and connects " +
50
50
  "to the Modules it touched (mgx:touchedByCommit) and, more precisely, the specific " +
51
51
  "symbols whose current source span its changed lines intersect (mgx:touchesSymbol)." },
52
+ { name: "Session", description:
53
+ "One recorded `seonix chat` session — a runtime observation, not a source " +
54
+ "derivation. Carries started/ended timestamps (temporal ordering, like a Commit's " +
55
+ "date), a turn count and the queries asked, and connects to the entities its turns " +
56
+ "resolved or answered with (mgx:asksAbout). Recorded under .seonix/sessions/ and " +
57
+ "re-attached on every re-index (sessions.mjs)." },
52
58
  ]);
53
59
 
54
60
  // ---- predicates / attributes (every `prop:` token actually emitted by buildEntities,
@@ -96,6 +102,11 @@ export const PREDICATE_DOCS = Object.freeze([
96
102
  "Module → Function/Class. A module's public-API (`__all__`) entry that re-exports a " +
97
103
  "symbol defined or imported elsewhere — answers \"where is X importable from,\" not " +
98
104
  "just \"where is X defined.\"" },
105
+ { prop: "mgx:asksAbout", kind: "asksAbout", description:
106
+ "Session → any entity. A chat session's turn resolved this entity as its subject " +
107
+ "or cited it in the answer — which parts of the codebase a human actually asked " +
108
+ "about. A runtime observation (owned term, no SEON equivalent); references that no " +
109
+ "longer resolve after a re-index are dropped and counted, never guessed." },
99
110
  { prop: "mgx:hasProseTokens", kind: "prose", description:
100
111
  "Individual → word tokens (an attribute, not a between-individuals edge). The " +
101
112
  "decomposed word sequence extracted from an identifier's name (camelCase/snake_case " +
@@ -120,6 +131,20 @@ export const PREDICATE_DOCS = Object.freeze([
120
131
  { prop: "mgx:value", kind: "attribute", description:
121
132
  "The literal right-hand-side value of a GlobalVariable's assignment, when the AST " +
122
133
  "extractor could capture one cheaply (a constant or simple literal)." },
134
+ { prop: "mgx:sessionStarted", kind: "attribute", description:
135
+ "A chat Session's start time, ISO-8601 — the timestamp the session enters the " +
136
+ "timeline at (the Session analogue of a Commit's date)." },
137
+ { prop: "mgx:sessionEnded", kind: "attribute", description:
138
+ "A chat Session's end time, ISO-8601 (the /exit, EOF, or last recorded turn)." },
139
+ { prop: "mgx:sessionTurns", kind: "attribute", description:
140
+ "How many query turns a chat Session ran (non-empty, non-/exit inputs)." },
141
+ { prop: "mgx:sessionQueries", kind: "attribute", description:
142
+ "The queries a chat Session asked, ' | '-joined and length-capped — what the " +
143
+ "human wanted to know, in their own words." },
144
+ { prop: "mgx:sessionDroppedEdges", kind: "attribute", description:
145
+ "How many of a Session's recorded entity references could not be re-resolved " +
146
+ "against the current graph (renamed/removed code) — those edges are dropped, " +
147
+ "never guessed, and this attribute keeps the loss honest and visible." },
123
148
  { prop: "mgx:commitAuthor", kind: "attribute", description: "A Commit's author name (git %an)." },
124
149
  { prop: "mgx:commitDate", kind: "attribute", description:
125
150
  "A Commit's author date, ISO-8601 (git %aI)." },
@@ -146,6 +171,10 @@ export const PREDICATE_DOCS = Object.freeze([
146
171
  { prop: "seon:isConstant", kind: "attribute", description:
147
172
  "An ALL_CAPS module-level GlobalVariable — a naming-convention signal, not enforced " +
148
173
  "immutability." },
174
+ { prop: "seon:subKind", kind: "attribute", description:
175
+ "The flavour of a Class define when it is not a plain class: interface, enum, struct " +
176
+ "or record. The graph keeps kind=class for every type declaration; this attribute " +
177
+ "carries the distinction." },
149
178
  { prop: "seon:hasAccessModifier", kind: "attribute", description:
150
179
  "Visibility inferred from a leading underscore (private/protected); a public member " +
151
180
  "carries no value for this attribute." },
package/src/server.mjs CHANGED
@@ -90,7 +90,7 @@ export const TOOLS = [
90
90
  {
91
91
  name: "seonix_ask",
92
92
  description:
93
- "Ask a structural question in plain English (e.g. \"which functions call X\") one call, no model, replaces manual search+describe+traversal. A clean miss beats a guess.",
93
+ "Ask a structural question in plain English: \"which functions call X\", \"what uses X\", \"where is X defined\", \"when did X change\". One call, no model. A clean miss beats a guess.",
94
94
  inputSchema: {
95
95
  type: "object",
96
96
  required: ["query"],
@@ -394,13 +394,36 @@ export async function dispatchTool(name, args, { config, source = defaultSource
394
394
  throw new ToolError(`unknown tool: ${name}`);
395
395
  }
396
396
 
397
+ /** SEONIX_TOOLS (csv of tool names) narrows what the server registers AND serves —
398
+ * an unlisted tool is invisible to the client and un-dispatchable, not merely
399
+ * un-granted, so a permission layer that auto-denies visible-but-ungranted tools
400
+ * (headless `claude -p`) has nothing to deny. Unknown names in the csv throw at
401
+ * startup: a typo must fail loudly, not silently serve the full set. */
402
+ export function resolveServedTools(env = process.env) {
403
+ const csv = (env.SEONIX_TOOLS || "").trim();
404
+ if (!csv) return TOOLS;
405
+ const wanted = csv.split(",").map((s) => s.trim()).filter(Boolean);
406
+ const byName = new Map(TOOLS.map((t) => [t.name, t]));
407
+ const unknown = wanted.filter((n) => !byName.has(n));
408
+ if (unknown.length) throw new Error(`SEONIX_TOOLS names unknown tool(s): ${unknown.join(", ")}`);
409
+ return wanted.map((n) => byName.get(n));
410
+ }
411
+
397
412
  /** Build the MCP server (transport-agnostic — tests connect it in-memory). */
398
- export function buildServer({ config = loadConfig(), source = defaultSource } = {}) {
413
+ export function buildServer({ config = loadConfig(), source = defaultSource, env = process.env } = {}) {
414
+ const served = resolveServedTools(env);
415
+ const servedNames = new Set(served.map((t) => t.name));
416
+ // The dispatch gate applies ONLY under an active SEONIX_TOOLS filter: unfiltered, the cold
417
+ // tools stay dispatchable-though-unlisted (the C4 tiered surface); filtered, EVERYTHING
418
+ // outside the csv — hot siblings and cold tools alike — is un-dispatchable, because the
419
+ // filter's whole point is a provably single-tool server for the seonix-ask benchmark arm.
420
+ const filtered = served !== TOOLS;
399
421
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
400
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
422
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: served }));
401
423
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
402
424
  const { name, arguments: args } = req.params;
403
425
  try {
426
+ if (filtered && !servedNames.has(name)) throw new ToolError(`unknown tool: ${name}`);
404
427
  const text = await dispatchTool(name, args || {}, { config, source });
405
428
  return { content: [{ type: "text", text }] };
406
429
  } catch (e) {
@@ -416,5 +439,5 @@ export async function startServer() {
416
439
  const server = buildServer({ config });
417
440
  await server.connect(new StdioServerTransport());
418
441
  // stdout belongs to the MCP transport; sign on via stderr.
419
- process.stderr.write(`seonix: serving ${TOOLS.length} tools over ${config.graphFile}\n`);
442
+ process.stderr.write(`seonix: serving ${resolveServedTools().length} tools over ${config.graphFile}\n`);
420
443
  }
@@ -0,0 +1,206 @@
1
+ // sessions.mjs — chat sessions as first-class temporal graph data, like commits.
2
+ //
3
+ // A `seonix chat` session leaves two artifacts under the target repo:
4
+ // .seonix/session-<uuidv7>.log — the human-readable transcript (chat.mjs)
5
+ // .seonix/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
6
+ // {"type":"session", id, started, repo, seonixVersion} (header line)
7
+ // {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
8
+ // {"type":"end", ts} (clean close marker)
9
+ //
10
+ // From the sidecar the session enters the typed graph twice:
11
+ // - READ TIME (chat.mjs, per turn): appendSessionToGraph() upserts one `Session`
12
+ // individual (`session:<uuidv7>`) + `mgx:asksAbout` edges into graph.json —
13
+ // atomically (temp + rename), re-reading the file first so a re-index that
14
+ // happened mid-session is tolerated: edges whose targets vanished are dropped,
15
+ // never left dangling (honest degradation).
16
+ // - RE-INDEX (extract.mjs single-path mode): readSessionRecords() + foldInSessions()
17
+ // re-attach every recorded session to the FRESH graph, re-resolving each recorded
18
+ // entity id (by id first, then by unique label derived from the id shape);
19
+ // unresolvable references are dropped and counted on the session node
20
+ // (mgx:sessionDroppedEdges) — never a guessed edge.
21
+ //
22
+ // Sessions are runtime observations, not source derivations: they record what a human
23
+ // asked the graph about and which entities answered, so re-indexing re-attaches them
24
+ // rather than re-deriving them from source.
25
+
26
+ import { readFile, readdir, rename, writeFile } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+
29
+ export const SESSIONS_DIR_REL = join(".seonix", "sessions");
30
+
31
+ export const SESSION_CLASS = "Session";
32
+ export const ASKS_ABOUT_PREDICATE = "asksAbout";
33
+ export const ASKS_ABOUT_PROP = "mgx:asksAbout";
34
+
35
+ const QUERIES_ATTR_CAP = 500; // joined-queries attribute cap (mirrors commitMessage's cap idea)
36
+
37
+ /** Session labels mirror Commit's short-sha convention: the uuid's leading time-ordered hex. */
38
+ const sessionLabel = (id) => String(id).slice(0, 8);
39
+
40
+ /** Best-effort label a recorded entity id would carry, derived from the id shape —
41
+ * the "then by label" tier of fold-in re-resolution (ids are `mod:<path>`,
42
+ * `fn:<path>#<name>`, `commit:<sha>`; labels are path / name / short sha). */
43
+ function labelFromId(id) {
44
+ const s = String(id);
45
+ if (s.startsWith("mod:")) return s.slice(4);
46
+ const fn = s.match(/^fn:.*#(.+)$/);
47
+ if (fn) return fn[1];
48
+ if (s.startsWith("commit:")) return s.slice(7, 19);
49
+ return null;
50
+ }
51
+
52
+ /** Atomic JSON write: temp file in the same directory + rename, so a concurrent
53
+ * reader never sees a torn graph.json and a crash never destroys the old one. */
54
+ async function atomicWriteJson(file, obj) {
55
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
56
+ await writeFile(tmp, JSON.stringify(obj));
57
+ await rename(tmp, file);
58
+ }
59
+
60
+ /**
61
+ * Upsert one session record into an `entities` payload (mutates; pure of I/O).
62
+ * Shared by the read-time append AND the re-index fold-in, so both resolve and
63
+ * degrade identically:
64
+ * - a prior copy of the same session (per-turn re-appends) is replaced;
65
+ * - every recorded ref resolves by id, else by UNIQUE label (ambiguous → drop);
66
+ * - dropped refs are counted on the session node, never guessed into edges.
67
+ * Record shape: { id, started, ended?, turns: [{ts, query, resolvedIds, answeredIds, miss}] }.
68
+ * Returns { kept, dropped }.
69
+ */
70
+ export function upsertSession(entities, record) {
71
+ const sid = `session:${record.id}`;
72
+ entities.individuals ||= [];
73
+ entities.objectProperties ||= [];
74
+
75
+ // replace any prior copy of this session (read-time appends run once per turn)
76
+ entities.individuals = entities.individuals.filter((i) => i?.id !== sid);
77
+ let group = entities.objectProperties.find((g) => g?.prop === ASKS_ABOUT_PROP);
78
+ if (group) {
79
+ group.examples = (group.examples || []).filter((e) => e?.subject !== sid);
80
+ } else {
81
+ group = { predicate: ASKS_ABOUT_PREDICATE, prop: ASKS_ABOUT_PROP, count: 0, examples: [] };
82
+ entities.objectProperties.push(group);
83
+ }
84
+
85
+ // resolve refs against the CURRENT individuals — by id, then by unique label
86
+ const byId = new Map();
87
+ const byLabel = new Map(); // label -> id, or null when ambiguous
88
+ for (const i of entities.individuals) {
89
+ if (!i?.id) continue;
90
+ byId.set(i.id, i);
91
+ if (i.label) byLabel.set(i.label, byLabel.has(i.label) ? null : i.id);
92
+ }
93
+ const turns = Array.isArray(record.turns) ? record.turns : [];
94
+ const refs = [...new Set(turns.flatMap((t) => [...(t?.resolvedIds || []), ...(t?.answeredIds || [])]))];
95
+ const targets = [];
96
+ let dropped = 0;
97
+ for (const ref of refs) {
98
+ if (byId.has(ref)) { targets.push(ref); continue; }
99
+ const cand = labelFromId(ref);
100
+ const viaLabel = cand ? byLabel.get(cand) : undefined;
101
+ if (viaLabel) { targets.push(viaLabel); continue; }
102
+ dropped += 1; // vanished or ambiguous — an honest drop, never a dangling/guessed edge
103
+ }
104
+
105
+ const started = record.started || "";
106
+ const ended = record.ended || turns.at(-1)?.ts || started;
107
+ const queries = turns.map((t) => String(t?.query || "")).filter(Boolean);
108
+ const label = sessionLabel(record.id);
109
+ entities.individuals.push({
110
+ id: sid, label, class: SESSION_CLASS,
111
+ derived_from: [], mentions: [],
112
+ attributes: [
113
+ { prop: "mgx:sessionStarted", key: "started", value: started },
114
+ { prop: "mgx:sessionEnded", key: "ended", value: ended },
115
+ { prop: "mgx:sessionTurns", key: "turns", value: String(turns.length) },
116
+ ...(queries.length ? [{ prop: "mgx:sessionQueries", key: "queries", value: queries.join(" | ").slice(0, QUERIES_ATTR_CAP) }] : []),
117
+ ...(dropped ? [{ prop: "mgx:sessionDroppedEdges", key: "dropped", value: String(dropped) }] : []),
118
+ ],
119
+ });
120
+ for (const t of targets) {
121
+ group.examples.push({ subject: sid, object: t, subjectLabel: label, objectLabel: byId.get(t)?.label || t });
122
+ }
123
+ group.count = group.examples.length;
124
+
125
+ // classes[] + vocabulary[] entries appear ONLY once a session exists, so a
126
+ // session-less graph stays byte-identical to what buildEntities always produced.
127
+ const sessions = entities.individuals.filter((i) => i.class === SESSION_CLASS);
128
+ if (Array.isArray(entities.classes)) {
129
+ let cls = entities.classes.find((c) => c?.name === SESSION_CLASS);
130
+ if (!cls) { cls = { name: SESSION_CLASS, count: 0, sample: [] }; entities.classes.push(cls); }
131
+ cls.count = sessions.length;
132
+ cls.sample = sessions.slice(0, 3).map((i) => i.label);
133
+ }
134
+ if (Array.isArray(entities.vocabulary) && !entities.vocabulary.some((v) => v?.prop === ASKS_ABOUT_PROP)) {
135
+ entities.vocabulary.push({
136
+ prop: ASKS_ABOUT_PROP, predicate: ASKS_ABOUT_PREDICATE,
137
+ note: "chat Session → entity a turn resolved/answered with; runtime observation, owned (no SEON term)",
138
+ });
139
+ }
140
+ return { kept: targets.length, dropped };
141
+ }
142
+
143
+ /** Read-time append (chat.mjs, once per turn): re-read graph.json FRESH (a re-index
144
+ * may have replaced it mid-session), upsert, write back atomically. Throws on a
145
+ * missing/invalid artifact — the caller treats the append as best-effort. */
146
+ export async function appendSessionToGraph(graphFile, record) {
147
+ const entities = JSON.parse(await readFile(graphFile, "utf8"));
148
+ const res = upsertSession(entities, record);
149
+ await atomicWriteJson(graphFile, entities);
150
+ return res;
151
+ }
152
+
153
+ /** Parse one sidecar .jsonl into a session record (null if no valid header).
154
+ * Torn/partial trailing lines (a killed session) are skipped, not fatal. */
155
+ export function parseSessionJsonl(text) {
156
+ let header = null;
157
+ let ended = "";
158
+ const turns = [];
159
+ const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
160
+ for (const line of String(text).split("\n")) {
161
+ const s = line.trim();
162
+ if (!s) continue;
163
+ let rec;
164
+ try { rec = JSON.parse(s); } catch { continue; }
165
+ if (rec?.type === "session" && !header) header = rec;
166
+ else if (rec?.type === "turn") {
167
+ turns.push({
168
+ ts: String(rec.ts || ""), query: String(rec.query || ""),
169
+ resolvedIds: arr(rec.resolvedIds), answeredIds: arr(rec.answeredIds), miss: !!rec.miss,
170
+ });
171
+ } else if (rec?.type === "end") ended = String(rec.ts || "") || ended;
172
+ }
173
+ if (!header?.id) return null;
174
+ return { id: String(header.id), started: String(header.started || ""), ended, turns };
175
+ }
176
+
177
+ /** All recorded sessions under <rootDir>/.seonix/sessions/*.jsonl, oldest first
178
+ * (uuidv7 filenames sort chronologically). Best-effort: no dir → []. */
179
+ export async function readSessionRecords(rootDir) {
180
+ const dir = join(rootDir, SESSIONS_DIR_REL);
181
+ let names;
182
+ try { names = await readdir(dir); } catch { return []; }
183
+ const records = [];
184
+ for (const name of names.filter((n) => n.endsWith(".jsonl")).sort()) {
185
+ try {
186
+ const rec = parseSessionJsonl(await readFile(join(dir, name), "utf8"));
187
+ if (rec) records.push(rec);
188
+ } catch { /* unreadable sidecar — skip, never fail an index run */ }
189
+ }
190
+ return records;
191
+ }
192
+
193
+ /** Re-index fold-in: attach every recorded session to a FRESH entities payload.
194
+ * Sessions are runtime observations, not source derivations — they are re-attached
195
+ * after the source-derived build, with every reference re-resolved against the new
196
+ * graph (upsertSession's id-then-label tiers; unresolvable → dropped + counted). */
197
+ export function foldInSessions(entities, records) {
198
+ let kept = 0;
199
+ let dropped = 0;
200
+ for (const rec of records || []) {
201
+ const r = upsertSession(entities, rec);
202
+ kept += r.kept;
203
+ dropped += r.dropped;
204
+ }
205
+ return { sessions: (records || []).length, kept, dropped };
206
+ }
package/src/temporal.mjs CHANGED
@@ -557,3 +557,73 @@ export function decodeViewState(search) {
557
557
  }
558
558
  return out;
559
559
  }
560
+
561
+ // ---- P4: cross-repo awareness ------------------------------------------------------
562
+ // A merged multi-repo index (extract.mjs indexRepositories) prefixes every module id
563
+ // with the repo's directory basename: `mod:<repo>/<path>`, `fn:<repo>/<path>#sym`.
564
+ // Commit individuals are NOT prefixed (one Commit per sha, shared across clones), so a
565
+ // commit's repo is inferred from the prefixes of the modules it touched. These three
566
+ // pure helpers are the shared, node-tested core the timeline (timeline.mjs) and the
567
+ // temporal browser (browser.mjs) both use to become repo-aware WITHOUT a top-level
568
+ // marker at index time. A single-repo graph never trips the detector, so its timeline
569
+ // and temporal-graph output stay byte-identical to the pre-P4 pipeline.
570
+
571
+ /**
572
+ * First path component of a prefixed id (`mod:<repo>/<rest>` → `<repo>`), or null when
573
+ * the id has no `/` after its `type:` prefix (an unprefixed single-repo id such as
574
+ * `mod:a.py`). Cheap, deterministic; the atom the repo detector counts.
575
+ * @param {string} id
576
+ * @returns {string|null}
577
+ */
578
+ export function repoOfId(id) {
579
+ const m = String(id).match(/^[a-z]+:([^#]+)/);
580
+ if (!m) return null;
581
+ const slash = m[1].indexOf("/");
582
+ return slash > 0 ? m[1].slice(0, slash) : null;
583
+ }
584
+
585
+ /**
586
+ * Attribute one commit to a repo given a `Map<prefix, touchCount>` of the modules it
587
+ * touched: the majority prefix wins, ties break lexicographically, an empty map → null
588
+ * (touched nothing tracked). This is how a commit that touches modules from >1 repo
589
+ * (only possible when two clones share a sha, since git history is per-repo) is
590
+ * attributed — to the repo it touched most, deterministically.
591
+ * @param {Map<string, number>} counts
592
+ * @returns {string|null}
593
+ */
594
+ export function assignRepo(counts) {
595
+ let best = null;
596
+ let bestN = -1;
597
+ for (const p of [...counts.keys()].sort()) {
598
+ const n = counts.get(p);
599
+ if (n > bestN) { best = p; bestN = n; }
600
+ }
601
+ return best;
602
+ }
603
+
604
+ /**
605
+ * Decide whether a graph is a merged multi-repo index from its commits' touched-module
606
+ * prefixes. `commitPrefixCounts` is one `Map<prefix,count>` per commit. A graph reads
607
+ * as multi-repo iff ≥2 distinct prefixes each appear as the SOLE prefix of some commit
608
+ * AND commits confined to a single prefix are at least as many as cross-prefix commits.
609
+ * Rationale: a real git history is per-repo, so a genuine merge has each commit land in
610
+ * exactly one repo; a single monorepo's commits routinely span several top-level dirs,
611
+ * so the "majority stay confined + ≥2 sole prefixes" test never fires for it — that is
612
+ * what keeps single-repo output byte-identical. (A monorepo whose every commit happens
613
+ * to stay within one top-level dir is the one documented ambiguity; it degrades
614
+ * gracefully to repo-tagged rendering, it does not error.)
615
+ * @param {Array<Map<string, number>>} commitPrefixCounts
616
+ * @returns {boolean}
617
+ */
618
+ export function isMultiRepo(commitPrefixCounts) {
619
+ let pure = 0;
620
+ let cross = 0;
621
+ const sole = new Set();
622
+ for (const counts of commitPrefixCounts) {
623
+ const keys = [...counts.keys()];
624
+ if (!keys.length) continue;
625
+ if (keys.length === 1) { pure += 1; sole.add(keys[0]); }
626
+ else cross += 1;
627
+ }
628
+ return sole.size >= 2 && pure >= cross;
629
+ }