code-graph-context 2.8.0 → 2.10.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Embedding Sidecar Manager
3
+ * Manages a Python FastAPI process that serves local embedding requests.
4
+ * The sidecar loads the model once and keeps it warm between requests.
5
+ */
6
+ import { spawn } from 'child_process';
7
+ import { existsSync } from 'fs';
8
+ import { dirname, join } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const DEFAULT_CONFIG = {
13
+ port: parseInt(process.env.EMBEDDING_SIDECAR_PORT ?? '', 10) || 8787,
14
+ host: '127.0.0.1',
15
+ model: process.env.EMBEDDING_MODEL ?? 'Qodo/Qodo-Embed-1-1.5B',
16
+ startupTimeoutMs: 120_000, // 2 min — first run downloads the model
17
+ requestTimeoutMs: 60_000,
18
+ };
19
+ export class EmbeddingSidecar {
20
+ process = null;
21
+ readyPromise = null;
22
+ config;
23
+ _dimensions = null;
24
+ stopping = false;
25
+ constructor(config = {}) {
26
+ this.config = { ...DEFAULT_CONFIG, ...config };
27
+ }
28
+ get baseUrl() {
29
+ return `http://${this.config.host}:${this.config.port}`;
30
+ }
31
+ get dimensions() {
32
+ return this._dimensions;
33
+ }
34
+ get isRunning() {
35
+ return this.process !== null && !this.stopping;
36
+ }
37
+ /**
38
+ * Start the sidecar process. No-ops if already running.
39
+ * Resolves when the server is healthy and ready to serve requests.
40
+ */
41
+ async start() {
42
+ if (this.readyPromise)
43
+ return this.readyPromise;
44
+ this.stopping = false;
45
+ this.readyPromise = this.doStart();
46
+ try {
47
+ await this.readyPromise;
48
+ }
49
+ catch (err) {
50
+ // Clean up on failed start
51
+ this.cleanup();
52
+ throw err;
53
+ }
54
+ }
55
+ /**
56
+ * Resolve the python binary — prefer venv, fall back to system python3.
57
+ */
58
+ resolvePython(sidecarDir) {
59
+ const venvPython = join(sidecarDir, '.venv', 'bin', 'python3');
60
+ if (existsSync(venvPython))
61
+ return venvPython;
62
+ return 'python3';
63
+ }
64
+ async doStart() {
65
+ // Check if something is already listening on the port (e.g. previous run)
66
+ if (await this.checkHealth()) {
67
+ console.error(`[embedding-sidecar] Server already running on ${this.baseUrl}`);
68
+ return;
69
+ }
70
+ await this.verifyPython();
71
+ // sidecar/ lives at project root — go up from dist/core/embeddings/ or src/core/embeddings/
72
+ const sidecarDir = join(__dirname, '..', '..', '..', 'sidecar');
73
+ const python = this.resolvePython(sidecarDir);
74
+ console.error(`[embedding-sidecar] Starting on ${this.baseUrl} (python: ${python}, model: ${this.config.model})`);
75
+ this.process = spawn(python, ['-m', 'uvicorn', 'embedding_server:app', '--host', this.config.host, '--port', String(this.config.port)], {
76
+ cwd: sidecarDir,
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ env: {
79
+ ...process.env,
80
+ EMBEDDING_MODEL: this.config.model,
81
+ },
82
+ });
83
+ // Forward stderr for visibility (model loading progress, errors)
84
+ this.process.stderr?.on('data', (data) => {
85
+ const line = data.toString().trim();
86
+ if (line)
87
+ console.error(`[embedding-sidecar] ${line}`);
88
+ });
89
+ this.process.on('error', (err) => {
90
+ console.error(`[embedding-sidecar] Process error: ${err.message}`);
91
+ });
92
+ this.process.on('exit', (code, signal) => {
93
+ if (!this.stopping) {
94
+ console.error(`[embedding-sidecar] Process exited unexpectedly (code=${code}, signal=${signal})`);
95
+ }
96
+ this.cleanup();
97
+ });
98
+ // Poll until healthy
99
+ await this.waitForHealthy();
100
+ }
101
+ async verifyPython() {
102
+ return new Promise((resolve, reject) => {
103
+ const check = spawn('python3', ['--version'], { stdio: 'pipe' });
104
+ let output = '';
105
+ check.stdout?.on('data', (d) => (output += d.toString()));
106
+ check.stderr?.on('data', (d) => (output += d.toString()));
107
+ check.on('error', () => {
108
+ reject(new Error('python3 not found. Local embeddings require Python 3.10+.\n\n' +
109
+ 'Install Python and the sidecar dependencies:\n' +
110
+ ' pip install -r sidecar/requirements.txt\n\n' +
111
+ 'Or set OPENAI_ENABLED=true to use OpenAI instead.'));
112
+ });
113
+ check.on('close', (code) => {
114
+ if (code !== 0) {
115
+ reject(new Error(`python3 check failed: ${output}`));
116
+ }
117
+ else {
118
+ resolve();
119
+ }
120
+ });
121
+ });
122
+ }
123
+ async waitForHealthy() {
124
+ const start = Date.now();
125
+ const pollInterval = 1000;
126
+ while (Date.now() - start < this.config.startupTimeoutMs) {
127
+ if (this.stopping)
128
+ throw new Error('Sidecar stopped during startup');
129
+ if (this.process?.exitCode !== null && this.process?.exitCode !== undefined) {
130
+ throw new Error(`Sidecar process exited during startup with code ${this.process.exitCode}`);
131
+ }
132
+ if (await this.checkHealth()) {
133
+ console.error(`[embedding-sidecar] Ready (${Date.now() - start}ms)`);
134
+ return;
135
+ }
136
+ await new Promise((r) => setTimeout(r, pollInterval));
137
+ }
138
+ throw new Error(`Embedding sidecar failed to start within ${this.config.startupTimeoutMs}ms.\n` +
139
+ 'This usually means the model is still downloading or dependencies are missing.\n\n' +
140
+ 'Try running manually:\n' +
141
+ ' cd sidecar && python3 -m uvicorn embedding_server:app --host 127.0.0.1 --port 8787');
142
+ }
143
+ async checkHealth() {
144
+ try {
145
+ const controller = new AbortController();
146
+ const timeout = setTimeout(() => controller.abort(), 2000);
147
+ const res = await fetch(`${this.baseUrl}/health`, { signal: controller.signal });
148
+ clearTimeout(timeout);
149
+ if (res.ok) {
150
+ const data = (await res.json());
151
+ if (data.dimensions)
152
+ this._dimensions = data.dimensions;
153
+ return data.status === 'ok';
154
+ }
155
+ return false;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ /**
162
+ * Embed an array of texts. Lazily starts the sidecar if not running.
163
+ */
164
+ async embed(texts) {
165
+ await this.start();
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
168
+ try {
169
+ const res = await fetch(`${this.baseUrl}/embed`, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ texts }),
173
+ signal: controller.signal,
174
+ });
175
+ if (!res.ok) {
176
+ const detail = await res.text();
177
+ throw new Error(`Sidecar embed failed (${res.status}): ${detail}`);
178
+ }
179
+ const data = (await res.json());
180
+ if (data.dimensions)
181
+ this._dimensions = data.dimensions;
182
+ return data.embeddings;
183
+ }
184
+ catch (err) {
185
+ if (err instanceof Error && err.name === 'AbortError') {
186
+ throw new Error(`Embedding request timed out after ${this.config.requestTimeoutMs}ms`);
187
+ }
188
+ throw err;
189
+ }
190
+ finally {
191
+ clearTimeout(timeout);
192
+ }
193
+ }
194
+ /**
195
+ * Embed a single text. Convenience wrapper.
196
+ */
197
+ async embedText(text) {
198
+ const [embedding] = await this.embed([text]);
199
+ return embedding;
200
+ }
201
+ /**
202
+ * Stop the sidecar process.
203
+ */
204
+ async stop() {
205
+ this.stopping = true;
206
+ if (this.process) {
207
+ console.error('[embedding-sidecar] Stopping...');
208
+ this.process.kill('SIGTERM');
209
+ // Give it 5s to shut down gracefully, then force kill
210
+ await new Promise((resolve) => {
211
+ const forceKill = setTimeout(() => {
212
+ if (this.process) {
213
+ this.process.kill('SIGKILL');
214
+ }
215
+ resolve();
216
+ }, 5000);
217
+ this.process?.on('exit', () => {
218
+ clearTimeout(forceKill);
219
+ resolve();
220
+ });
221
+ });
222
+ }
223
+ this.cleanup();
224
+ }
225
+ cleanup() {
226
+ this.process = null;
227
+ this.readyPromise = null;
228
+ }
229
+ }
230
+ /**
231
+ * Singleton sidecar instance — shared across all tool calls.
232
+ * The sidecar starts lazily on first embed request and stays warm.
233
+ */
234
+ let sidecarInstance = null;
235
+ export const getEmbeddingSidecar = () => {
236
+ sidecarInstance ??= new EmbeddingSidecar();
237
+ return sidecarInstance;
238
+ };
239
+ export const stopEmbeddingSidecar = async () => {
240
+ if (sidecarInstance) {
241
+ await sidecarInstance.stop();
242
+ sidecarInstance = null;
243
+ }
244
+ };
@@ -1,146 +1,74 @@
1
- import OpenAI from 'openai';
2
- import { debugLog } from '../../mcp/utils.js';
3
- import { getTimeoutConfig } from '../config/timeouts.js';
4
1
  /**
5
- * Custom error class for OpenAI configuration issues
6
- * Provides helpful guidance on how to resolve the issue
2
+ * Embeddings Service barrel module
3
+ *
4
+ * Exports a common interface and a factory. Consumers do `new EmbeddingsService()`
5
+ * and get the right implementation based on OPENAI_ENABLED.
6
+ *
7
+ * OPENAI_ENABLED=true → OpenAI text-embedding-3-large (requires OPENAI_API_KEY)
8
+ * default → Local Python sidecar with Qodo-Embed-1-1.5B
7
9
  */
8
- export class OpenAIConfigError extends Error {
9
- constructor(message) {
10
- super(message);
11
- this.name = 'OpenAIConfigError';
12
- }
13
- }
10
+ import { LocalEmbeddingsService } from './local-embeddings.service.js';
11
+ import { OpenAIEmbeddingsService } from './openai-embeddings.service.js';
12
+ // Re-export error classes so existing imports keep working
13
+ export { OpenAIConfigError, OpenAIAPIError } from './openai-embeddings.service.js';
14
+ export const EMBEDDING_BATCH_CONFIG = {
15
+ maxBatchSize: 100,
16
+ delayBetweenBatchesMs: 500,
17
+ };
14
18
  /**
15
- * Custom error class for OpenAI API issues (rate limits, quota, etc.)
19
+ * Known dimensions per model.
20
+ * For unlisted models, dimensions are detected at runtime from the sidecar health endpoint.
16
21
  */
17
- export class OpenAIAPIError extends Error {
18
- statusCode;
19
- constructor(message, statusCode) {
20
- super(message);
21
- this.statusCode = statusCode;
22
- this.name = 'OpenAIAPIError';
22
+ export const EMBEDDING_DIMENSIONS = {
23
+ // OpenAI models
24
+ 'text-embedding-3-large': 3072,
25
+ 'text-embedding-3-small': 1536,
26
+ // Local models (via sidecar)
27
+ 'Qodo/Qodo-Embed-1-1.5B': 1536,
28
+ 'sentence-transformers/all-MiniLM-L6-v2': 384,
29
+ 'sentence-transformers/all-mpnet-base-v2': 768,
30
+ 'BAAI/bge-small-en-v1.5': 384,
31
+ 'BAAI/bge-base-en-v1.5': 768,
32
+ 'nomic-ai/nomic-embed-text-v1.5': 768,
33
+ };
34
+ export const isOpenAIEnabled = () => {
35
+ return process.env.OPENAI_ENABLED?.toLowerCase() === 'true';
36
+ };
37
+ /**
38
+ * Get the vector dimensions for the active embedding provider.
39
+ * For known models, returns a static value. For unknown local models,
40
+ * falls back to 1536 — the actual dimensions are verified at runtime
41
+ * when the sidecar starts and reports via /health.
42
+ */
43
+ export const getEmbeddingDimensions = () => {
44
+ if (isOpenAIEnabled()) {
45
+ const model = process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-large';
46
+ return EMBEDDING_DIMENSIONS[model] ?? 3072;
23
47
  }
24
- }
25
- export const EMBEDDING_BATCH_CONFIG = {
26
- maxBatchSize: 100, // OpenAI supports up to 2048, but 100 is efficient
27
- delayBetweenBatchesMs: 500, // Rate limit protection (500ms = ~2 batches/sec)
48
+ const model = process.env.EMBEDDING_MODEL ?? 'Qodo/Qodo-Embed-1-1.5B';
49
+ return EMBEDDING_DIMENSIONS[model] ?? 1536;
28
50
  };
51
+ /**
52
+ * Factory that returns the correct service based on OPENAI_ENABLED.
53
+ * Drop-in replacement everywhere `new EmbeddingsService()` was used.
54
+ */
29
55
  export class EmbeddingsService {
30
- openai;
31
- model;
32
- constructor(model = 'text-embedding-3-large') {
33
- const apiKey = process.env.OPENAI_API_KEY;
34
- if (!apiKey) {
35
- throw new OpenAIConfigError('OPENAI_API_KEY environment variable is required.\n\n' +
36
- 'To use semantic search features (search_codebase, natural_language_to_cypher), ' +
37
- 'you need an OpenAI API key.\n\n' +
38
- 'Set it in your environment:\n' +
39
- ' export OPENAI_API_KEY=sk-...\n\n' +
40
- 'Or in .env file:\n' +
41
- ' OPENAI_API_KEY=sk-...\n\n' +
42
- 'Alternative: Use impact_analysis or traverse_from_node which do not require OpenAI.');
43
- }
44
- const timeoutConfig = getTimeoutConfig();
45
- this.openai = new OpenAI({
46
- apiKey,
47
- timeout: timeoutConfig.openai.embeddingTimeoutMs,
48
- maxRetries: 2, // Built-in retry for transient errors
49
- });
50
- this.model = model;
51
- }
52
- /**
53
- * Embed a single text string
54
- */
55
- async embedText(text) {
56
- try {
57
- const response = await this.openai.embeddings.create({
58
- model: this.model,
59
- input: text,
60
- });
61
- return response.data[0].embedding;
56
+ impl;
57
+ constructor(model) {
58
+ if (isOpenAIEnabled()) {
59
+ this.impl = new OpenAIEmbeddingsService(model);
62
60
  }
63
- catch (error) {
64
- // Handle specific error types with helpful messages
65
- if (error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
66
- throw new OpenAIAPIError('OpenAI embedding request timed out. Consider increasing OPENAI_EMBEDDING_TIMEOUT_MS.');
67
- }
68
- if (error.status === 429) {
69
- throw new OpenAIAPIError('OpenAI rate limit exceeded.\n\n' +
70
- 'This usually means:\n' +
71
- '- You have hit your API rate limit\n' +
72
- '- You have exceeded your quota\n\n' +
73
- 'Solutions:\n' +
74
- '- Wait a few minutes and try again\n' +
75
- '- Check your OpenAI usage at https://platform.openai.com/usage\n' +
76
- '- Use impact_analysis or traverse_from_node which do not require OpenAI', 429);
77
- }
78
- if (error.status === 401) {
79
- throw new OpenAIAPIError('OpenAI API key is invalid or expired.\n\n' + 'Please check your OPENAI_API_KEY environment variable.', 401);
80
- }
81
- if (error.status === 402 || error.message?.includes('quota') || error.message?.includes('billing')) {
82
- throw new OpenAIAPIError('OpenAI quota exceeded or billing issue.\n\n' +
83
- 'Solutions:\n' +
84
- '- Check your OpenAI billing at https://platform.openai.com/settings/organization/billing\n' +
85
- '- Add credits to your account\n' +
86
- '- Use impact_analysis or traverse_from_node which do not require OpenAI', 402);
87
- }
88
- console.error('Error creating embedding:', error);
89
- throw error;
61
+ else {
62
+ this.impl = new LocalEmbeddingsService();
90
63
  }
91
64
  }
92
- /**
93
- * Embed multiple texts in a single API call.
94
- * OpenAI's embedding API supports batching natively.
95
- */
96
- async embedTexts(texts) {
97
- if (texts.length === 0)
98
- return [];
99
- try {
100
- const response = await this.openai.embeddings.create({
101
- model: this.model,
102
- input: texts,
103
- });
104
- // Map results back to original order (OpenAI returns with index)
105
- return response.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
106
- }
107
- catch (error) {
108
- if (error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
109
- throw new OpenAIAPIError('OpenAI batch embedding request timed out. Consider reducing batch size or increasing timeout.');
110
- }
111
- // Rate limited - SDK already has maxRetries:2, don't add recursive retry
112
- if (error.status === 429) {
113
- throw new OpenAIAPIError('OpenAI rate limit exceeded. Wait a few minutes and try again.\n' +
114
- 'Check your usage at https://platform.openai.com/usage', 429);
115
- }
116
- // Re-throw with context
117
- throw new OpenAIAPIError(`OpenAI embedding failed: ${error.message}`, error.status);
118
- }
65
+ embedText(text) {
66
+ return this.impl.embedText(text);
119
67
  }
120
- /**
121
- * Embed texts in batches with rate limiting.
122
- * Returns array of embeddings in same order as input.
123
- * @param texts Array of texts to embed
124
- * @param batchSize Number of texts per API call (default: 100)
125
- */
126
- async embedTextsInBatches(texts, batchSize = EMBEDDING_BATCH_CONFIG.maxBatchSize) {
127
- await debugLog('Batch embedding started', { textCount: texts.length });
128
- const results = [];
129
- const totalBatches = Math.ceil(texts.length / batchSize);
130
- for (let i = 0; i < texts.length; i += batchSize) {
131
- const batch = texts.slice(i, i + batchSize);
132
- const batchIndex = Math.floor(i / batchSize) + 1;
133
- await debugLog('Embedding batch progress', { batchIndex, totalBatches, batchSize: batch.length });
134
- const batchResults = await this.embedTexts(batch);
135
- results.push(...batchResults);
136
- // Rate limit protection between batches
137
- if (i + batchSize < texts.length) {
138
- await this.delay(EMBEDDING_BATCH_CONFIG.delayBetweenBatchesMs);
139
- }
140
- }
141
- return results;
68
+ embedTexts(texts) {
69
+ return this.impl.embedTexts(texts);
142
70
  }
143
- delay(ms) {
144
- return new Promise((resolve) => setTimeout(resolve, ms));
71
+ embedTextsInBatches(texts, batchSize) {
72
+ return this.impl.embedTextsInBatches(texts, batchSize);
145
73
  }
146
74
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Local Embeddings Service
3
+ * Uses a Python sidecar running Qodo-Embed-1-1.5B (or configurable model).
4
+ * Default provider — no API key required.
5
+ */
6
+ import { debugLog } from '../../mcp/utils.js';
7
+ import { getEmbeddingSidecar } from './embedding-sidecar.js';
8
+ const BATCH_CONFIG = {
9
+ maxBatchSize: 16, // Small batches — 1.5B model on MPS has limited VRAM
10
+ };
11
+ export class LocalEmbeddingsService {
12
+ async embedText(text) {
13
+ const sidecar = getEmbeddingSidecar();
14
+ return sidecar.embedText(text);
15
+ }
16
+ async embedTexts(texts) {
17
+ if (texts.length === 0)
18
+ return [];
19
+ const sidecar = getEmbeddingSidecar();
20
+ return sidecar.embed(texts);
21
+ }
22
+ async embedTextsInBatches(texts, batchSize = BATCH_CONFIG.maxBatchSize) {
23
+ await debugLog('Batch embedding started', { provider: 'local', textCount: texts.length });
24
+ const sidecar = getEmbeddingSidecar();
25
+ const results = [];
26
+ const totalBatches = Math.ceil(texts.length / batchSize);
27
+ for (let i = 0; i < texts.length; i += batchSize) {
28
+ const batch = texts.slice(i, i + batchSize);
29
+ const batchIndex = Math.floor(i / batchSize) + 1;
30
+ await debugLog('Embedding batch progress', {
31
+ provider: 'local',
32
+ batchIndex,
33
+ totalBatches,
34
+ batchSize: batch.length,
35
+ });
36
+ const batchResults = await sidecar.embed(batch);
37
+ results.push(...batchResults);
38
+ }
39
+ return results;
40
+ }
41
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * OpenAI Embeddings Service
3
+ * Uses OpenAI's text-embedding API. Requires OPENAI_API_KEY.
4
+ * Opt-in via OPENAI_ENABLED=true.
5
+ */
6
+ import OpenAI from 'openai';
7
+ import { debugLog } from '../../mcp/utils.js';
8
+ import { getTimeoutConfig } from '../config/timeouts.js';
9
+ export class OpenAIConfigError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'OpenAIConfigError';
13
+ }
14
+ }
15
+ export class OpenAIAPIError extends Error {
16
+ statusCode;
17
+ constructor(message, statusCode) {
18
+ super(message);
19
+ this.statusCode = statusCode;
20
+ this.name = 'OpenAIAPIError';
21
+ }
22
+ }
23
+ const BATCH_CONFIG = {
24
+ maxBatchSize: 100,
25
+ delayBetweenBatchesMs: 500,
26
+ };
27
+ export class OpenAIEmbeddingsService {
28
+ openai;
29
+ model;
30
+ constructor(model = 'text-embedding-3-large') {
31
+ const apiKey = process.env.OPENAI_API_KEY;
32
+ if (!apiKey) {
33
+ throw new OpenAIConfigError('OPENAI_API_KEY environment variable is required.\n\n' +
34
+ 'To use semantic search features (search_codebase, natural_language_to_cypher), ' +
35
+ 'you need an OpenAI API key.\n\n' +
36
+ 'Set it in your environment:\n' +
37
+ ' export OPENAI_API_KEY=sk-...\n\n' +
38
+ 'Or in .env file:\n' +
39
+ ' OPENAI_API_KEY=sk-...\n\n' +
40
+ 'Alternative: Use local embeddings (default) which require no API key.');
41
+ }
42
+ const timeoutConfig = getTimeoutConfig();
43
+ this.openai = new OpenAI({
44
+ apiKey,
45
+ timeout: timeoutConfig.openai.embeddingTimeoutMs,
46
+ maxRetries: 2,
47
+ });
48
+ this.model = model;
49
+ }
50
+ async embedText(text) {
51
+ try {
52
+ const response = await this.openai.embeddings.create({
53
+ model: this.model,
54
+ input: text,
55
+ });
56
+ return response.data[0].embedding;
57
+ }
58
+ catch (error) {
59
+ throw this.wrapError(error);
60
+ }
61
+ }
62
+ async embedTexts(texts) {
63
+ if (texts.length === 0)
64
+ return [];
65
+ try {
66
+ const response = await this.openai.embeddings.create({
67
+ model: this.model,
68
+ input: texts,
69
+ });
70
+ return response.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
71
+ }
72
+ catch (error) {
73
+ throw this.wrapError(error);
74
+ }
75
+ }
76
+ async embedTextsInBatches(texts, batchSize = BATCH_CONFIG.maxBatchSize) {
77
+ await debugLog('Batch embedding started', { provider: 'openai', textCount: texts.length });
78
+ const results = [];
79
+ const totalBatches = Math.ceil(texts.length / batchSize);
80
+ for (let i = 0; i < texts.length; i += batchSize) {
81
+ const batch = texts.slice(i, i + batchSize);
82
+ const batchIndex = Math.floor(i / batchSize) + 1;
83
+ await debugLog('Embedding batch progress', {
84
+ provider: 'openai',
85
+ batchIndex,
86
+ totalBatches,
87
+ batchSize: batch.length,
88
+ });
89
+ const batchResults = await this.embedTexts(batch);
90
+ results.push(...batchResults);
91
+ if (i + batchSize < texts.length) {
92
+ await new Promise((resolve) => setTimeout(resolve, BATCH_CONFIG.delayBetweenBatchesMs));
93
+ }
94
+ }
95
+ return results;
96
+ }
97
+ wrapError(error) {
98
+ if (error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
99
+ return new OpenAIAPIError('OpenAI embedding request timed out. Consider increasing OPENAI_EMBEDDING_TIMEOUT_MS.');
100
+ }
101
+ if (error.status === 429) {
102
+ return new OpenAIAPIError('OpenAI rate limit exceeded. Wait a few minutes and try again.\n' +
103
+ 'Check your usage at https://platform.openai.com/usage', 429);
104
+ }
105
+ if (error.status === 401) {
106
+ return new OpenAIAPIError('OpenAI API key is invalid or expired.\nPlease check your OPENAI_API_KEY.', 401);
107
+ }
108
+ if (error.status === 402 || error.message?.includes('quota') || error.message?.includes('billing')) {
109
+ return new OpenAIAPIError('OpenAI quota exceeded or billing issue.\n' +
110
+ 'Check billing at https://platform.openai.com/settings/organization/billing', 402);
111
+ }
112
+ return new OpenAIAPIError(`OpenAI embedding failed: ${error.message}`, error.status);
113
+ }
114
+ }
@@ -43,6 +43,7 @@ export const TOOL_NAMES = {
43
43
  saveSessionNote: 'save_session_note',
44
44
  recallSessionNotes: 'recall_session_notes',
45
45
  cleanupSession: 'cleanup_session',
46
+ swarmMessage: 'swarm_message',
46
47
  };
47
48
  // Tool Metadata
48
49
  export const TOOL_METADATA = {
@@ -221,7 +222,7 @@ Returns pheromones with current intensity after decay. minIntensity default: 0.3
221
222
  },
222
223
  [TOOL_NAMES.swarmCleanup]: {
223
224
  title: 'Swarm Cleanup',
224
- description: `Bulk delete pheromones. Specify swarmId, agentId, or all:true. Warning pheromones preserved by default (override with keepTypes:[]). Use dryRun:true to preview.`,
225
+ description: `Bulk delete pheromones, tasks, and messages. Specify swarmId, agentId, or all:true. Warning pheromones preserved by default (override with keepTypes:[]). Messages and tasks are deleted when swarmId is provided. Use dryRun:true to preview.`,
225
226
  },
226
227
  [TOOL_NAMES.swarmPostTask]: {
227
228
  title: 'Swarm Post Task',
@@ -343,6 +344,28 @@ Parameters:
343
344
 
344
345
  Returns counts of deleted notes, bookmarks, and edges.`,
345
346
  },
347
+ [TOOL_NAMES.swarmMessage]: {
348
+ title: 'Swarm Message',
349
+ description: `Direct agent-to-agent messaging via Neo4j. Unlike pheromones (passive/decay-based), messages are explicit and delivered when agents claim tasks.
350
+
351
+ **Actions:**
352
+ - send: Post a message to a specific agent or broadcast to all agents in a swarm
353
+ - read: Retrieve unread messages (or all messages) for an agent
354
+ - acknowledge: Mark messages as read
355
+
356
+ **Categories:** blocked, conflict, finding, request, alert, handoff
357
+
358
+ **Key behavior:**
359
+ - Messages sent to a specific agent are delivered when that agent calls swarm_claim_task
360
+ - Broadcast messages (toAgentId omitted) are visible to all agents in the swarm
361
+ - Messages auto-expire after 4 hours (configurable via ttlMs)
362
+ - Use this for critical coordination signals that agents MUST see, not optional context
363
+
364
+ **Examples:**
365
+ - Agent finds a breaking type error: send alert to all
366
+ - Agent is blocked on a file another agent owns: send blocked to that agent
367
+ - Agent discovers context another agent needs: send finding to that agent`,
368
+ },
346
369
  };
347
370
  // Default Values
348
371
  export const DEFAULTS = {
@@ -3,7 +3,7 @@
3
3
  * Handles importing parsed graph data into Neo4j with embeddings
4
4
  */
5
5
  import fs from 'fs/promises';
6
- import { EMBEDDING_BATCH_CONFIG } from '../../core/embeddings/embeddings.service.js';
6
+ import { EMBEDDING_BATCH_CONFIG, getEmbeddingDimensions } from '../../core/embeddings/embeddings.service.js';
7
7
  import { QUERIES } from '../../storage/neo4j/neo4j.service.js';
8
8
  import { DEFAULTS } from '../constants.js';
9
9
  import { debugLog } from '../utils.js';
@@ -181,10 +181,11 @@ export class GraphGeneratorHandler {
181
181
  }
182
182
  }
183
183
  async createVectorIndexes() {
184
- console.error('Creating vector indexes...');
185
- await this.neo4jService.run(QUERIES.CREATE_EMBEDDED_VECTOR_INDEX);
186
- await this.neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX);
187
- await debugLog('Vector indexes created');
184
+ const dims = getEmbeddingDimensions();
185
+ console.error(`Creating vector indexes (dimensions: ${dims})...`);
186
+ await this.neo4jService.run(QUERIES.CREATE_EMBEDDED_VECTOR_INDEX(dims));
187
+ await this.neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX(dims));
188
+ await debugLog('Vector indexes created', { dimensions: dims });
188
189
  }
189
190
  flattenProperties(properties) {
190
191
  const flattened = {};