@dpesch/mantisbt-mcp-server 1.14.1 → 1.16.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/CHANGELOG.md +64 -0
- package/README.de.md +123 -33
- package/README.md +123 -33
- package/dist/client.js +4 -0
- package/dist/coerce.js +8 -0
- package/dist/config.js +5 -2
- package/dist/deprecation.js +9 -0
- package/dist/index.js +20 -31
- package/dist/issue-enrich.js +23 -0
- package/dist/issue-envelope.js +4 -0
- package/dist/prompts/index.js +4 -4
- package/dist/resources/index.js +4 -8
- package/dist/search/embedder.js +83 -34
- package/dist/search/index.js +9 -7
- package/dist/search/lexical.js +88 -0
- package/dist/search/store.js +44 -3
- package/dist/search/sync.js +26 -15
- package/dist/search/tools.js +23 -22
- package/dist/tools/config.js +9 -4
- package/dist/tools/files.js +12 -5
- package/dist/tools/index.js +34 -0
- package/dist/tools/issues.js +285 -147
- package/dist/tools/metadata.js +99 -40
- package/dist/tools/monitors.js +145 -20
- package/dist/tools/notes.js +67 -9
- package/dist/tools/project-versions.js +40 -18
- package/dist/tools/projects.js +109 -37
- package/dist/tools/users.js +3 -1
- package/dist/tools/version.js +61 -35
- package/dist/toolsets.js +104 -0
- package/docs/cookbook.de.md +604 -183
- package/docs/cookbook.md +604 -183
- package/docs/examples.de.md +37 -3
- package/docs/examples.md +37 -3
- package/package.json +3 -1
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -38,6 +38,10 @@ export function buildIssueViewUrl(baseUrl, issueId) {
|
|
|
38
38
|
export function buildNoteViewUrl(baseUrl, issueId, noteId) {
|
|
39
39
|
return `${baseUrl}/view.php?id=${issueId}#bugnote${noteId}`;
|
|
40
40
|
}
|
|
41
|
+
/** MantisBT sends download_url for attachments only via SOAP, so the REST tools build it themselves. */
|
|
42
|
+
export function buildAttachmentDownloadUrl(baseUrl, fileId) {
|
|
43
|
+
return `${baseUrl}/file_download.php?file_id=${fileId}&type=bug`;
|
|
44
|
+
}
|
|
41
45
|
// ---------------------------------------------------------------------------
|
|
42
46
|
// MantisApiError
|
|
43
47
|
// ---------------------------------------------------------------------------
|
package/dist/coerce.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the strings "true"/"false" into booleans before zod validation. Some
|
|
3
|
+
* MCP clients send boolean tool arguments as strings. Anything else is passed
|
|
4
|
+
* through unchanged, so zod still rejects invalid input.
|
|
5
|
+
*
|
|
6
|
+
* Use as z.preprocess(coerceBool, z.boolean()…).
|
|
7
|
+
*/
|
|
8
|
+
export const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { normalizeBaseUrl, usesIndexPhpEntryPoint } from './client.js';
|
|
6
|
+
import { parseToolsets } from './toolsets.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// .env.local loader
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
@@ -39,6 +40,7 @@ async function readDotEnvLocal() {
|
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
// Non-credential config (safe to read at startup without credentials)
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
43
|
+
export const DEFAULT_SEARCH_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
|
|
42
44
|
function readNonCredentialConfig() {
|
|
43
45
|
const defaultCacheDir = join(homedir(), '.cache', 'mantisbt-mcp');
|
|
44
46
|
const cacheDir = process.env.MANTIS_CACHE_DIR ?? defaultCacheDir;
|
|
@@ -52,8 +54,7 @@ function readNonCredentialConfig() {
|
|
|
52
54
|
}
|
|
53
55
|
const searchBackend = searchBackendRaw === 'sqlite-vec' ? 'sqlite-vec' : 'vectra';
|
|
54
56
|
const searchDir = process.env.MANTIS_SEARCH_DIR ?? join(cacheDir, 'search');
|
|
55
|
-
const searchModelName = process.env.MANTIS_SEARCH_MODEL ??
|
|
56
|
-
'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
|
|
57
|
+
const searchModelName = process.env.MANTIS_SEARCH_MODEL ?? DEFAULT_SEARCH_MODEL;
|
|
57
58
|
const searchNumThreads = Math.max(1, parseInt(process.env.MANTIS_SEARCH_THREADS ?? '', 10) || 1);
|
|
58
59
|
return {
|
|
59
60
|
cacheDir,
|
|
@@ -70,6 +71,8 @@ function readNonCredentialConfig() {
|
|
|
70
71
|
numThreads: searchNumThreads,
|
|
71
72
|
},
|
|
72
73
|
testEnvironment: process.env.MCP_TEST_ENVIRONMENT === 'true',
|
|
74
|
+
toolsets: parseToolsets(process.env.MANTIS_TOOLSETS),
|
|
75
|
+
disableDestructive: process.env.MANTIS_DISABLE_DESTRUCTIVE === 'true',
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Major version in which deprecated tools are removed (see versioning rules in CLAUDE.md). */
|
|
2
|
+
export const REMOVAL_VERSION = '2.0.0';
|
|
3
|
+
/**
|
|
4
|
+
* First line of the description of a deprecated tool. Keeping the wording in
|
|
5
|
+
* one place keeps README, CHANGELOG and tests consistent.
|
|
6
|
+
*/
|
|
7
|
+
export function deprecationNote(tool, replacement) {
|
|
8
|
+
return `Deprecated: use ${replacement} instead — ${tool} will be removed in ${REMOVAL_VERSION}.`;
|
|
9
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -10,19 +10,8 @@ import { getConfig, getStartupConfig, assertHttpAuthConfigured } from './config.
|
|
|
10
10
|
import { MantisClient } from './client.js';
|
|
11
11
|
import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
|
|
12
12
|
import { MetadataCache } from './cache.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { registerFileTools } from './tools/files.js';
|
|
16
|
-
import { registerRelationshipTools } from './tools/relationships.js';
|
|
17
|
-
import { registerMonitorTools } from './tools/monitors.js';
|
|
18
|
-
import { registerProjectTools } from './tools/projects.js';
|
|
19
|
-
import { registerProjectVersionTools } from './tools/project-versions.js';
|
|
20
|
-
import { registerUserTools } from './tools/users.js';
|
|
21
|
-
import { registerFilterTools } from './tools/filters.js';
|
|
22
|
-
import { registerConfigTools } from './tools/config.js';
|
|
23
|
-
import { registerMetadataTools } from './tools/metadata.js';
|
|
24
|
-
import { registerTagTools } from './tools/tags.js';
|
|
25
|
-
import { registerVersionTools } from './tools/version.js';
|
|
13
|
+
import { registerAllTools } from './tools/index.js';
|
|
14
|
+
import { filterToolRegistration } from './toolsets.js';
|
|
26
15
|
import { registerPrompts } from './prompts/index.js';
|
|
27
16
|
import { registerResources } from './resources/index.js';
|
|
28
17
|
// ---------------------------------------------------------------------------
|
|
@@ -55,26 +44,26 @@ async function createMcpServer(transport) {
|
|
|
55
44
|
name: 'mantisbt-mcp-server',
|
|
56
45
|
version,
|
|
57
46
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
47
|
+
// Tools register through a filter for MANTIS_TOOLSETS / MANTIS_DISABLE_DESTRUCTIVE;
|
|
48
|
+
// prompts and resources use the server directly.
|
|
49
|
+
const tools = filterToolRegistration(server, startupConfig);
|
|
50
|
+
// Optional: Semantic search module — needs MANTIS_SEARCH_ENABLED=true and the
|
|
51
|
+
// "search" tool group (skipping it avoids loading the model and the index).
|
|
52
|
+
// Initialized first so get_metadata can report the index status.
|
|
53
|
+
const searchIndexStatus = startupConfig.search.enabled && startupConfig.toolsets.has('search')
|
|
54
|
+
? await (await import('./search/index.js')).initializeSearchModule(tools, client, startupConfig.search)
|
|
55
|
+
: undefined;
|
|
56
|
+
registerAllTools(tools, {
|
|
57
|
+
client,
|
|
58
|
+
cache,
|
|
59
|
+
versionHint,
|
|
60
|
+
version,
|
|
61
|
+
uploadDir: startupConfig.uploadDir,
|
|
62
|
+
transport,
|
|
63
|
+
searchIndexStatus,
|
|
64
|
+
});
|
|
71
65
|
registerPrompts(server);
|
|
72
66
|
registerResources(server, client, cache, startupConfig.testEnvironment);
|
|
73
|
-
// Optional: Semantic search module
|
|
74
|
-
if (startupConfig.search.enabled) {
|
|
75
|
-
const { initializeSearchModule } = await import('./search/index.js');
|
|
76
|
-
await initializeSearchModule(server, client, startupConfig.search);
|
|
77
|
-
}
|
|
78
67
|
return server;
|
|
79
68
|
}
|
|
80
69
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildAttachmentDownloadUrl, buildIssueViewUrl, buildNoteViewUrl } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Adds the links MantisBT's REST API leaves out: view_url for the issue and
|
|
4
|
+
* each note, download_url for each attachment (REST sends it only via SOAP).
|
|
5
|
+
* issueId is the fallback when a select projection excluded the id field.
|
|
6
|
+
*/
|
|
7
|
+
export function enrichIssue(issue, baseUrl, issueId) {
|
|
8
|
+
const id = issue.id ?? issueId;
|
|
9
|
+
if (id === undefined)
|
|
10
|
+
return issue;
|
|
11
|
+
return {
|
|
12
|
+
...issue,
|
|
13
|
+
view_url: buildIssueViewUrl(baseUrl, id),
|
|
14
|
+
notes: issue.notes?.map(note => ({
|
|
15
|
+
...note,
|
|
16
|
+
view_url: buildNoteViewUrl(baseUrl, id, note.id),
|
|
17
|
+
})),
|
|
18
|
+
attachments: issue.attachments?.map(file => ({
|
|
19
|
+
...file,
|
|
20
|
+
download_url: buildAttachmentDownloadUrl(baseUrl, file.id),
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/prompts/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export function registerPrompts(server) {
|
|
|
16
16
|
description: 'Guide the creation of a complete bug report in MantisBT. Project, category, summary, and description are required.',
|
|
17
17
|
argsSchema: {
|
|
18
18
|
project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
|
|
19
|
-
category: z.string().describe('Issue category (call
|
|
19
|
+
category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
|
|
20
20
|
summary: z.string().describe('Short, descriptive summary of the bug'),
|
|
21
21
|
description: z.string().describe('Detailed description of the bug'),
|
|
22
22
|
steps_to_reproduce: z.string().optional().describe('Step-by-step reproduction instructions'),
|
|
@@ -46,7 +46,7 @@ export function registerPrompts(server) {
|
|
|
46
46
|
if (environment) {
|
|
47
47
|
lines.push(``, `Environment:`, environment);
|
|
48
48
|
}
|
|
49
|
-
lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call
|
|
49
|
+
lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_schema first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
|
|
50
50
|
return userMessage(lines.join('\n'));
|
|
51
51
|
});
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
@@ -57,7 +57,7 @@ export function registerPrompts(server) {
|
|
|
57
57
|
description: 'Guide the creation of a feature request in MantisBT. Project, category, summary, and description are required.',
|
|
58
58
|
argsSchema: {
|
|
59
59
|
project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
|
|
60
|
-
category: z.string().describe('Issue category (call
|
|
60
|
+
category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
|
|
61
61
|
summary: z.string().describe('Short description of the requested feature'),
|
|
62
62
|
description: z.string().describe('Detailed description of the feature'),
|
|
63
63
|
use_case: z.string().optional().describe('Business use case or motivation for the feature'),
|
|
@@ -75,7 +75,7 @@ export function registerPrompts(server) {
|
|
|
75
75
|
if (use_case) {
|
|
76
76
|
lines.push(``, `Use case / motivation:`, use_case);
|
|
77
77
|
}
|
|
78
|
-
lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call
|
|
78
|
+
lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_schema first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
|
|
79
79
|
return userMessage(lines.join('\n'));
|
|
80
80
|
});
|
|
81
81
|
// ---------------------------------------------------------------------------
|
package/dist/resources/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { fetchIssueEnums } from '../tools/config.js';
|
|
3
|
-
import { normalizeProject } from '../tools/metadata.js';
|
|
4
|
-
import { ALL_PROJECTS_PREFIX } from '../constants.js';
|
|
3
|
+
import { normalizeProject, stripAllProjectsPrefix } from '../tools/metadata.js';
|
|
5
4
|
function jsonResource(uri, data) {
|
|
6
5
|
return {
|
|
7
6
|
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }],
|
|
@@ -21,7 +20,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
21
20
|
}, async (uri) => jsonResource(uri, await client.get('users/me')));
|
|
22
21
|
server.registerResource('projects', 'mantis://projects', {
|
|
23
22
|
title: 'Projects',
|
|
24
|
-
description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via
|
|
23
|
+
description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via get_metadata with refresh: true.',
|
|
25
24
|
mimeType: 'application/json',
|
|
26
25
|
}, async (uri) => jsonResource(uri, await loadProjects()));
|
|
27
26
|
server.registerResource('project-detail', new ResourceTemplate('mantis://projects/{id}', {
|
|
@@ -40,7 +39,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
40
39
|
},
|
|
41
40
|
}), {
|
|
42
41
|
title: 'Project Detail',
|
|
43
|
-
description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via
|
|
42
|
+
description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via get_metadata with refresh: true.',
|
|
44
43
|
mimeType: 'application/json',
|
|
45
44
|
}, async (uri, { id }) => {
|
|
46
45
|
const numId = Number(id);
|
|
@@ -66,10 +65,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
66
65
|
project = raw ? normalizeProject(raw) : undefined;
|
|
67
66
|
users = usersResult.users ?? [];
|
|
68
67
|
versions = versionsResult.versions ?? [];
|
|
69
|
-
categories = (raw?.categories ?? [])
|
|
70
|
-
...c,
|
|
71
|
-
name: c.name.startsWith(ALL_PROJECTS_PREFIX) ? c.name.slice(ALL_PROJECTS_PREFIX.length) : c.name,
|
|
72
|
-
}));
|
|
68
|
+
categories = stripAllProjectsPrefix(raw?.categories ?? []);
|
|
73
69
|
}
|
|
74
70
|
if (!project) {
|
|
75
71
|
throw new Error(`Project ${numId} not found`);
|
package/dist/search/embedder.js
CHANGED
|
@@ -6,18 +6,49 @@
|
|
|
6
6
|
// @huggingface/transformers is an optional dependency. The import is
|
|
7
7
|
// guarded with a try/catch so the server starts without it.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
|
+
import { DEFAULT_SEARCH_MODEL } from '../config.js';
|
|
10
|
+
// Sequence length a model was trained on (max_seq_length in its
|
|
11
|
+
// sentence_bert_config.json). transformers.js does not read that file and
|
|
12
|
+
// truncates at the tokenizer's model_max_length instead — 512 for the default
|
|
13
|
+
// model, which is trained on 128. Longer input is out of distribution: the
|
|
14
|
+
// vectors of long issues collapse into one another (#8539). Models not listed
|
|
15
|
+
// keep the tokenizer's limit.
|
|
16
|
+
const MODEL_MAX_TOKENS = {
|
|
17
|
+
[DEFAULT_SEARCH_MODEL]: 128,
|
|
18
|
+
};
|
|
19
|
+
// Vectors per issue for models with a known token limit: the first covers
|
|
20
|
+
// summary and the start of the description, each further one the next part of
|
|
21
|
+
// the description, prefixed with the summary (#8541). 8 chunks reach about
|
|
22
|
+
// 1,000 description tokens and grow the index by a factor of about 1.7.
|
|
23
|
+
const MAX_CHUNKS = 8;
|
|
24
|
+
// Special tokens the tokenizer adds ([CLS], [SEP]) plus the newline.
|
|
25
|
+
const CHUNK_OVERHEAD_TOKENS = 3;
|
|
26
|
+
const MIN_CHUNK_TOKENS = 32;
|
|
27
|
+
// Characters per token kept before tokenizing: tokens average about 4
|
|
28
|
+
// characters, so this keeps well beyond MAX_CHUNKS windows without tokenizing
|
|
29
|
+
// descriptions of 17,000+ tokens in full.
|
|
30
|
+
const MAX_CHARS_PER_TOKEN = 16;
|
|
31
|
+
// Texts per model call; caps the activation memory of a batch.
|
|
32
|
+
const MODEL_BATCH_SIZE = 32;
|
|
9
33
|
// A loaded model takes ~0.9 GB RAM, and every client session runs its own
|
|
10
34
|
// server process. An idle process releases the model; the next use reloads it.
|
|
11
35
|
const IDLE_UNLOAD_MS = 10 * 60 * 1000;
|
|
12
36
|
export class Embedder {
|
|
13
37
|
modelName;
|
|
14
38
|
numThreads;
|
|
39
|
+
/** Identifies how vectors are made; a different signature means the index must be rebuilt. */
|
|
40
|
+
signature;
|
|
15
41
|
pipe = null;
|
|
42
|
+
maxTokens;
|
|
16
43
|
running = 0;
|
|
17
44
|
idleTimer = null;
|
|
18
45
|
constructor(modelName, numThreads = 1) {
|
|
19
46
|
this.modelName = modelName;
|
|
20
47
|
this.numThreads = numThreads;
|
|
48
|
+
this.maxTokens = MODEL_MAX_TOKENS[modelName];
|
|
49
|
+
this.signature = this.maxTokens === undefined
|
|
50
|
+
? `${modelName}|max_tokens=default`
|
|
51
|
+
: `${modelName}|max_tokens=${this.maxTokens}|chunks=${MAX_CHUNKS}`;
|
|
21
52
|
}
|
|
22
53
|
async withPipeline(fn) {
|
|
23
54
|
this.running++;
|
|
@@ -54,56 +85,74 @@ export class Embedder {
|
|
|
54
85
|
process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName} (threads: ${this.numThreads})...\n`);
|
|
55
86
|
let transformers;
|
|
56
87
|
try {
|
|
57
|
-
|
|
58
|
-
transformers = (await import('@huggingface/transformers'));
|
|
88
|
+
transformers = await import('@huggingface/transformers');
|
|
59
89
|
}
|
|
60
90
|
catch (err) {
|
|
61
91
|
const msg = err instanceof Error ? err.message : String(err);
|
|
62
92
|
throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
|
|
63
93
|
}
|
|
94
|
+
// onnxruntime takes camelCase keys and silently ignores unknown ones
|
|
95
|
+
// (snake_case had no effect until #8540).
|
|
64
96
|
return transformers.pipeline('feature-extraction', this.modelName, {
|
|
65
97
|
session_options: {
|
|
66
|
-
|
|
67
|
-
|
|
98
|
+
intraOpNumThreads: this.numThreads,
|
|
99
|
+
interOpNumThreads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
|
|
68
100
|
},
|
|
69
101
|
});
|
|
70
102
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return
|
|
103
|
+
/** One or more vectors per document; see MAX_CHUNKS. */
|
|
104
|
+
async embedDocuments(docs) {
|
|
105
|
+
if (docs.length === 0)
|
|
106
|
+
return [];
|
|
107
|
+
return this.withPipeline(async ({ tokenizer }) => {
|
|
108
|
+
const texts = docs.map(doc => this.chunkTexts(doc, tokenizer));
|
|
109
|
+
const vectors = await this.embedBatch(texts.flat());
|
|
110
|
+
let start = 0;
|
|
111
|
+
return texts.map(chunks => {
|
|
112
|
+
const own = vectors.slice(start, start + chunks.length);
|
|
113
|
+
start += chunks.length;
|
|
114
|
+
return own;
|
|
115
|
+
});
|
|
75
116
|
});
|
|
76
117
|
}
|
|
118
|
+
chunkTexts({ summary, description: fullDescription }, tokenizer) {
|
|
119
|
+
const description = this.maxTokens === undefined
|
|
120
|
+
? fullDescription
|
|
121
|
+
: fullDescription?.slice(0, MAX_CHUNKS * this.maxTokens * MAX_CHARS_PER_TOKEN);
|
|
122
|
+
const texts = [`${summary}\n${description ?? ''}`.trim()];
|
|
123
|
+
if (this.maxTokens === undefined || !description)
|
|
124
|
+
return texts;
|
|
125
|
+
const encode = (text) => tokenizer.encode(text, { add_special_tokens: false });
|
|
126
|
+
const window = Math.max(MIN_CHUNK_TOKENS, this.maxTokens - CHUNK_OVERHEAD_TOKENS - encode(summary).length);
|
|
127
|
+
const tokens = encode(description);
|
|
128
|
+
// The first text already covers about the first window of the description.
|
|
129
|
+
for (let start = window; start < tokens.length && texts.length < MAX_CHUNKS; start += window) {
|
|
130
|
+
const part = tokenizer.decode(tokens.slice(start, start + window), { skip_special_tokens: true });
|
|
131
|
+
texts.push(`${summary}\n${part}`);
|
|
132
|
+
}
|
|
133
|
+
return texts;
|
|
134
|
+
}
|
|
135
|
+
async embed(text) {
|
|
136
|
+
return (await this.embedBatch([text]))[0];
|
|
137
|
+
}
|
|
138
|
+
// Same steps as the feature-extraction pipeline with pooling 'mean' and
|
|
139
|
+
// normalize — but the pipeline offers no way to pass max_length.
|
|
77
140
|
async embedBatch(texts) {
|
|
78
141
|
if (texts.length === 0)
|
|
79
142
|
return [];
|
|
80
|
-
return this.withPipeline(async (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
143
|
+
return this.withPipeline(async ({ tokenizer, model }) => {
|
|
144
|
+
// Already loaded by load(), so this resolves from the module cache.
|
|
145
|
+
const { mean_pooling } = await import('@huggingface/transformers');
|
|
146
|
+
const vectors = [];
|
|
147
|
+
for (let i = 0; i < texts.length; i += MODEL_BATCH_SIZE) {
|
|
148
|
+
const inputs = tokenizer(texts.slice(i, i + MODEL_BATCH_SIZE), { padding: true, truncation: true, max_length: this.maxTokens });
|
|
149
|
+
const outputs = (await model(inputs));
|
|
150
|
+
const tokenVectors = outputs['last_hidden_state'] ?? outputs['logits'] ?? outputs['token_embeddings'];
|
|
151
|
+
if (!tokenVectors)
|
|
152
|
+
throw new Error('Embedding model returned no token vectors');
|
|
153
|
+
vectors.push(...mean_pooling(tokenVectors, inputs.attention_mask).normalize(2, -1).tolist());
|
|
84
154
|
}
|
|
85
|
-
|
|
86
|
-
return extractBatchVectors(output, texts.length);
|
|
155
|
+
return vectors;
|
|
87
156
|
});
|
|
88
157
|
}
|
|
89
158
|
}
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// Helpers
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
function extractVector(output) {
|
|
94
|
-
const data = output.data;
|
|
95
|
-
return Array.from(data);
|
|
96
|
-
}
|
|
97
|
-
function extractBatchVectors(output, batchSize) {
|
|
98
|
-
const data = Array.from(output.data);
|
|
99
|
-
// dims is typically [batchSize, sequenceLen, hiddenSize] or [batchSize, hiddenSize]
|
|
100
|
-
const vecSize = data.length / batchSize;
|
|
101
|
-
if (!Number.isInteger(vecSize)) {
|
|
102
|
-
throw new Error(`Unexpected batch output shape: ${data.length} elements for batch size ${batchSize}`);
|
|
103
|
-
}
|
|
104
|
-
const result = [];
|
|
105
|
-
for (let i = 0; i < batchSize; i++) {
|
|
106
|
-
result.push(data.slice(i * vecSize, (i + 1) * vecSize));
|
|
107
|
-
}
|
|
108
|
-
return result;
|
|
109
|
-
}
|
package/dist/search/index.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { createVectorStore } from './store.js';
|
|
2
2
|
import { Embedder } from './embedder.js';
|
|
3
|
-
import { registerSearchTools } from './tools.js';
|
|
3
|
+
import { registerSearchTools, getSearchIndexStatus } from './tools.js';
|
|
4
4
|
import { SearchSyncService, SyncLockedError } from './sync.js';
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// initializeSearchModule
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
export async function initializeSearchModule(server, client, config) {
|
|
9
|
-
if (!config.enabled)
|
|
10
|
-
return;
|
|
11
9
|
const store = createVectorStore(config.backend, config.dir);
|
|
12
10
|
const embedder = new Embedder(config.modelName, config.numThreads);
|
|
13
11
|
registerSearchTools(server, client, store, embedder, config.dir);
|
|
14
|
-
// Pre-initialize lastKnownTotal so
|
|
12
|
+
// Pre-initialize lastKnownTotal so the index status (get_metadata) shows a value
|
|
15
13
|
// immediately on startup, even while the background sync is still running.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
// count() loads the whole index into memory, and this runs before the server
|
|
15
|
+
// connects — so only do it when there is no stored total yet.
|
|
16
|
+
if (await store.getLastKnownTotal() === null) {
|
|
17
|
+
const startupCount = await store.count();
|
|
18
|
+
if (startupCount > 0)
|
|
19
|
+
await store.setLastKnownTotal(startupCount);
|
|
19
20
|
}
|
|
20
21
|
// Non-blocking background sync on startup. If another server process (another
|
|
21
22
|
// client session) is already syncing the shared index, this one skips it.
|
|
@@ -27,4 +28,5 @@ export async function initializeSearchModule(server, client, config) {
|
|
|
27
28
|
}
|
|
28
29
|
console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
|
|
29
30
|
});
|
|
31
|
+
return () => getSearchIndexStatus(store);
|
|
30
32
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Keyword index (BM25) for hybrid search (#8542)
|
|
3
|
+
//
|
|
4
|
+
// Embeddings capture meaning but blur exact terms: identifiers, error codes,
|
|
5
|
+
// version numbers. search_issues therefore fuses the vector ranking with this
|
|
6
|
+
// keyword ranking. Built in memory from the summaries and descriptions the
|
|
7
|
+
// vector index already stores — no extra API calls.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Standard BM25 parameters.
|
|
10
|
+
const K1 = 1.2;
|
|
11
|
+
const B = 0.75;
|
|
12
|
+
// A summary term counts twice: titles name what an issue is about.
|
|
13
|
+
const SUMMARY_WEIGHT = 2;
|
|
14
|
+
/** Lowercased words of letters, digits and underscores, at least 2 characters. */
|
|
15
|
+
export function tokenize(text) {
|
|
16
|
+
return (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter(word => word.length >= 2);
|
|
17
|
+
}
|
|
18
|
+
export class LexicalIndex {
|
|
19
|
+
// Postings in compressed sparse rows: the documents of term t are
|
|
20
|
+
// docs[offsets[t] .. offsets[t + 1]], with their term frequencies in tfs.
|
|
21
|
+
// Typed arrays keep 340,000 postings (7,700 issues) at about 7 MB instead of
|
|
22
|
+
// 30 MB as one object per posting — in every server process.
|
|
23
|
+
termIds = new Map();
|
|
24
|
+
offsets;
|
|
25
|
+
docs;
|
|
26
|
+
tfs;
|
|
27
|
+
ids;
|
|
28
|
+
lengths;
|
|
29
|
+
avgLength;
|
|
30
|
+
constructor(documents) {
|
|
31
|
+
this.ids = Uint32Array.from(documents, doc => doc.id);
|
|
32
|
+
this.lengths = new Uint32Array(documents.length);
|
|
33
|
+
const counts = documents.map((doc, index) => {
|
|
34
|
+
const terms = new Map();
|
|
35
|
+
const add = (text, weight) => {
|
|
36
|
+
for (const term of tokenize(text ?? '')) {
|
|
37
|
+
let id = this.termIds.get(term);
|
|
38
|
+
if (id === undefined)
|
|
39
|
+
this.termIds.set(term, (id = this.termIds.size));
|
|
40
|
+
terms.set(id, (terms.get(id) ?? 0) + weight);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
add(doc.summary, SUMMARY_WEIGHT);
|
|
44
|
+
add(doc.description, 1);
|
|
45
|
+
for (const tf of terms.values())
|
|
46
|
+
this.lengths[index] += tf;
|
|
47
|
+
return terms;
|
|
48
|
+
});
|
|
49
|
+
const df = new Uint32Array(this.termIds.size);
|
|
50
|
+
for (const terms of counts)
|
|
51
|
+
for (const term of terms.keys())
|
|
52
|
+
df[term]++;
|
|
53
|
+
this.offsets = new Uint32Array(df.length + 1);
|
|
54
|
+
for (let t = 0; t < df.length; t++)
|
|
55
|
+
this.offsets[t + 1] = this.offsets[t] + df[t];
|
|
56
|
+
this.docs = new Uint32Array(this.offsets[df.length]);
|
|
57
|
+
this.tfs = new Uint16Array(this.docs.length);
|
|
58
|
+
const cursor = this.offsets.slice(0, df.length);
|
|
59
|
+
counts.forEach((terms, doc) => {
|
|
60
|
+
for (const [term, tf] of terms) {
|
|
61
|
+
const at = cursor[term]++;
|
|
62
|
+
this.docs[at] = doc;
|
|
63
|
+
this.tfs[at] = Math.min(tf, 0xffff);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.avgLength = this.lengths.reduce((sum, length) => sum + length, 0) / (documents.length || 1);
|
|
67
|
+
}
|
|
68
|
+
/** BM25 score per document id; documents without a query term are absent. */
|
|
69
|
+
scores(query) {
|
|
70
|
+
const byDoc = new Map();
|
|
71
|
+
const total = this.ids.length;
|
|
72
|
+
for (const word of new Set(tokenize(query))) {
|
|
73
|
+
const term = this.termIds.get(word);
|
|
74
|
+
if (term === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
const start = this.offsets[term];
|
|
77
|
+
const end = this.offsets[term + 1];
|
|
78
|
+
const idf = Math.log(1 + (total - (end - start) + 0.5) / (end - start + 0.5));
|
|
79
|
+
for (let at = start; at < end; at++) {
|
|
80
|
+
const doc = this.docs[at];
|
|
81
|
+
const tf = this.tfs[at];
|
|
82
|
+
const norm = K1 * (1 - B + B * this.lengths[doc] / this.avgLength);
|
|
83
|
+
byDoc.set(doc, (byDoc.get(doc) ?? 0) + idf * tf * (K1 + 1) / (tf + norm));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return new Map([...byDoc].map(([doc, score]) => [this.ids[doc], score]));
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/search/store.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { LexicalIndex } from './lexical.js';
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// VectraStore
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
7
|
+
// Reciprocal Rank Fusion constant. Measured on 7,739 issues (#8542): 10 keeps
|
|
8
|
+
// literal queries (titles, sentences, identifiers) at 97–100 % recall@10 and
|
|
9
|
+
// paraphrased ones level with vector-only search; the common 60 lost up to
|
|
10
|
+
// 8 points on literal queries.
|
|
11
|
+
const RRF_K = 10;
|
|
6
12
|
export class VectraStore {
|
|
7
13
|
dir;
|
|
8
14
|
vectraDir;
|
|
9
15
|
lastSyncFile;
|
|
10
16
|
lastTotalFile;
|
|
17
|
+
signatureFile;
|
|
11
18
|
items = new Map();
|
|
12
19
|
loaded = false;
|
|
13
20
|
// Version of index.json the in-memory items correspond to (null: no file).
|
|
@@ -16,11 +23,15 @@ export class VectraStore {
|
|
|
16
23
|
loadedVersion = null;
|
|
17
24
|
// Unflushed addBatch() changes — never discard them by reloading.
|
|
18
25
|
dirty = false;
|
|
26
|
+
// Keyword index over the current items, built on the first search with
|
|
27
|
+
// query text and dropped whenever the items change.
|
|
28
|
+
lexical = null;
|
|
19
29
|
constructor(dir) {
|
|
20
30
|
this.dir = dir;
|
|
21
31
|
this.vectraDir = join(dir, 'vectra');
|
|
22
32
|
this.lastSyncFile = join(dir, 'last_sync.txt');
|
|
23
33
|
this.lastTotalFile = join(dir, 'last_total.txt');
|
|
34
|
+
this.signatureFile = join(dir, 'embedding_signature.txt');
|
|
24
35
|
}
|
|
25
36
|
get indexFile() {
|
|
26
37
|
return join(this.vectraDir, 'index.json');
|
|
@@ -50,6 +61,7 @@ export class VectraStore {
|
|
|
50
61
|
// No index yet — start empty
|
|
51
62
|
this.items = new Map();
|
|
52
63
|
}
|
|
64
|
+
this.lexical = null;
|
|
53
65
|
this.loaded = true;
|
|
54
66
|
this.loadedVersion = version;
|
|
55
67
|
}
|
|
@@ -64,6 +76,7 @@ export class VectraStore {
|
|
|
64
76
|
async add(item) {
|
|
65
77
|
await this.ensureLoaded();
|
|
66
78
|
this.items.set(item.id, item);
|
|
79
|
+
this.lexical = null;
|
|
67
80
|
await this.persist();
|
|
68
81
|
}
|
|
69
82
|
async addBatch(items) {
|
|
@@ -71,21 +84,35 @@ export class VectraStore {
|
|
|
71
84
|
for (const item of items) {
|
|
72
85
|
this.items.set(item.id, item);
|
|
73
86
|
}
|
|
87
|
+
this.lexical = null;
|
|
74
88
|
this.dirty = items.length > 0 || this.dirty;
|
|
75
89
|
// No persist() here — call flush() once after all batches are processed.
|
|
76
90
|
}
|
|
77
91
|
async flush() {
|
|
78
92
|
await this.persist();
|
|
79
93
|
}
|
|
80
|
-
async search(vector, topN) {
|
|
94
|
+
async search(vector, topN, queryText) {
|
|
81
95
|
await this.ensureLoaded();
|
|
82
96
|
const results = [];
|
|
83
97
|
for (const item of this.items.values()) {
|
|
84
|
-
|
|
98
|
+
let score = cosineSimilarity(vector, item.vector);
|
|
99
|
+
if (item.chunks)
|
|
100
|
+
for (const chunk of item.chunks)
|
|
101
|
+
score = Math.max(score, cosineSimilarity(vector, chunk));
|
|
85
102
|
results.push({ id: item.id, score });
|
|
86
103
|
}
|
|
87
104
|
results.sort((a, b) => b.score - a.score);
|
|
88
|
-
|
|
105
|
+
if (!queryText)
|
|
106
|
+
return results.slice(0, topN);
|
|
107
|
+
// Reciprocal Rank Fusion of the vector ranking and the keyword ranking.
|
|
108
|
+
this.lexical ??= new LexicalIndex([...this.items.values()].map(item => ({ id: item.id, ...item.metadata })));
|
|
109
|
+
const keywordRank = new Map([...this.lexical.scores(queryText)].sort((a, b) => b[1] - a[1]).map(([id], rank) => [id, rank]));
|
|
110
|
+
const fused = results.map((result, vectorRank) => {
|
|
111
|
+
const rank = keywordRank.get(result.id);
|
|
112
|
+
return { result, fused: 1 / (RRF_K + vectorRank) + (rank === undefined ? 0 : 1 / (RRF_K + rank)) };
|
|
113
|
+
});
|
|
114
|
+
fused.sort((a, b) => b.fused - a.fused);
|
|
115
|
+
return fused.slice(0, topN).map(f => f.result);
|
|
89
116
|
}
|
|
90
117
|
async getItem(id) {
|
|
91
118
|
await this.ensureLoaded();
|
|
@@ -94,6 +121,7 @@ export class VectraStore {
|
|
|
94
121
|
async delete(id) {
|
|
95
122
|
await this.ensureLoaded();
|
|
96
123
|
this.items.delete(id);
|
|
124
|
+
this.lexical = null;
|
|
97
125
|
await this.persist();
|
|
98
126
|
}
|
|
99
127
|
async count() {
|
|
@@ -103,6 +131,7 @@ export class VectraStore {
|
|
|
103
131
|
async clear() {
|
|
104
132
|
await this.ensureLoaded();
|
|
105
133
|
this.items.clear();
|
|
134
|
+
this.lexical = null;
|
|
106
135
|
await this.persist();
|
|
107
136
|
}
|
|
108
137
|
async getLastSyncedAt() {
|
|
@@ -141,6 +170,18 @@ export class VectraStore {
|
|
|
141
170
|
await mkdir(this.dir, { recursive: true });
|
|
142
171
|
await writeFile(this.lastTotalFile, String(total), 'utf-8');
|
|
143
172
|
}
|
|
173
|
+
async getEmbeddingSignature() {
|
|
174
|
+
try {
|
|
175
|
+
return (await readFile(this.signatureFile, 'utf-8')).trim() || null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async setEmbeddingSignature(signature) {
|
|
182
|
+
await mkdir(this.dir, { recursive: true });
|
|
183
|
+
await writeFile(this.signatureFile, signature, 'utf-8');
|
|
184
|
+
}
|
|
144
185
|
}
|
|
145
186
|
// ---------------------------------------------------------------------------
|
|
146
187
|
// Cosine similarity helper
|