gitnexus 1.6.5-rc.21 → 1.6.5-rc.23
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/dist/core/search/hybrid-search.d.ts +3 -0
- package/dist/core/search/hybrid-search.js +23 -6
- package/dist/core/wiki/generator.js +4 -3
- package/dist/core/wiki/html-viewer.js +2 -1
- package/dist/core/wiki/mermaid-sanitizer.d.ts +2 -0
- package/dist/core/wiki/mermaid-sanitizer.js +100 -0
- package/dist/mcp/local/local-backend.js +20 -7
- package/package.json +1 -1
|
@@ -46,5 +46,8 @@ export declare const formatHybridResults: (results: HybridSearchResult[]) => str
|
|
|
46
46
|
* Execute BM25 + semantic search and merge with RRF.
|
|
47
47
|
* Uses LadybugDB FTS for always-fresh BM25 results (no cached data).
|
|
48
48
|
* The semanticSearch function is injected to keep this module environment-agnostic.
|
|
49
|
+
*
|
|
50
|
+
* When FTS is unavailable (e.g. read-only MCP connection, missing indexes),
|
|
51
|
+
* falls back to semantic-only results instead of crashing (#1489).
|
|
49
52
|
*/
|
|
50
53
|
export declare const hybridSearch: (query: string, limit: number, executeQuery: (cypher: string) => Promise<any[]>, semanticSearch: (executeQuery: (cypher: string) => Promise<any[]>, query: string, k?: number) => Promise<SemanticSearchResult[]>) => Promise<HybridSearchResult[]>;
|
|
@@ -23,9 +23,14 @@ const RRF_K = 60;
|
|
|
23
23
|
*/
|
|
24
24
|
export const mergeWithRRF = (bm25Results, semanticResults, limit = 10) => {
|
|
25
25
|
const merged = new Map();
|
|
26
|
+
// Guard against undefined/null inputs (#1489) — when FTS is unavailable
|
|
27
|
+
// in the MCP process, bm25Results can arrive as undefined and the
|
|
28
|
+
// for-loop would throw "bm25Results is not iterable".
|
|
29
|
+
const safeBm25 = bm25Results ?? [];
|
|
30
|
+
const safeSemantic = semanticResults ?? [];
|
|
26
31
|
// Process BM25 results
|
|
27
|
-
for (let i = 0; i <
|
|
28
|
-
const r =
|
|
32
|
+
for (let i = 0; i < safeBm25.length; i++) {
|
|
33
|
+
const r = safeBm25[i];
|
|
29
34
|
const rrfScore = 1 / (RRF_K + i + 1); // i+1 because rank starts at 1
|
|
30
35
|
merged.set(r.filePath, {
|
|
31
36
|
filePath: r.filePath,
|
|
@@ -36,8 +41,8 @@ export const mergeWithRRF = (bm25Results, semanticResults, limit = 10) => {
|
|
|
36
41
|
});
|
|
37
42
|
}
|
|
38
43
|
// Process semantic results and merge
|
|
39
|
-
for (let i = 0; i <
|
|
40
|
-
const r =
|
|
44
|
+
for (let i = 0; i < safeSemantic.length; i++) {
|
|
45
|
+
const r = safeSemantic[i];
|
|
41
46
|
const rrfScore = 1 / (RRF_K + i + 1);
|
|
42
47
|
const existing = merged.get(r.filePath);
|
|
43
48
|
if (existing) {
|
|
@@ -110,10 +115,22 @@ export const formatHybridResults = (results) => {
|
|
|
110
115
|
* Execute BM25 + semantic search and merge with RRF.
|
|
111
116
|
* Uses LadybugDB FTS for always-fresh BM25 results (no cached data).
|
|
112
117
|
* The semanticSearch function is injected to keep this module environment-agnostic.
|
|
118
|
+
*
|
|
119
|
+
* When FTS is unavailable (e.g. read-only MCP connection, missing indexes),
|
|
120
|
+
* falls back to semantic-only results instead of crashing (#1489).
|
|
113
121
|
*/
|
|
114
122
|
export const hybridSearch = async (query, limit, executeQuery, semanticSearch) => {
|
|
115
|
-
// Use LadybugDB FTS for always-fresh BM25 results
|
|
116
|
-
|
|
123
|
+
// Use LadybugDB FTS for always-fresh BM25 results.
|
|
124
|
+
// If FTS fails (e.g. extension not loaded in MCP process), fall back to
|
|
125
|
+
// semantic-only search instead of crashing with "bm25Results is not iterable".
|
|
126
|
+
let bm25Results = [];
|
|
127
|
+
try {
|
|
128
|
+
const ftsResponse = await searchFTSFromLbug(query, limit);
|
|
129
|
+
bm25Results = ftsResponse?.results ?? [];
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// FTS unavailable — continue with semantic-only search
|
|
133
|
+
}
|
|
117
134
|
const semanticResults = await semanticSearch(executeQuery, query, limit);
|
|
118
135
|
return mergeWithRRF(bm25Results, semanticResults, limit);
|
|
119
136
|
};
|
|
@@ -14,6 +14,7 @@ import path from 'path';
|
|
|
14
14
|
import { execSync, execFileSync } from 'child_process';
|
|
15
15
|
import { initWikiDb, closeWikiDb, touchWikiDb, getFilesWithExports, getAllFiles, getIntraModuleCallEdges, getInterModuleCallEdges, getProcessesForFiles, getAllProcesses, getInterModuleEdgesForOverview, } from './graph-queries.js';
|
|
16
16
|
import { generateHTMLViewer } from './html-viewer.js';
|
|
17
|
+
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
|
17
18
|
import { callLLM, estimateTokens, } from './llm-client.js';
|
|
18
19
|
import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
|
|
19
20
|
import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, } from './prompts.js';
|
|
@@ -445,7 +446,7 @@ export class WikiGenerator {
|
|
|
445
446
|
});
|
|
446
447
|
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
447
448
|
// Write page with front matter
|
|
448
|
-
const pageContent = `# ${node.name}\n\n${response.content}
|
|
449
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
|
449
450
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
450
451
|
}
|
|
451
452
|
/**
|
|
@@ -480,7 +481,7 @@ export class WikiGenerator {
|
|
|
480
481
|
CROSS_PROCESSES: formatProcesses(processes),
|
|
481
482
|
});
|
|
482
483
|
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
483
|
-
const pageContent = `# ${node.name}\n\n${response.content}
|
|
484
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
|
484
485
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
485
486
|
}
|
|
486
487
|
// ─── Phase 3: Generate Overview ─────────────────────────────────────
|
|
@@ -516,7 +517,7 @@ export class WikiGenerator {
|
|
|
516
517
|
TOP_PROCESSES: formatProcesses(topProcesses),
|
|
517
518
|
});
|
|
518
519
|
const response = await this.invokeLLM(prompt, OVERVIEW_SYSTEM_PROMPT, this.streamOpts('Generating overview', 88));
|
|
519
|
-
const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}
|
|
520
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`);
|
|
520
521
|
await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
|
|
521
522
|
}
|
|
522
523
|
// ─── Incremental Updates ────────────────────────────────────────────
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
|
9
10
|
/**
|
|
10
11
|
* Generate the wiki HTML viewer (index.html) from existing markdown pages.
|
|
11
12
|
*/
|
|
@@ -33,7 +34,7 @@ export async function generateHTMLViewer(wikiDir, projectName) {
|
|
|
33
34
|
const dirEntries = await fs.readdir(wikiDir);
|
|
34
35
|
for (const f of dirEntries.filter((f) => f.endsWith('.md'))) {
|
|
35
36
|
const content = await fs.readFile(path.join(wikiDir, f), 'utf-8');
|
|
36
|
-
pages[f.replace(/\.md$/, '')] = content;
|
|
37
|
+
pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content);
|
|
37
38
|
}
|
|
38
39
|
const html = buildHTML(projectName, moduleTree, pages, meta);
|
|
39
40
|
const outputPath = path.join(wikiDir, 'index.html');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
2
|
+
const NODE_LABEL_RE = /(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g;
|
|
3
|
+
const EDGE_LABEL_RE = /\|([^|\n]+)\|/g;
|
|
4
|
+
const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/;
|
|
5
|
+
const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/;
|
|
6
|
+
const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/;
|
|
7
|
+
const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/;
|
|
8
|
+
const EDGE_RE = /(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g;
|
|
9
|
+
export function sanitizeMermaidMarkdown(markdown) {
|
|
10
|
+
return markdown.replace(MERMAID_FENCE_RE, (_match, diagram) => {
|
|
11
|
+
return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```';
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function sanitizeMermaidDiagram(diagram) {
|
|
15
|
+
const aliases = new Map();
|
|
16
|
+
let nextAlias = 1;
|
|
17
|
+
const aliasFor = (id) => {
|
|
18
|
+
const existing = aliases.get(id);
|
|
19
|
+
if (existing)
|
|
20
|
+
return existing;
|
|
21
|
+
const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
|
|
22
|
+
let alias = base;
|
|
23
|
+
while ([...aliases.values()].includes(alias)) {
|
|
24
|
+
nextAlias += 1;
|
|
25
|
+
alias = `${base}_${nextAlias}`;
|
|
26
|
+
}
|
|
27
|
+
aliases.set(id, alias);
|
|
28
|
+
return alias;
|
|
29
|
+
};
|
|
30
|
+
return diagram
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => sanitizeMermaidLine(line, aliasFor))
|
|
33
|
+
.join('\n');
|
|
34
|
+
}
|
|
35
|
+
function sanitizeMermaidLine(line, aliasFor) {
|
|
36
|
+
let sanitized = replaceLiteralLineBreaksInLabels(line);
|
|
37
|
+
sanitized = quoteUnsafeEdgeLabels(sanitized);
|
|
38
|
+
const prefixMatch = sanitized.match(LINE_PREFIX_RE);
|
|
39
|
+
if (!prefixMatch)
|
|
40
|
+
return sanitized;
|
|
41
|
+
const prefix = prefixMatch[1];
|
|
42
|
+
const body = prefixMatch[2];
|
|
43
|
+
if (isDirectiveLine(body))
|
|
44
|
+
return sanitized;
|
|
45
|
+
const parts = body.split(EDGE_RE);
|
|
46
|
+
if (parts.length === 1)
|
|
47
|
+
return sanitized;
|
|
48
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
49
|
+
parts[i] = sanitizeNodeReference(parts[i], aliasFor);
|
|
50
|
+
}
|
|
51
|
+
return prefix + parts.join('');
|
|
52
|
+
}
|
|
53
|
+
function replaceLiteralLineBreaksInLabels(line) {
|
|
54
|
+
return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '<br/>'));
|
|
55
|
+
}
|
|
56
|
+
function quoteUnsafeEdgeLabels(line) {
|
|
57
|
+
return line.replace(EDGE_LABEL_RE, (match, label) => {
|
|
58
|
+
const trimmed = label.trim();
|
|
59
|
+
if (!UNSAFE_EDGE_LABEL_RE.test(trimmed))
|
|
60
|
+
return match;
|
|
61
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
62
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
63
|
+
return match;
|
|
64
|
+
}
|
|
65
|
+
return `|"${escapeMermaidLabel(trimmed)}"|`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function sanitizeNodeReference(segment, aliasFor) {
|
|
69
|
+
const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/);
|
|
70
|
+
if (!match)
|
|
71
|
+
return segment;
|
|
72
|
+
const [, leading, id, suffix, trailing] = match;
|
|
73
|
+
if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id))
|
|
74
|
+
return segment;
|
|
75
|
+
const hasInlineLabel = suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{');
|
|
76
|
+
if (hasInlineLabel)
|
|
77
|
+
return `${leading}${aliasFor(id)}${suffix}${trailing}`;
|
|
78
|
+
return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
|
|
79
|
+
}
|
|
80
|
+
function escapeMermaidLabel(label) {
|
|
81
|
+
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
82
|
+
}
|
|
83
|
+
function isDirectiveLine(line) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
return (trimmed === '' ||
|
|
86
|
+
trimmed.startsWith('%%') ||
|
|
87
|
+
trimmed.startsWith('graph ') ||
|
|
88
|
+
trimmed.startsWith('flowchart ') ||
|
|
89
|
+
trimmed.startsWith('sequenceDiagram') ||
|
|
90
|
+
trimmed.startsWith('classDiagram') ||
|
|
91
|
+
trimmed.startsWith('stateDiagram') ||
|
|
92
|
+
trimmed.startsWith('erDiagram') ||
|
|
93
|
+
trimmed.startsWith('journey') ||
|
|
94
|
+
trimmed.startsWith('gantt') ||
|
|
95
|
+
trimmed.startsWith('pie ') ||
|
|
96
|
+
trimmed.startsWith('mindmap') ||
|
|
97
|
+
trimmed.startsWith('timeline') ||
|
|
98
|
+
trimmed.startsWith('subgraph ') ||
|
|
99
|
+
trimmed === 'end');
|
|
100
|
+
}
|
|
@@ -634,8 +634,10 @@ export class LocalBackend {
|
|
|
634
634
|
timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)),
|
|
635
635
|
timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
|
|
636
636
|
]);
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
// Guard against undefined results (#1489) — when FTS is entirely
|
|
638
|
+
// unavailable the search helper may return an unexpected shape.
|
|
639
|
+
const bm25Results = bm25SearchResult?.results ?? [];
|
|
640
|
+
const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
|
|
639
641
|
// Merge via reciprocal rank fusion
|
|
640
642
|
timer.start('merge');
|
|
641
643
|
const scoreMap = new Map();
|
|
@@ -651,8 +653,9 @@ export class LocalBackend {
|
|
|
651
653
|
scoreMap.set(key, { score: rrfScore, data: result });
|
|
652
654
|
}
|
|
653
655
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
+
const safeSemanticResults = semanticResults ?? [];
|
|
657
|
+
for (let i = 0; i < safeSemanticResults.length; i++) {
|
|
658
|
+
const result = safeSemanticResults[i];
|
|
656
659
|
const key = result.nodeId || result.filePath;
|
|
657
660
|
const rrfScore = 1 / (60 + i);
|
|
658
661
|
const existing = scoreMap.get(key);
|
|
@@ -826,7 +829,15 @@ export class LocalBackend {
|
|
|
826
829
|
* BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
|
|
827
830
|
*/
|
|
828
831
|
async bm25Search(repo, query, limit) {
|
|
829
|
-
|
|
832
|
+
let searchFTSFromLbug;
|
|
833
|
+
try {
|
|
834
|
+
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
// Module import can fail in sandboxed MCP contexts (#1489)
|
|
838
|
+
logger.warn({ err: err?.message }, 'GitNexus: bm25-index.js import failed — falling back to semantic-only');
|
|
839
|
+
return { results: [], ftsUsed: false };
|
|
840
|
+
}
|
|
830
841
|
let ftsResponse;
|
|
831
842
|
try {
|
|
832
843
|
ftsResponse = await searchFTSFromLbug(query, limit, repo.id);
|
|
@@ -835,8 +846,10 @@ export class LocalBackend {
|
|
|
835
846
|
logger.error({ err: err.message }, 'GitNexus: BM25/FTS search failed (FTS indexes may not exist) -');
|
|
836
847
|
return { results: [], ftsUsed: false };
|
|
837
848
|
}
|
|
838
|
-
|
|
839
|
-
|
|
849
|
+
// Guard against unexpected response shape (#1489) — ftsResponse.results
|
|
850
|
+
// could be undefined when the FTS extension is unavailable in the MCP process.
|
|
851
|
+
const bm25Results = ftsResponse?.results ?? [];
|
|
852
|
+
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
|
840
853
|
const results = [];
|
|
841
854
|
for (const bm25Result of bm25Results) {
|
|
842
855
|
const fullPath = bm25Result.filePath;
|
package/package.json
CHANGED