@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/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 { registerIssueTools } from './tools/issues.js';
14
- import { registerNoteTools } from './tools/notes.js';
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
- registerIssueTools(server, client, cache);
59
- registerNoteTools(server, client);
60
- registerFileTools(server, client, startupConfig.uploadDir, transport);
61
- registerRelationshipTools(server, client);
62
- registerMonitorTools(server, client);
63
- registerProjectTools(server, client, cache);
64
- registerProjectVersionTools(server, client, cache);
65
- registerUserTools(server, client);
66
- registerFilterTools(server, client);
67
- registerConfigTools(server, client, cache);
68
- registerMetadataTools(server, client, cache);
69
- registerTagTools(server, client);
70
- registerVersionTools(server, client, versionHint, version);
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
+ }
@@ -0,0 +1,4 @@
1
+ /** The issue from a MantisBT issue response, whichever of the two envelopes it uses. */
2
+ export function firstIssue(response) {
3
+ return response?.issues?.[0] ?? response?.issue;
4
+ }
@@ -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 get_project_categories to list valid values)'),
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 get_issue_enums first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
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 get_project_categories to list valid values)'),
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 get_issue_enums first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
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
  // ---------------------------------------------------------------------------
@@ -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 the sync_metadata tool.',
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 the sync_metadata tool.',
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 ?? []).map((c) => ({
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`);
@@ -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
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
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
- intra_op_num_threads: this.numThreads,
67
- inter_op_num_threads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
98
+ intraOpNumThreads: this.numThreads,
99
+ interOpNumThreads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
68
100
  },
69
101
  });
70
102
  }
71
- async embed(text) {
72
- return this.withPipeline(async (extractor) => {
73
- const output = await extractor(text, { pooling: 'mean', normalize: true });
74
- return extractVector(output);
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 (extractor) => {
81
- const output = await extractor(texts, { pooling: 'mean', normalize: true });
82
- if (Array.isArray(output)) {
83
- return output.map(extractVector);
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
- // Some versions return a single tensor for batch input
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
- }
@@ -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 get_search_index_status shows a value
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
- const [startupCount, startupTotal] = await Promise.all([store.count(), store.getLastKnownTotal()]);
17
- if (startupTotal === null && startupCount > 0) {
18
- await store.setLastKnownTotal(startupCount);
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
+ }
@@ -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
- const score = cosineSimilarity(vector, item.vector);
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
- return results.slice(0, topN);
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