@polycode-projects/seonix 0.3.0 → 0.5.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 +27 -4
- package/package.json +1 -1
- package/src/ask-vocab.mjs +262 -2
- package/src/ask.mjs +1025 -8
- package/src/browser.mjs +87 -13
- package/src/chat.mjs +644 -47
- package/src/codegraph.mjs +177 -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
|
|
@@ -545,6 +563,27 @@ const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
|
|
|
545
563
|
const BEAM_PLIES = 2; // hops of expansion
|
|
546
564
|
const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
|
|
547
565
|
|
|
566
|
+
// ---- SPIRAL expansion (opt-in, default off; BEAM_RESEARCH.md's "fix #2/#3" made concrete) ------
|
|
567
|
+
// Deterministic bounded-radius ego walk from the lexical seeds, ordered fewest-arcs-first, with a
|
|
568
|
+
// degree-quantile hub gate. UNLIKE beamExpand it MAY introduce modules that had no lexical match
|
|
569
|
+
// (it walks the graph from the seeds), so it can in principle lift a lexically-invisible truth into
|
|
570
|
+
// top-k — the whole point. cochange is dropped (temporal-coupling noise; see the research synthesis).
|
|
571
|
+
// • spiralDepth — max hop radius from the seeds (bounded ego expansion). Default 3.
|
|
572
|
+
// • mostDistinctiveBeams — degree-quantile gate q∈(0,1]: at each expansion step keep only the
|
|
573
|
+
// lowest-degree ⌊q·n⌋ candidates (drop the top (1−q) hubs); q=1.0 keeps
|
|
574
|
+
// all. Never empties the frontier (keeps ≥1 — the least-connected).
|
|
575
|
+
// • spiralNodeLimit — emit budget: how many newly-reached nodes the spiral surfaces. Held at
|
|
576
|
+
// 12 (MID-tier digest breadth, a KNOWN-DOABLE token budget) — a fixed
|
|
577
|
+
// budget, NOT a recall dial.
|
|
578
|
+
const SPIRAL_DEPTH_DEFAULT = 3;
|
|
579
|
+
const SPIRAL_NODE_LIMIT_DEFAULT = 12;
|
|
580
|
+
const SPIRAL_Q_DEFAULT = 0.9; // mild hub pruning (drop only the densest 10%) — the centre point
|
|
581
|
+
const SPIRAL_EXPAND_KINDS = ["imports", "calls", "callsSymbol", "inherits"]; // cochange dropped
|
|
582
|
+
const SPIRAL_EMIT_FRAC = 0.5; // a newly-surfaced node's base score = maxSeed × this …
|
|
583
|
+
const SPIRAL_HOP_DECAY = 0.6; // … decayed by this per hop from the seeds (bounded < maxSeed, so a walked-in node never dominates rank 1)
|
|
584
|
+
const SPIRAL_PROX_FRAC = 0.2; // an ALREADY-matched module the spiral re-reaches gets a bounded nudge …
|
|
585
|
+
const SPIRAL_PROX_CAP_FRAC = 0.35; // … capped at this × its own score (same shape as every other proximity family)
|
|
586
|
+
|
|
548
587
|
/** embedRank: per-module embeddable text — path components + defined symbol names + doc
|
|
549
588
|
* first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
|
|
550
589
|
* caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
|
|
@@ -667,13 +706,106 @@ function beamExpand(graph, scored, beamWidth) {
|
|
|
667
706
|
}
|
|
668
707
|
}
|
|
669
708
|
|
|
709
|
+
/** SPIRAL expansion (opt-in; see the SPIRAL_* constants' comment above for the full design).
|
|
710
|
+
* A deterministic bounded-radius ego walk from the lexical seeds (`scored`), popped
|
|
711
|
+
* fewest-arcs-first via a min-heap keyed (hop ASC, in-graph degree ASC, id ASC), with a
|
|
712
|
+
* degree-quantile hub gate at each expansion step. Emits up to `nodeLimit` newly-reached nodes
|
|
713
|
+
* in pop order, scoring each seed-relative and bounded so a hub can't dominate rank 1.
|
|
714
|
+
* CRITICAL vs beamExpand: it deliberately OMITS the `if (!baseScore.has) continue` guard, so it
|
|
715
|
+
* MAY push modules that had NO lexical match into `scored` — the one path to breaking the lexical
|
|
716
|
+
* ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules).
|
|
717
|
+
* Pure otherwise (no fs/network); deterministic total ordering throughout. */
|
|
718
|
+
function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_Q_DEFAULT, nodeLimit = SPIRAL_NODE_LIMIT_DEFAULT } = {}) {
|
|
719
|
+
if (!scored.length) return;
|
|
720
|
+
const byId = new Map(scored.map((s) => [s.ind.id, s]));
|
|
721
|
+
const seeds = new Set(byId.keys());
|
|
722
|
+
let maxSeed = 0;
|
|
723
|
+
for (const s of scored) maxSeed = Math.max(maxSeed, s.score);
|
|
724
|
+
if (!(maxSeed > 0)) return;
|
|
725
|
+
// Combined undirected adjacency over the expansion kinds (cochange dropped). In-graph degree =
|
|
726
|
+
// neighbour count over these kinds — the "arcs" the frontier orders and the quantile gate reads.
|
|
727
|
+
const adj = adjacencyForKinds(graph, SPIRAL_EXPAND_KINDS);
|
|
728
|
+
const degree = (id) => (adj.get(id)?.size || 0);
|
|
729
|
+
// Binary min-heap over the frontier, keyed (hop ASC, degree ASC, id ASC) — pop the closest,
|
|
730
|
+
// least-connected node first, so expansion fans through the sparse surroundings and fizzles at
|
|
731
|
+
// hubs. The id tiebreak makes the order a deterministic total order (no RNG, no insertion bias).
|
|
732
|
+
const heap = [];
|
|
733
|
+
const less = (a, b) => a.hop !== b.hop ? a.hop < b.hop
|
|
734
|
+
: a.deg !== b.deg ? a.deg < b.deg
|
|
735
|
+
: a.id < b.id;
|
|
736
|
+
const swap = (i, j) => { const t = heap[i]; heap[i] = heap[j]; heap[j] = t; };
|
|
737
|
+
const push = (node) => {
|
|
738
|
+
heap.push(node);
|
|
739
|
+
let i = heap.length - 1;
|
|
740
|
+
while (i > 0) { const p = (i - 1) >> 1; if (less(heap[i], heap[p])) { swap(i, p); i = p; } else break; }
|
|
741
|
+
};
|
|
742
|
+
const pop = () => {
|
|
743
|
+
const top = heap[0];
|
|
744
|
+
const last = heap.pop();
|
|
745
|
+
if (heap.length) {
|
|
746
|
+
heap[0] = last;
|
|
747
|
+
let i = 0;
|
|
748
|
+
for (;;) {
|
|
749
|
+
const l = 2 * i + 1, r = 2 * i + 2; let m = i;
|
|
750
|
+
if (l < heap.length && less(heap[l], heap[m])) m = l;
|
|
751
|
+
if (r < heap.length && less(heap[r], heap[m])) m = r;
|
|
752
|
+
if (m === i) break;
|
|
753
|
+
swap(i, m); i = m;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return top;
|
|
757
|
+
};
|
|
758
|
+
const visited = new Set(seeds);
|
|
759
|
+
for (const id of seeds) push({ id, hop: 0, deg: degree(id) });
|
|
760
|
+
const defIdx = definesIndex(graph);
|
|
761
|
+
let emitted = 0;
|
|
762
|
+
while (heap.length && emitted < nodeLimit) {
|
|
763
|
+
const node = pop();
|
|
764
|
+
if (!seeds.has(node.id)) {
|
|
765
|
+
// Slot this newly-reached node: seed-relative base, hop-decayed and bounded below maxSeed.
|
|
766
|
+
const emitScore = maxSeed * SPIRAL_EMIT_FRAC * Math.pow(SPIRAL_HOP_DECAY, node.hop - 1);
|
|
767
|
+
const existing = byId.get(node.id);
|
|
768
|
+
if (existing) { // already lexically matched (below-k) → bounded nudge, never a replacement
|
|
769
|
+
existing.score += Math.min(emitScore * SPIRAL_PROX_FRAC, existing.score * SPIRAL_PROX_CAP_FRAC);
|
|
770
|
+
} else { // lexically INVISIBLE → introduce it (the beam structurally cannot)
|
|
771
|
+
const ind = graph.byId?.get?.(node.id);
|
|
772
|
+
if (ind && (ind.class || "") === "Module") {
|
|
773
|
+
const defines = defIdx.get(ind.id) || [];
|
|
774
|
+
const entry = { ind, score: emitScore, defineCount: defines.length, matching: [], density: 0 };
|
|
775
|
+
scored.push(entry);
|
|
776
|
+
byId.set(node.id, entry);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
emitted++;
|
|
780
|
+
}
|
|
781
|
+
if (node.hop >= depth) continue;
|
|
782
|
+
// This step's candidate set = the popped node's unvisited MODULE neighbours; quantile-gate by
|
|
783
|
+
// degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never fewer than one.
|
|
784
|
+
const cands = [];
|
|
785
|
+
for (const nid of adj.get(node.id) || []) {
|
|
786
|
+
if (visited.has(nid)) continue;
|
|
787
|
+
const ind = graph.byId?.get?.(nid);
|
|
788
|
+
if (!ind || (ind.class || "") !== "Module") continue; // no phantom (fn-fallback) module ids
|
|
789
|
+
cands.push({ id: nid, deg: degree(nid) });
|
|
790
|
+
}
|
|
791
|
+
if (!cands.length) continue;
|
|
792
|
+
cands.sort((a, b) => a.deg - b.deg || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
793
|
+
const keep = Math.max(1, Math.floor(q * cands.length)); // lowest-degree q-fraction; never empty
|
|
794
|
+
for (let i = 0; i < keep; i++) {
|
|
795
|
+
const c = cands[i];
|
|
796
|
+
visited.add(c.id);
|
|
797
|
+
push({ id: c.id, hop: node.hop + 1, deg: c.deg });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
670
802
|
/** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
|
|
671
803
|
* IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
|
|
672
804
|
* swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
|
|
673
805
|
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
674
806
|
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
675
807
|
function scoreModules(graph, tokens, opts = {}) {
|
|
676
|
-
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, proseBoost = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
808
|
+
const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
|
|
677
809
|
const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
|
|
678
810
|
const defIdx = definesIndex(graph);
|
|
679
811
|
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
@@ -869,6 +1001,41 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
869
1001
|
}
|
|
870
1002
|
}
|
|
871
1003
|
}
|
|
1004
|
+
// Layered prose normalisation (opt-in): a query token that did NOT match a module lexically but
|
|
1005
|
+
// resolves to one of its individuals through a NORMALISED prose layer (stem/lemma/canonical/spell)
|
|
1006
|
+
// adds a bounded, discounted signal — see the PROSE_LAYER_* comment above. One proseLayerHits call
|
|
1007
|
+
// per DISTINCT query token, ids folded to their containing module via moduleIdOfId (same as the
|
|
1008
|
+
// proseBoost/call-adjacency families). Only tokens NOT already matching a module lexically count
|
|
1009
|
+
// for that module (a layer hit is purely ADDITIVE evidence for otherwise-missed words — never
|
|
1010
|
+
// double-counting a token the base score already saw), weighted by the token's own IDF (so a
|
|
1011
|
+
// ubiquitous word contributes almost nothing) and halved (PROSE_LAYER_DISCOUNT: weaker than a
|
|
1012
|
+
// verbatim match), then the shared FRAC/CAP nudge. Only re-ranks modules already in `scored`.
|
|
1013
|
+
if (proseLayers && scored.length && graph.proseIndex) {
|
|
1014
|
+
const scoredById = new Map(scored.map((s) => [s.ind.id, s]));
|
|
1015
|
+
const modById = new Map(modules.map((m) => [m.ind.id, m]));
|
|
1016
|
+
const layerSignal = new Map(); // moduleId -> accumulated discounted, IDF-weighted layer signal
|
|
1017
|
+
for (const t of new Set(tokens)) {
|
|
1018
|
+
const w = idf.get(t) || 0;
|
|
1019
|
+
if (!w) continue;
|
|
1020
|
+
const { ids } = proseLayerHits(graph.proseIndex, t);
|
|
1021
|
+
if (!ids.length) continue;
|
|
1022
|
+
const hitMods = new Set();
|
|
1023
|
+
for (const id of ids) {
|
|
1024
|
+
const modId = moduleIdOfId(graph, id);
|
|
1025
|
+
if (!modId || hitMods.has(modId)) continue;
|
|
1026
|
+
hitMods.add(modId);
|
|
1027
|
+
if (!scoredById.has(modId)) continue; // never a new zero-match candidate
|
|
1028
|
+
const m = modById.get(modId);
|
|
1029
|
+
if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically → not additive
|
|
1030
|
+
layerSignal.set(modId, (layerSignal.get(modId) || 0) + w * PROSE_LAYER_DISCOUNT);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (const s of scored) {
|
|
1034
|
+
const signal = layerSignal.get(s.ind.id) || 0;
|
|
1035
|
+
if (!signal) continue;
|
|
1036
|
+
s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
872
1039
|
// §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank — see the EMB_* constants'
|
|
873
1040
|
// comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
|
|
874
1041
|
// this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
|
|
@@ -903,6 +1070,14 @@ function scoreModules(graph, tokens, opts = {}) {
|
|
|
903
1070
|
}
|
|
904
1071
|
// §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
|
|
905
1072
|
if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
|
|
1073
|
+
// SPIRAL (opt-in): bounded-radius ego walk that MAY introduce lexically-invisible modules — runs
|
|
1074
|
+
// last (after every family has finalised the seed scores) so its seed-relative emit scores and
|
|
1075
|
+
// hub gate read the settled ranking, and before the sort so surfaced nodes slot into it.
|
|
1076
|
+
if (spiral && scored.length) spiralExpand(graph, scored, {
|
|
1077
|
+
depth: Number.isFinite(opts.spiralDepth) && opts.spiralDepth > 0 ? opts.spiralDepth : SPIRAL_DEPTH_DEFAULT,
|
|
1078
|
+
q: Number.isFinite(opts.mostDistinctiveBeams) && opts.mostDistinctiveBeams > 0 ? opts.mostDistinctiveBeams : SPIRAL_Q_DEFAULT,
|
|
1079
|
+
nodeLimit: Number.isFinite(opts.spiralNodeLimit) && opts.spiralNodeLimit > 0 ? opts.spiralNodeLimit : SPIRAL_NODE_LIMIT_DEFAULT,
|
|
1080
|
+
});
|
|
906
1081
|
// Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
|
|
907
1082
|
scored.sort((a, b) => b.score - a.score || b.density - a.density || a.defineCount - b.defineCount || String(a.ind.label).length - String(b.ind.label).length);
|
|
908
1083
|
return scored;
|
|
@@ -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>
|