@polycode-projects/seonix 0.3.0 → 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.
- package/README.md +93 -23
- package/bin/cli.mjs +9 -3
- package/package.json +1 -1
- package/src/ask-vocab.mjs +198 -2
- package/src/ask.mjs +824 -8
- package/src/browser.mjs +87 -13
- package/src/chat.mjs +644 -47
- package/src/codegraph.mjs +55 -2
- package/src/nlp-bundle.mjs +120 -0
- package/src/prose.mjs +42 -0
- package/src/temporal.mjs +70 -0
- package/src/timeline.mjs +51 -8
- package/src/viz.mjs +88 -19
package/src/codegraph.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lookupByProseTokens } from "./prose.mjs";
|
|
1
|
+
import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
|
|
2
2
|
import { cosine } from "./embed.mjs";
|
|
3
3
|
|
|
4
4
|
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
@@ -467,6 +467,24 @@ const PROSE_PROX_FRAC = 0.2;
|
|
|
467
467
|
const PROSE_PROX_CAP_FRAC = 0.35;
|
|
468
468
|
const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
|
|
469
469
|
|
|
470
|
+
// Layered prose normalisation (opt-in via proseLayers, 2026-07-02): the prose index now carries
|
|
471
|
+
// NORMALISED layers (spell-corrected / canonical-schema-term / stem / lemma) under
|
|
472
|
+
// proseIndex["seonix:layers"] (built by the prose pre-pass; consumed read-only via prose.mjs's
|
|
473
|
+
// proseLayerHits). Today the locate scorer matches query tokens against a module's path/symbol
|
|
474
|
+
// text VERBATIM, so a task-text word that only reaches a module via its stem/lemma/canonical form
|
|
475
|
+
// scores nothing. With the flag on, a query token that does NOT already match a module lexically,
|
|
476
|
+
// but DOES resolve to one of that module's individuals through a normalised layer, contributes a
|
|
477
|
+
// bounded, DISCOUNTED signal — weaker evidence than a verbatim match by construction (halved, then
|
|
478
|
+
// the shared FRAC/CAP nudge), and, like every proximity family, it only re-ranks modules ALREADY
|
|
479
|
+
// in `scored` — it never invents a zero-match candidate and never overrides an exact hit. NOT a
|
|
480
|
+
// shipped default and NOT wired into any bench arm — an available lever pending its own gate
|
|
481
|
+
// evidence, exactly like proseBoost/beamSearch before it.
|
|
482
|
+
const PROSE_LAYER_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families …
|
|
483
|
+
const PROSE_LAYER_CAP_FRAC = 0.35; // … capped at this × the module's own base score (a nudge; hubs can't run away)
|
|
484
|
+
const PROSE_LAYER_DISCOUNT = 0.5; // a normalised-layer hit is WEAKER evidence than an exact/component token
|
|
485
|
+
// match — halved before the FRAC/CAP nudge, so a layer hit can never rival
|
|
486
|
+
// a verbatim lexical match (the "a miss beats a guess" discipline).
|
|
487
|
+
|
|
470
488
|
// PLAN_SEON_TUNING.md §7.5 finding 1 / §7.6(5a) (opt-in via literalMention, 2026-07-02): the query
|
|
471
489
|
// tokenizer split(/[^a-z0-9_]+/) DESTROYS a literal dotted module reference present verbatim in
|
|
472
490
|
// task text — "django.utils.http" scatters into {django,utils,http}, tokens so common across
|
|
@@ -673,7 +691,7 @@ function beamExpand(graph, scored, beamWidth) {
|
|
|
673
691
|
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
674
692
|
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
675
693
|
function scoreModules(graph, tokens, opts = {}) {
|
|
676
|
-
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
694
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
677
695
|
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
678
696
|
const defIdx = definesIndex(graph);
|
|
679
697
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
@@ -869,6 +887,41 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
869
887
|
}
|
|
870
888
|
}
|
|
871
889
|
}
|
|
890
|
+
// Layered prose normalisation (opt-in): a query token that did NOT match a module lexically but
|
|
891
|
+
// resolves to one of its individuals through a NORMALISED prose layer (stem/lemma/canonical/spell)
|
|
892
|
+
// adds a bounded, discounted signal — see the PROSE_LAYER_* comment above. One proseLayerHits call
|
|
893
|
+
// per DISTINCT query token, ids folded to their containing module via moduleIdOfId (same as the
|
|
894
|
+
// proseBoost/call-adjacency families). Only tokens NOT already matching a module lexically count
|
|
895
|
+
// for that module (a layer hit is purely ADDITIVE evidence for otherwise-missed words — never
|
|
896
|
+
// double-counting a token the base score already saw), weighted by the token's own IDF (so a
|
|
897
|
+
// ubiquitous word contributes almost nothing) and halved (PROSE_LAYER_DISCOUNT: weaker than a
|
|
898
|
+
// verbatim match), then the shared FRAC/CAP nudge. Only re-ranks modules already in `scored`.
|
|
899
|
+
if (proseLayers && scored.length && graph.proseIndex) {
|
|
900
|
+
const scoredById = new Map(scored.map((s) => [s.ind.id, s]));
|
|
901
|
+
const modById = new Map(modules.map((m) => [m.ind.id, m]));
|
|
902
|
+
const layerSignal = new Map(); // moduleId -> accumulated discounted, IDF-weighted layer signal
|
|
903
|
+
for (const t of new Set(tokens)) {
|
|
904
|
+
const w = idf.get(t) || 0;
|
|
905
|
+
if (!w) continue;
|
|
906
|
+
const { ids } = proseLayerHits(graph.proseIndex, t);
|
|
907
|
+
if (!ids.length) continue;
|
|
908
|
+
const hitMods = new Set();
|
|
909
|
+
for (const id of ids) {
|
|
910
|
+
const modId = moduleIdOfId(graph, id);
|
|
911
|
+
if (!modId || hitMods.has(modId)) continue;
|
|
912
|
+
hitMods.add(modId);
|
|
913
|
+
if (!scoredById.has(modId)) continue; // never a new zero-match candidate
|
|
914
|
+
const m = modById.get(modId);
|
|
915
|
+
if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically → not additive
|
|
916
|
+
layerSignal.set(modId, (layerSignal.get(modId) || 0) + w * PROSE_LAYER_DISCOUNT);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
for (const s of scored) {
|
|
920
|
+
const signal = layerSignal.get(s.ind.id) || 0;
|
|
921
|
+
if (!signal) continue;
|
|
922
|
+
s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
872
925
|
// §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank — see the EMB_* constants'
|
|
873
926
|
// comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
|
|
874
927
|
// this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
|
|
@@ -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
|
+
}
|
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
|
+
}
|
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
|
+
}
|
package/src/timeline.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// timeline as `type:"session"` entries at their started timestamp — a graph with
|
|
11
11
|
// zero sessions renders exactly as before.
|
|
12
12
|
|
|
13
|
+
import { repoOfId, assignRepo, isMultiRepo } from "./temporal.mjs";
|
|
14
|
+
|
|
13
15
|
const esc = (s) =>
|
|
14
16
|
String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
15
17
|
|
|
@@ -17,12 +19,19 @@ export function extractTimeline(graph) {
|
|
|
17
19
|
const commits = [];
|
|
18
20
|
const sessions = [];
|
|
19
21
|
const churnByShort = new Map(); // short sha -> modules touched
|
|
22
|
+
const prefixByShort = new Map(); // short sha -> Map<repoPrefix, modules touched> (multi-repo)
|
|
20
23
|
for (const ind of graph.individuals || []) {
|
|
21
24
|
const attr = (key) => ind.attributes?.find((a) => a.key === key)?.value || "";
|
|
22
25
|
if (ind.class === "Module") {
|
|
26
|
+
const prefix = repoOfId(ind.id);
|
|
23
27
|
for (const ref of ind.derived_from || []) {
|
|
24
28
|
const short = ref.replace(/^git:/, "");
|
|
25
29
|
churnByShort.set(short, (churnByShort.get(short) || 0) + 1);
|
|
30
|
+
if (prefix) {
|
|
31
|
+
let m = prefixByShort.get(short);
|
|
32
|
+
if (!m) { m = new Map(); prefixByShort.set(short, m); }
|
|
33
|
+
m.set(prefix, (m.get(prefix) || 0) + 1);
|
|
34
|
+
}
|
|
26
35
|
}
|
|
27
36
|
} else if (ind.class === "Commit") {
|
|
28
37
|
commits.push({
|
|
@@ -45,11 +54,27 @@ export function extractTimeline(graph) {
|
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
for (const c of commits) c.touched = churnByShort.get(c.short) || 0;
|
|
57
|
+
// P4 cross-repo: on a MERGED multi-repo graph, tag every commit with the repo it
|
|
58
|
+
// belongs to (inferred from its touched modules' id prefixes) so the render can group
|
|
59
|
+
// by repo on a single global time axis. Single-repo graphs never trip isMultiRepo, so
|
|
60
|
+
// no `repo` key is added and the output stays byte-identical to before.
|
|
61
|
+
if (isMultiRepo(commits.map((c) => prefixByShort.get(c.short) || new Map()))) {
|
|
62
|
+
for (const c of commits) {
|
|
63
|
+
const r = assignRepo(prefixByShort.get(c.short) || new Map());
|
|
64
|
+
if (r) c.repo = r;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
48
67
|
const entries = [...commits, ...sessions];
|
|
49
68
|
entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
50
69
|
return entries;
|
|
51
70
|
}
|
|
52
71
|
|
|
72
|
+
// Per-repo badge palette (multi-repo timelines only) — hues distinct from the default
|
|
73
|
+
// commit-bar blue (#3d59a1) and the session amber (#e0af68); assigned by sorted repo
|
|
74
|
+
// order so a repo's colour is stable across renders.
|
|
75
|
+
const REPO_PALETTE = ["#7aa2f7", "#9ece6a", "#bb9af7", "#f7768e", "#2ac3de", "#ff9e64", "#73daca", "#e0af68"];
|
|
76
|
+
const repoColorMap = (repos) => new Map(repos.map((r, i) => [r, REPO_PALETTE[i % REPO_PALETTE.length]]));
|
|
77
|
+
|
|
53
78
|
/** `nav` is the shared {name: href} nav object the viz CLI computes from the
|
|
54
79
|
* actual sibling output paths — header links render ONLY from its entries, so
|
|
55
80
|
* a page generated without siblings never carries dead links. */
|
|
@@ -58,14 +83,26 @@ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", ge
|
|
|
58
83
|
const sessionCount = commits.filter((c) => c.type === "session").length;
|
|
59
84
|
const commitCount = commits.length - sessionCount;
|
|
60
85
|
const maxTouched = Math.max(1, ...commits.map((c) => c.touched));
|
|
86
|
+
// P4 cross-repo: extractTimeline tags each commit of a MERGED graph with its `repo`.
|
|
87
|
+
// When present, entries are labelled and colour-badged by repo on one global time
|
|
88
|
+
// axis; when absent (single-repo or zero commits) every branch below is byte-for-byte
|
|
89
|
+
// the pre-P4 render.
|
|
90
|
+
const repos = [...new Set(commits.filter((c) => c.repo).map((c) => c.repo))].sort();
|
|
91
|
+
const multi = repos.length > 0;
|
|
92
|
+
const rc = multi ? repoColorMap(repos) : null;
|
|
93
|
+
const badgeStyle = (color) => `display:inline-block;padding:0 6px;border-radius:8px;font-size:11px;font-weight:600;color:#16161e;background:${color}`;
|
|
61
94
|
// Bars run oldest→newest left to right; the list reads newest first. Chat
|
|
62
|
-
// sessions are the visually distinct entries (amber, fixed-height bars).
|
|
95
|
+
// sessions are the visually distinct entries (amber, fixed-height bars). On a
|
|
96
|
+
// multi-repo timeline each commit bar is tinted with its repo colour.
|
|
63
97
|
const bars = commits
|
|
64
|
-
.map((c, i) =>
|
|
65
|
-
c.type === "session"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
.map((c, i) => {
|
|
99
|
+
if (c.type === "session") {
|
|
100
|
+
return `<a class="bar sess" href="#c${i}" title="chat session ${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${c.turns} turn(s)"><i style="height:10px"></i></a>`;
|
|
101
|
+
}
|
|
102
|
+
const barRepoTitle = multi && c.repo ? `[${esc(c.repo)}] ` : "";
|
|
103
|
+
const barRepoBg = multi && c.repo ? `;background:${rc.get(c.repo)}` : "";
|
|
104
|
+
return `<a class="bar" href="#c${i}" title="${barRepoTitle}${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${esc(c.message)}"><i style="height:${Math.max(6, Math.round((c.touched / maxTouched) * 64))}px${barRepoBg}"></i></a>`;
|
|
105
|
+
})
|
|
69
106
|
.join("");
|
|
70
107
|
const rows = commits
|
|
71
108
|
.map((c, i) => {
|
|
@@ -75,10 +112,16 @@ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", ge
|
|
|
75
112
|
const sha = base
|
|
76
113
|
? `<a href="${base}/-/commit/${esc(c.sha)}" target="_blank" rel="noopener"><code>${esc(c.short)}</code></a>`
|
|
77
114
|
: `<code>${esc(c.short)}</code>`;
|
|
78
|
-
|
|
115
|
+
const rowBadge = multi && c.repo ? `<span style="${badgeStyle(rc.get(c.repo))}">${esc(c.repo)}</span> ` : "";
|
|
116
|
+
return `<li id="c${i}">${rowBadge}<span class="d">${esc(c.date.slice(0, 10))}</span> ${sha} <span class="a">${esc(c.author)}</span> <span class="m">${esc(c.message)}</span> <span class="t" title="modules touched">${c.touched}⛁</span></li>`;
|
|
79
117
|
})
|
|
80
118
|
.reverse()
|
|
81
119
|
.join("\n");
|
|
120
|
+
const repoLegend = multi
|
|
121
|
+
? repos
|
|
122
|
+
.map((r) => `<span class="rl"><span style="display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:3px;vertical-align:middle;background:${rc.get(r)}"></span>${esc(r)}</span>`)
|
|
123
|
+
.join(" ")
|
|
124
|
+
: "";
|
|
82
125
|
const navLinks = nav
|
|
83
126
|
? Object.entries(nav)
|
|
84
127
|
.map(([name, href]) => `<a href="${esc(href)}">${name === "home" ? "← home" : esc(name)}</a>`)
|
|
@@ -104,7 +147,7 @@ export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", ge
|
|
|
104
147
|
li.sess{border-left:3px solid #e0af68;padding-left:5px} li.sess code,li.sess .m{color:#e0af68}
|
|
105
148
|
</style></head><body>
|
|
106
149
|
<header>
|
|
107
|
-
<span><b>seonix</b> commit timeline — ${commitCount} commits${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}</span
|
|
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>` : ""}
|
|
108
151
|
${navLinks}
|
|
109
152
|
${base ? `<a href="${base}" target="_blank" rel="noopener">repository ↗</a>` : ""}
|
|
110
153
|
<span class="meta">bars = modules touched per commit · generated from the seonix graph artifact${generatedAt ? ` (${esc(generatedAt.slice(0, 10))})` : ""}</span>
|