@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/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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
- ? `<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>`
67
- : `<a class="bar" href="#c${i}" title="${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"></i></a>`,
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
- return `<li id="c${i}"><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>`;
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>