feat: replace WASM embeddings with persistent chroma-mcp MCP connection (#1176)

* feat: replace WASM embeddings with persistent chroma-mcp MCP connection

Replace ChromaServerManager (npx chroma run + chromadb npm + ONNX/WASM)
with ChromaMcpManager, a singleton stdio MCP client that communicates with
chroma-mcp via uvx. This eliminates native binary issues, segfaults, and
WASM embedding failures that plagued cross-platform installs.

Key changes:
- Add ChromaMcpManager: singleton MCP client with lazy connect, auto-reconnect,
  connection lock, and Zscaler SSL cert support
- Rewrite ChromaSync to use MCP tool calls instead of chromadb npm client
- Handle chroma-mcp's non-JSON responses (plain text success/error messages)
- Treat "collection already exists" as idempotent success
- Wire ChromaMcpManager into GracefulShutdown for clean subprocess teardown
- Delete ChromaServerManager (no longer needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review — connection guard leak, timer leak, async reset

- Clear connecting guard in finally block to prevent permanent reconnection block
- Clear timeout after successful connection to prevent timer leak
- Make reset() async to await stop() before nullifying instance
- Delete obsolete chroma-server-manager test (imports deleted class)
- Update graceful-shutdown test to use chromaMcpManager property name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent chroma-mcp spawn storm — zombie cleanup, stale onclose guard, reconnect backoff

Three bugs caused chroma-mcp processes to accumulate (92+ observed):

1. Zombie on timeout: failed connections left subprocess alive because
   only the timer was cleared, not the transport. Now catch block
   explicitly closes transport+client before rethrowing.

2. Stale onclose race: old transport's onclose handler captured `this`
   and overwrote the current connection reference after reconnect,
   orphaning the new subprocess. Now guarded with reference check.

3. No backoff: every failure triggered immediate reconnect. With
   backfill doing hundreds of MCP calls, this created rapid-fire
   spawning. Added 10s backoff on both connection failure and
   unexpected process death.

Also includes ChromaSync fixes from PR review:
- queryChroma deduplication now preserves index-aligned arrays
- SQL injection guard on backfill ID exclusion lists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-18 18:32:38 -05:00
committed by GitHub
parent 7e57b6e02d
commit 40daf8f3fa
13 changed files with 833 additions and 1189 deletions
@@ -30,9 +30,9 @@ export interface CloseableDatabase {
}
/**
* Stoppable service interface for Chroma server
* Stoppable service interface for ChromaMcpManager
*/
export interface StoppableServer {
export interface StoppableService {
stop(): Promise<void>;
}
@@ -44,7 +44,7 @@ export interface GracefulShutdownConfig {
sessionManager: ShutdownableService;
mcpClient?: CloseableClient;
dbManager?: CloseableDatabase;
chromaServer?: StoppableServer;
chromaMcpManager?: StoppableService;
}
/**
@@ -79,11 +79,11 @@ export async function performGracefulShutdown(config: GracefulShutdownConfig): P
logger.info('SYSTEM', 'MCP client closed');
}
// STEP 5: Stop Chroma server (local mode only)
if (config.chromaServer) {
logger.info('SHUTDOWN', 'Stopping Chroma server...');
await config.chromaServer.stop();
logger.info('SHUTDOWN', 'Chroma server stopped');
// STEP 5: Stop Chroma MCP connection
if (config.chromaMcpManager) {
logger.info('SHUTDOWN', 'Stopping Chroma MCP connection...');
await config.chromaMcpManager.stop();
logger.info('SHUTDOWN', 'Chroma MCP connection stopped');
}
// STEP 6: Close database connection (includes ChromaSync cleanup)
+422
View File
@@ -0,0 +1,422 @@
/**
* ChromaMcpManager - Singleton managing a persistent MCP connection to chroma-mcp via uvx
*
* Replaces ChromaServerManager (which spawned `npx chroma run`) with a stdio-based
* MCP client that communicates with chroma-mcp as a subprocess. The chroma-mcp server
* handles its own embedding and persistent storage, eliminating the need for a separate
* HTTP server, chromadb npm package, and ONNX/WASM embedding dependencies.
*
* Lifecycle: lazy-connects on first callTool() use, maintains a single persistent
* connection per worker lifetime, and auto-reconnects if the subprocess dies.
*
* Cross-platform: Linux, macOS, Windows
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { execSync } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
const CHROMA_MCP_CLIENT_NAME = 'claude-mem-chroma';
const CHROMA_MCP_CLIENT_VERSION = '1.0.0';
const MCP_CONNECTION_TIMEOUT_MS = 30_000;
const RECONNECT_BACKOFF_MS = 10_000; // Don't retry connections faster than this after failure
const DEFAULT_CHROMA_DATA_DIR = path.join(os.homedir(), '.claude-mem', 'chroma');
export class ChromaMcpManager {
private static instance: ChromaMcpManager | null = null;
private client: Client | null = null;
private transport: StdioClientTransport | null = null;
private connected: boolean = false;
private lastConnectionFailureTimestamp: number = 0;
private connecting: Promise<void> | null = null;
private constructor() {}
/**
* Get or create the singleton instance
*/
static getInstance(): ChromaMcpManager {
if (!ChromaMcpManager.instance) {
ChromaMcpManager.instance = new ChromaMcpManager();
}
return ChromaMcpManager.instance;
}
/**
* Ensure the MCP client is connected to chroma-mcp.
* Uses a connection lock to prevent concurrent connection attempts.
* If the subprocess has died since the last use, reconnects transparently.
*/
private async ensureConnected(): Promise<void> {
if (this.connected && this.client) {
return;
}
// Backoff: don't retry connections too fast after a failure
const timeSinceLastFailure = Date.now() - this.lastConnectionFailureTimestamp;
if (this.lastConnectionFailureTimestamp > 0 && timeSinceLastFailure < RECONNECT_BACKOFF_MS) {
throw new Error(`chroma-mcp connection in backoff (${Math.ceil((RECONNECT_BACKOFF_MS - timeSinceLastFailure) / 1000)}s remaining)`);
}
// If another caller is already connecting, wait for that attempt
if (this.connecting) {
await this.connecting;
return;
}
this.connecting = this.connectInternal();
try {
await this.connecting;
} catch (error) {
this.lastConnectionFailureTimestamp = Date.now();
throw error;
} finally {
this.connecting = null;
}
}
/**
* Internal connection logic - spawns uvx chroma-mcp and performs MCP handshake.
* Called behind the connection lock to ensure only one connection attempt at a time.
*/
private async connectInternal(): Promise<void> {
// Clean up any stale client/transport from a dead subprocess.
// Close transport first (kills subprocess via SIGTERM) before client
// to avoid hanging on a stuck process.
if (this.transport) {
try { await this.transport.close(); } catch { /* already dead */ }
}
if (this.client) {
try { await this.client.close(); } catch { /* already dead */ }
}
this.client = null;
this.transport = null;
this.connected = false;
const commandArgs = this.buildCommandArgs();
const spawnEnvironment = this.getSpawnEnv();
const isWindows = process.platform === 'win32';
const uvxCommand = isWindows ? 'uvx.cmd' : 'uvx';
logger.info('CHROMA_MCP', 'Connecting to chroma-mcp via MCP stdio', {
command: uvxCommand,
args: commandArgs.join(' ')
});
this.transport = new StdioClientTransport({
command: uvxCommand,
args: commandArgs,
env: spawnEnvironment,
stderr: 'pipe'
});
this.client = new Client(
{ name: CHROMA_MCP_CLIENT_NAME, version: CHROMA_MCP_CLIENT_VERSION },
{ capabilities: {} }
);
const mcpConnectionPromise = this.client.connect(this.transport);
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`MCP connection to chroma-mcp timed out after ${MCP_CONNECTION_TIMEOUT_MS}ms`)),
MCP_CONNECTION_TIMEOUT_MS
);
});
try {
await Promise.race([mcpConnectionPromise, timeoutPromise]);
} catch (connectionError) {
// Connection failed or timed out - kill the subprocess to prevent zombies
clearTimeout(timeoutId!);
logger.warn('CHROMA_MCP', 'Connection failed, killing subprocess to prevent zombie', {
error: connectionError instanceof Error ? connectionError.message : String(connectionError)
});
try { await this.transport.close(); } catch { /* best effort */ }
try { await this.client.close(); } catch { /* best effort */ }
this.client = null;
this.transport = null;
this.connected = false;
throw connectionError;
}
clearTimeout(timeoutId!);
this.connected = true;
logger.info('CHROMA_MCP', 'Connected to chroma-mcp successfully');
// Listen for transport close to mark connection as dead and apply backoff.
// CRITICAL: Guard with reference check to prevent stale onclose handlers from
// previous transports overwriting the current connection (race condition).
const currentTransport = this.transport;
this.transport.onclose = () => {
if (this.transport !== currentTransport) {
logger.debug('CHROMA_MCP', 'Ignoring stale onclose from previous transport');
return;
}
logger.warn('CHROMA_MCP', 'chroma-mcp subprocess closed unexpectedly, applying reconnect backoff');
this.connected = false;
this.client = null;
this.transport = null;
this.lastConnectionFailureTimestamp = Date.now();
};
}
/**
* Build the uvx command arguments based on current settings.
* In local mode: uses persistent client with local data directory.
* In remote mode: uses http client with configured host/port/auth.
*/
private buildCommandArgs(): string[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const chromaMode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
if (chromaMode === 'remote') {
const chromaHost = settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1';
const chromaPort = settings.CLAUDE_MEM_CHROMA_PORT || '8000';
const chromaSsl = settings.CLAUDE_MEM_CHROMA_SSL === 'true';
const chromaTenant = settings.CLAUDE_MEM_CHROMA_TENANT || 'default_tenant';
const chromaDatabase = settings.CLAUDE_MEM_CHROMA_DATABASE || 'default_database';
const chromaApiKey = settings.CLAUDE_MEM_CHROMA_API_KEY || '';
const args = [
'chroma-mcp',
'--client-type', 'http',
'--host', chromaHost,
'--port', chromaPort
];
if (chromaSsl) {
args.push('--ssl');
}
if (chromaTenant !== 'default_tenant') {
args.push('--tenant', chromaTenant);
}
if (chromaDatabase !== 'default_database') {
args.push('--database', chromaDatabase);
}
if (chromaApiKey) {
args.push('--api-key', chromaApiKey);
}
return args;
}
// Local mode: persistent client with data directory
return [
'chroma-mcp',
'--client-type', 'persistent',
'--data-dir', DEFAULT_CHROMA_DATA_DIR
];
}
/**
* Call a chroma-mcp tool by name with the given arguments.
* Lazily connects on first call. Reconnects if the subprocess has died.
*
* @param toolName - The chroma-mcp tool name (e.g. 'chroma_query_documents')
* @param toolArguments - The tool arguments as a plain object
* @returns The parsed JSON result from the tool's text output
*/
async callTool(toolName: string, toolArguments: Record<string, unknown>): Promise<unknown> {
await this.ensureConnected();
logger.debug('CHROMA_MCP', `Calling tool: ${toolName}`, {
arguments: JSON.stringify(toolArguments).slice(0, 200)
});
const result = await this.client!.callTool({
name: toolName,
arguments: toolArguments
});
// MCP tools signal errors via isError flag on the CallToolResult
if (result.isError) {
const errorText = (result.content as Array<{ type: string; text?: string }>)
?.find(item => item.type === 'text')?.text || 'Unknown chroma-mcp error';
throw new Error(`chroma-mcp tool "${toolName}" returned error: ${errorText}`);
}
// Extract text from MCP CallToolResult: { content: Array<{ type, text? }> }
const contentArray = result.content as Array<{ type: string; text?: string }>;
if (!contentArray || contentArray.length === 0) {
return null;
}
const firstTextContent = contentArray.find(item => item.type === 'text' && item.text);
if (!firstTextContent || !firstTextContent.text) {
return null;
}
// chroma-mcp returns JSON for query/get results, but plain text for
// mutating operations (e.g. "Successfully created collection ...").
// Try JSON parse first; if it fails, return the raw text for non-error responses.
try {
return JSON.parse(firstTextContent.text);
} catch {
// Plain text response (e.g. "Successfully created collection cm__foo")
// Return null for void-like success messages, callers don't need the text
return null;
}
}
/**
* Check if the MCP connection is alive by calling chroma_list_collections.
* Returns true if the connection is healthy, false otherwise.
*/
async isHealthy(): Promise<boolean> {
try {
await this.callTool('chroma_list_collections', { limit: 1 });
return true;
} catch {
return false;
}
}
/**
* Gracefully stop the MCP connection and kill the chroma-mcp subprocess.
* client.close() sends stdin close -> SIGTERM -> SIGKILL to the subprocess.
*/
async stop(): Promise<void> {
if (!this.client) {
logger.debug('CHROMA_MCP', 'No active MCP connection to stop');
return;
}
logger.info('CHROMA_MCP', 'Stopping chroma-mcp MCP connection');
try {
await this.client.close();
} catch (error) {
logger.debug('CHROMA_MCP', 'Error during client close (subprocess may already be dead)', {}, error as Error);
}
this.client = null;
this.transport = null;
this.connected = false;
this.connecting = null;
logger.info('CHROMA_MCP', 'chroma-mcp MCP connection stopped');
}
/**
* Reset the singleton instance (for testing).
* Awaits stop() to prevent dual subprocesses.
*/
static async reset(): Promise<void> {
if (ChromaMcpManager.instance) {
await ChromaMcpManager.instance.stop();
}
ChromaMcpManager.instance = null;
}
/**
* Get or create a combined SSL certificate bundle for Zscaler/corporate proxy environments.
* On macOS, combines the Python certifi CA bundle with any Zscaler certificates from
* the system keychain. Caches the result for 24 hours at ~/.claude-mem/combined_certs.pem.
*
* Returns the path to the combined cert file, or undefined if not needed/available.
*/
private getCombinedCertPath(): string | undefined {
const combinedCertPath = path.join(os.homedir(), '.claude-mem', 'combined_certs.pem');
if (fs.existsSync(combinedCertPath)) {
const stats = fs.statSync(combinedCertPath);
const ageMs = Date.now() - stats.mtimeMs;
if (ageMs < 24 * 60 * 60 * 1000) {
return combinedCertPath;
}
}
if (process.platform !== 'darwin') {
return undefined;
}
try {
let certifiPath: string | undefined;
try {
certifiPath = execSync(
'uvx --with certifi python -c "import certifi; print(certifi.where())"',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }
).trim();
} catch {
return undefined;
}
if (!certifiPath || !fs.existsSync(certifiPath)) {
return undefined;
}
let zscalerCert = '';
try {
zscalerCert = execSync(
'security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
);
} catch {
return undefined;
}
if (!zscalerCert ||
!zscalerCert.includes('-----BEGIN CERTIFICATE-----') ||
!zscalerCert.includes('-----END CERTIFICATE-----')) {
return undefined;
}
const certifiContent = fs.readFileSync(certifiPath, 'utf8');
const tempPath = combinedCertPath + '.tmp';
fs.writeFileSync(tempPath, certifiContent + '\n' + zscalerCert);
fs.renameSync(tempPath, combinedCertPath);
logger.info('CHROMA_MCP', 'Created combined SSL certificate bundle for Zscaler', {
path: combinedCertPath
});
return combinedCertPath;
} catch (error) {
logger.debug('CHROMA_MCP', 'Could not create combined cert bundle', {}, error as Error);
return undefined;
}
}
/**
* Build subprocess environment with SSL certificate overrides for enterprise proxy compatibility.
* If a combined cert bundle exists (Zscaler), injects SSL_CERT_FILE, REQUESTS_CA_BUNDLE, etc.
* Otherwise returns a plain string-keyed copy of process.env.
*/
private getSpawnEnv(): Record<string, string> {
const baseEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
baseEnv[key] = value;
}
}
const combinedCertPath = this.getCombinedCertPath();
if (!combinedCertPath) {
return baseEnv;
}
logger.info('CHROMA_MCP', 'Using combined SSL certificates for enterprise compatibility', {
certPath: combinedCertPath
});
return {
...baseEnv,
SSL_CERT_FILE: combinedCertPath,
REQUESTS_CA_BUNDLE: combinedCertPath,
CURL_CA_BUNDLE: combinedCertPath,
NODE_EXTRA_CA_CERTS: combinedCertPath
};
}
}
-446
View File
@@ -1,446 +0,0 @@
/**
* ChromaServerManager - Singleton managing local Chroma HTTP server lifecycle
*
* Starts a persistent Chroma server via `npx chroma run` at worker startup
* and manages its lifecycle. In 'remote' mode, skips server start and connects
* to an existing server (future cloud support).
*
* Cross-platform: Linux, macOS, Windows
*/
import { spawn, ChildProcess, execSync } from 'child_process';
import path from 'path';
import os from 'os';
import fs, { existsSync } from 'fs';
import { logger } from '../../utils/logger.js';
export interface ChromaServerConfig {
dataDir: string;
host: string;
port: number;
}
export class ChromaServerManager {
private static instance: ChromaServerManager | null = null;
private serverProcess: ChildProcess | null = null;
private config: ChromaServerConfig;
private starting: boolean = false;
private ready: boolean = false;
private startPromise: Promise<boolean> | null = null;
private constructor(config: ChromaServerConfig) {
this.config = config;
}
/**
* Get or create the singleton instance
*/
static getInstance(config?: ChromaServerConfig): ChromaServerManager {
if (!ChromaServerManager.instance) {
const defaultConfig: ChromaServerConfig = {
dataDir: path.join(os.homedir(), '.claude-mem', 'vector-db'),
host: '127.0.0.1',
port: 8000
};
ChromaServerManager.instance = new ChromaServerManager(config || defaultConfig);
}
return ChromaServerManager.instance;
}
/**
* Start the Chroma HTTP server
* Reuses in-flight startup if already starting
* Spawns `npx chroma run` as a background process
* If a server is already running (from previous worker), reuses it
*/
async start(timeoutMs: number = 60000): Promise<boolean> {
if (this.ready) {
logger.debug('CHROMA_SERVER', 'Server already started or starting', {
ready: this.ready,
starting: this.starting
});
return true;
}
if (this.startPromise) {
logger.debug('CHROMA_SERVER', 'Awaiting existing startup', {
host: this.config.host,
port: this.config.port
});
return this.startPromise;
}
this.starting = true;
this.startPromise = this.startInternal(timeoutMs);
try {
return await this.startPromise;
} finally {
this.startPromise = null;
if (!this.ready) {
this.starting = false;
}
}
}
/**
* Internal startup path used behind a single shared startPromise lock
*/
private async startInternal(timeoutMs: number): Promise<boolean> {
// Check if a server is already running (from previous worker or manual start)
try {
const response = await fetch(
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`,
{ signal: AbortSignal.timeout(3000) }
);
if (response.ok) {
logger.info('CHROMA_SERVER', 'Existing server detected, reusing', {
host: this.config.host,
port: this.config.port
});
this.ready = true;
this.starting = false;
return true;
}
} catch {
// No server running, proceed to start one
}
// Cross-platform: use npx.cmd on Windows
const isWindows = process.platform === 'win32';
// Resolve chroma binary absolutely — npx fails when spawned from cache dirs (#1120)
let command: string;
let args: string[];
try {
// chromadb package installs a 'chroma' bin entry
const chromaBinDir = path.dirname(require.resolve('chromadb/package.json'));
// Check project-level .bin first (most common npm/bun installation layout)
const projectBin = path.join(chromaBinDir, '..', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
// Fallback: nested node_modules .bin (rare — pnpm or workspace hoisting)
const nestedBin = path.join(chromaBinDir, 'node_modules', '.bin', isWindows ? 'chroma.cmd' : 'chroma');
if (existsSync(projectBin)) {
command = projectBin;
} else if (existsSync(nestedBin)) {
command = nestedBin;
} else {
// Last resort: npx with explicit cwd
command = isWindows ? 'npx.cmd' : 'npx';
}
} catch {
command = isWindows ? 'npx.cmd' : 'npx';
}
if (command.includes('npx')) {
args = ['chroma', 'run', '--path', this.config.dataDir, '--host', this.config.host, '--port', String(this.config.port)];
} else {
args = ['run', '--path', this.config.dataDir, '--host', this.config.host, '--port', String(this.config.port)];
}
logger.info('CHROMA_SERVER', 'Starting Chroma server', {
command,
args: args.join(' '),
dataDir: this.config.dataDir
});
const spawnEnv = this.getSpawnEnv();
// Resolve cwd for npx fallback — ensures node_modules is findable (#1120)
let spawnCwd: string | undefined;
try {
spawnCwd = path.dirname(require.resolve('chromadb/package.json'));
} catch {
// If chromadb isn't resolvable, omit cwd and let npx handle it
}
this.serverProcess = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: !isWindows, // Don't detach on Windows (no process groups)
windowsHide: true, // Hide console window on Windows
env: spawnEnv,
...(spawnCwd && { cwd: spawnCwd })
});
// Log server output for debugging
this.serverProcess.stdout?.on('data', (data) => {
const msg = data.toString().trim();
if (msg) {
logger.debug('CHROMA_SERVER', msg);
}
});
this.serverProcess.stderr?.on('data', (data) => {
const msg = data.toString().trim();
if (msg) {
// Filter out noisy startup messages
if (!msg.includes('Chroma') || msg.includes('error') || msg.includes('Error')) {
logger.debug('CHROMA_SERVER', msg);
}
}
});
this.serverProcess.on('error', (err) => {
logger.error('CHROMA_SERVER', 'Server process error', {}, err);
this.ready = false;
this.starting = false;
});
this.serverProcess.on('exit', (code, signal) => {
logger.info('CHROMA_SERVER', 'Server process exited', { code, signal });
this.ready = false;
this.starting = false;
this.serverProcess = null;
});
return this.waitForReady(timeoutMs);
}
/**
* Wait for the server to become ready
* Polls the heartbeat endpoint until success or timeout
*/
async waitForReady(timeoutMs: number = 60000): Promise<boolean> {
if (this.ready) {
return true;
}
const startTime = Date.now();
const checkInterval = 500;
logger.info('CHROMA_SERVER', 'Waiting for server to be ready', {
host: this.config.host,
port: this.config.port,
timeoutMs
});
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`
);
if (response.ok) {
this.ready = true;
this.starting = false;
logger.info('CHROMA_SERVER', 'Server ready', {
host: this.config.host,
port: this.config.port,
startupTimeMs: Date.now() - startTime
});
return true;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
this.starting = false;
logger.error('CHROMA_SERVER', 'Server failed to start within timeout', {
timeoutMs,
elapsedMs: Date.now() - startTime
});
return false;
}
/**
* Check if the server is running and ready
* Returns true if we manage the process OR if a server is responding
*/
isRunning(): boolean {
return this.ready;
}
/**
* Async check if server is running by pinging heartbeat
* Use this when you need to verify server is actually reachable
*/
async isServerReachable(): Promise<boolean> {
try {
const response = await fetch(
`http://${this.config.host}:${this.config.port}/api/v2/heartbeat`
);
if (response.ok) {
this.ready = true;
return true;
}
} catch {
// Server not reachable
}
return false;
}
/**
* Get the server URL for client connections
*/
getUrl(): string {
return `http://${this.config.host}:${this.config.port}`;
}
/**
* Get the server configuration
*/
getConfig(): ChromaServerConfig {
return { ...this.config };
}
/**
* Stop the Chroma server
* Gracefully terminates the server process
*/
async stop(): Promise<void> {
if (!this.serverProcess) {
logger.debug('CHROMA_SERVER', 'No server process to stop');
return;
}
logger.info('CHROMA_SERVER', 'Stopping server', { pid: this.serverProcess.pid });
return new Promise((resolve) => {
const proc = this.serverProcess!;
const pid = proc.pid;
const cleanup = () => {
this.serverProcess = null;
this.ready = false;
this.starting = false;
this.startPromise = null;
logger.info('CHROMA_SERVER', 'Server stopped', { pid });
resolve();
};
// Set up exit handler
proc.once('exit', cleanup);
// Cross-platform graceful shutdown
if (process.platform === 'win32') {
// Windows: just send SIGTERM
proc.kill('SIGTERM');
} else {
// Unix: kill the process group to ensure all children are killed
if (pid !== undefined) {
try {
process.kill(-pid, 'SIGTERM');
} catch (err) {
// Process group kill failed, try direct kill
proc.kill('SIGTERM');
}
} else {
proc.kill('SIGTERM');
}
}
// Force kill after timeout if still running
setTimeout(() => {
if (this.serverProcess) {
logger.warn('CHROMA_SERVER', 'Force killing server after timeout', { pid });
try {
proc.kill('SIGKILL');
} catch {
// Already dead
}
cleanup();
}
}, 5000);
});
}
/**
* Get or create combined SSL certificate bundle for Zscaler/corporate proxy environments.
* This ports previous MCP SSL handling so local `npx chroma run` works behind enterprise proxies.
*/
private getCombinedCertPath(): string | undefined {
const combinedCertPath = path.join(os.homedir(), '.claude-mem', 'combined_certs.pem');
if (fs.existsSync(combinedCertPath)) {
const stats = fs.statSync(combinedCertPath);
const ageMs = Date.now() - stats.mtimeMs;
if (ageMs < 24 * 60 * 60 * 1000) {
return combinedCertPath;
}
}
if (process.platform !== 'darwin') {
return undefined;
}
try {
let certifiPath: string | undefined;
try {
certifiPath = execSync(
'uvx --with certifi python -c "import certifi; print(certifi.where())"',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 }
).trim();
} catch {
return undefined;
}
if (!certifiPath || !fs.existsSync(certifiPath)) {
return undefined;
}
let zscalerCert = '';
try {
zscalerCert = execSync(
'security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain',
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
);
} catch {
return undefined;
}
if (!zscalerCert ||
!zscalerCert.includes('-----BEGIN CERTIFICATE-----') ||
!zscalerCert.includes('-----END CERTIFICATE-----')) {
return undefined;
}
const certifiContent = fs.readFileSync(certifiPath, 'utf8');
const tempPath = combinedCertPath + '.tmp';
fs.writeFileSync(tempPath, certifiContent + '\n' + zscalerCert);
fs.renameSync(tempPath, combinedCertPath);
logger.info('CHROMA_SERVER', 'Created combined SSL certificate bundle for Zscaler', {
path: combinedCertPath
});
return combinedCertPath;
} catch (error) {
logger.debug('CHROMA_SERVER', 'Could not create combined cert bundle', {}, error as Error);
return undefined;
}
}
/**
* Build subprocess env and preserve Zscaler compatibility from previous architecture.
*/
private getSpawnEnv(): NodeJS.ProcessEnv {
const combinedCertPath = this.getCombinedCertPath();
if (!combinedCertPath) {
return process.env;
}
logger.info('CHROMA_SERVER', 'Using combined SSL certificates for enterprise compatibility', {
certPath: combinedCertPath
});
return {
...process.env,
SSL_CERT_FILE: combinedCertPath,
REQUESTS_CA_BUNDLE: combinedCertPath,
CURL_CA_BUNDLE: combinedCertPath,
NODE_EXTRA_CA_CERTS: combinedCertPath
};
}
/**
* Reset the singleton instance (for testing)
*/
static reset(): void {
if (ChromaServerManager.instance) {
// Don't await - just trigger stop
ChromaServerManager.instance.stop().catch(() => {});
}
ChromaServerManager.instance = null;
}
}
+120 -245
View File
@@ -1,26 +1,21 @@
/**
* ChromaSync Service
*
* Automatically syncs observations and session summaries to ChromaDB via HTTP.
* Automatically syncs observations and session summaries to ChromaDB via MCP.
* This service provides real-time semantic search capabilities by maintaining
* a vector database synchronized with SQLite.
*
* Uses the chromadb npm package's built-in ChromaClient for HTTP connections.
* Supports both local server (managed by ChromaServerManager) and remote/cloud
* servers for future claude-mem pro features.
* Uses ChromaMcpManager to communicate with chroma-mcp over stdio MCP protocol.
* The chroma-mcp server handles its own embedding and persistent storage,
* eliminating the need for chromadb npm package and ONNX/WASM dependencies.
*
* Design: Fail-fast with no fallbacks - if Chroma is unavailable, syncing fails.
*/
import { ChromaClient, Collection } from 'chromadb';
import { ChromaMcpManager } from './ChromaMcpManager.js';
import { ParsedObservation, ParsedSummary } from '../../sdk/parser.js';
import { SessionStore } from '../sqlite/SessionStore.js';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { ChromaServerManager } from './ChromaServerManager.js';
import path from 'path';
import os from 'os';
interface ChromaDocument {
id: string;
@@ -75,13 +70,10 @@ interface StoredUserPrompt {
}
export class ChromaSync {
private chromaClient: ChromaClient | null = null;
private collection: Collection | null = null;
private project: string;
private collectionName: string;
private readonly VECTOR_DB_DIR: string;
private collectionCreated = false;
private readonly BATCH_SIZE = 100;
private modelCacheCorruptionRetried = false;
constructor(project: string) {
this.project = project;
@@ -91,146 +83,36 @@ export class ChromaSync {
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/[^a-zA-Z0-9]+$/, ''); // strip trailing non-alphanumeric
this.collectionName = `cm__${sanitized || 'unknown'}`;
this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db');
}
/**
* Ensure HTTP client is connected to Chroma server
* In local mode, verifies ChromaServerManager has started the server
* In remote mode, connects directly to configured host
* Throws error if connection fails
* Ensure collection exists in Chroma via MCP.
* chroma_create_collection is idempotent - safe to call multiple times.
* Uses collectionCreated flag to avoid redundant calls within a session.
*/
private async ensureConnection(): Promise<void> {
if (this.chromaClient) {
private async ensureCollectionExists(): Promise<void> {
if (this.collectionCreated) {
return;
}
logger.info('CHROMA_SYNC', 'Connecting to Chroma HTTP server...', { project: this.project });
const chromaMcp = ChromaMcpManager.getInstance();
try {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const mode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
const host = settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1';
const port = parseInt(settings.CLAUDE_MEM_CHROMA_PORT || '8000', 10);
const ssl = settings.CLAUDE_MEM_CHROMA_SSL === 'true';
// Multi-tenancy settings (used in remote/pro mode)
const tenant = settings.CLAUDE_MEM_CHROMA_TENANT || 'default_tenant';
const database = settings.CLAUDE_MEM_CHROMA_DATABASE || 'default_database';
const apiKey = settings.CLAUDE_MEM_CHROMA_API_KEY || '';
// In local mode, verify server is reachable
if (mode === 'local') {
const serverManager = ChromaServerManager.getInstance();
const reachable = await serverManager.isServerReachable();
if (!reachable) {
throw new Error('Chroma server not reachable. Ensure worker started correctly.');
}
}
// Create HTTP client
const protocol = ssl ? 'https' : 'http';
const chromaPath = `${protocol}://${host}:${port}`;
// Build client options
const clientOptions: { path: string; tenant?: string; database?: string; headers?: Record<string, string> } = {
path: chromaPath
};
// In remote mode, use tenant isolation for pro users
if (mode === 'remote') {
clientOptions.tenant = tenant;
clientOptions.database = database;
// Add API key header if configured
if (apiKey) {
clientOptions.headers = {
'Authorization': `Bearer ${apiKey}`
};
}
logger.info('CHROMA_SYNC', 'Connecting with tenant isolation', {
tenant,
database,
hasApiKey: !!apiKey
});
}
this.chromaClient = new ChromaClient(clientOptions);
// Verify connection with heartbeat
await this.chromaClient.heartbeat();
logger.info('CHROMA_SYNC', 'Connected to Chroma HTTP server', {
project: this.project,
host,
port,
ssl,
mode,
tenant: mode === 'remote' ? tenant : 'default_tenant'
await chromaMcp.callTool('chroma_create_collection', {
collection_name: this.collectionName
});
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma HTTP server', { project: this.project }, error as Error);
this.chromaClient = null;
throw new Error(`Chroma connection failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Ensure collection exists, create if needed
* Throws error if collection creation fails
*/
private async ensureCollection(): Promise<void> {
await this.ensureConnection();
if (this.collection) {
return;
}
if (!this.chromaClient) {
throw new Error(
'Chroma client not initialized. Call ensureConnection() before using client methods.' +
` Project: ${this.project}`
);
}
try {
// Store model cache outside node_modules so reinstalls don't corrupt it
const { env } = await import('@huggingface/transformers');
env.cacheDir = path.join(os.homedir(), '.claude-mem', 'models');
// Use WASM backend to avoid native ONNX binary issues (#1104, #1105, #1110).
// Same model (all-MiniLM-L6-v2), same embeddings, but runs in WASM —
// no native binary loading, no segfaults, no ENOENT errors.
const { DefaultEmbeddingFunction } = await import('@chroma-core/default-embed');
const embeddingFunction = new DefaultEmbeddingFunction({ wasm: true });
this.collection = await this.chromaClient.getOrCreateCollection({
name: this.collectionName,
embeddingFunction
});
logger.debug('CHROMA_SYNC', 'Collection ready', {
collection: this.collectionName
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Self-heal: corrupted model cache → clear and retry once
if (errorMessage.includes('Protobuf parsing failed') && !this.modelCacheCorruptionRetried) {
this.modelCacheCorruptionRetried = true;
logger.warn('CHROMA_SYNC', 'Corrupted model cache detected, clearing and retrying...');
const modelCacheDir = path.join(os.homedir(), '.claude-mem', 'models');
const fs = await import('fs');
if (fs.existsSync(modelCacheDir)) {
fs.rmSync(modelCacheDir, { recursive: true, force: true });
}
return this.ensureCollection(); // retry once
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('already exists')) {
throw error;
}
logger.error('CHROMA_SYNC', 'Failed to get/create collection', { collection: this.collectionName }, error as Error);
throw new Error(`Collection setup failed: ${errorMessage}`);
// Collection already exists - this is the expected path after first creation
}
this.collectionCreated = true;
logger.debug('CHROMA_SYNC', 'Collection ready', {
collection: this.collectionName
});
}
/**
@@ -369,7 +251,7 @@ export class ChromaSync {
}
/**
* Add documents to Chroma in batch
* Add documents to Chroma in batch via MCP
* Throws error if batch add fails
*/
private async addDocuments(documents: ChromaDocument[]): Promise<void> {
@@ -377,33 +259,26 @@ export class ChromaSync {
return;
}
await this.ensureCollection();
await this.ensureCollectionExists();
if (!this.collection) {
throw new Error(
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${this.project}`
);
const chromaMcp = ChromaMcpManager.getInstance();
// Add in batches
for (let i = 0; i < documents.length; i += this.BATCH_SIZE) {
const batch = documents.slice(i, i + this.BATCH_SIZE);
await chromaMcp.callTool('chroma_add_documents', {
collection_name: this.collectionName,
ids: batch.map(d => d.id),
documents: batch.map(d => d.document),
metadatas: batch.map(d => d.metadata)
});
}
try {
await this.collection.add({
ids: documents.map(d => d.id),
documents: documents.map(d => d.document),
metadatas: documents.map(d => d.metadata)
});
logger.debug('CHROMA_SYNC', 'Documents added', {
collection: this.collectionName,
count: documents.length
});
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to add documents', {
collection: this.collectionName,
count: documents.length
}, error as Error);
throw new Error(`Document add failed: ${error instanceof Error ? error.message : String(error)}`);
}
logger.debug('CHROMA_SYNC', 'Documents added', {
collection: this.collectionName,
count: documents.length
});
}
/**
@@ -545,7 +420,7 @@ export class ChromaSync {
}
/**
* Fetch all existing document IDs from Chroma collection
* Fetch all existing document IDs from Chroma collection via MCP
* Returns Sets of SQLite IDs for observations, summaries, and prompts
*/
private async getExistingChromaIds(projectOverride?: string): Promise<{
@@ -554,14 +429,9 @@ export class ChromaSync {
prompts: Set<number>;
}> {
const targetProject = projectOverride ?? this.project;
await this.ensureCollection();
await this.ensureCollectionExists();
if (!this.collection) {
throw new Error(
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${targetProject}`
);
}
const chromaMcp = ChromaMcpManager.getInstance();
const observationIds = new Set<number>();
const summaryIds = new Set<number>();
@@ -573,45 +443,42 @@ export class ChromaSync {
logger.info('CHROMA_SYNC', 'Fetching existing Chroma document IDs...', { project: targetProject });
while (true) {
try {
const result = await this.collection.get({
limit,
offset,
where: { project: targetProject },
include: ['metadatas']
});
const result = await chromaMcp.callTool('chroma_get_documents', {
collection_name: this.collectionName,
limit: limit,
offset: offset,
where: { project: targetProject },
include: ['metadatas']
}) as any;
const metadatas = result.metadatas || [];
// chroma_get_documents returns flat arrays: { ids, metadatas, documents }
const metadatas = result?.metadatas || [];
if (metadatas.length === 0) {
break; // No more documents
}
if (metadatas.length === 0) {
break; // No more documents
}
// Extract SQLite IDs from metadata
for (const meta of metadatas) {
if (meta && meta.sqlite_id) {
const sqliteId = meta.sqlite_id as number;
if (meta.doc_type === 'observation') {
observationIds.add(sqliteId);
} else if (meta.doc_type === 'session_summary') {
summaryIds.add(sqliteId);
} else if (meta.doc_type === 'user_prompt') {
promptIds.add(sqliteId);
}
// Extract SQLite IDs from metadata
for (const meta of metadatas) {
if (meta && meta.sqlite_id) {
const sqliteId = meta.sqlite_id as number;
if (meta.doc_type === 'observation') {
observationIds.add(sqliteId);
} else if (meta.doc_type === 'session_summary') {
summaryIds.add(sqliteId);
} else if (meta.doc_type === 'user_prompt') {
promptIds.add(sqliteId);
}
}
offset += limit;
logger.debug('CHROMA_SYNC', 'Fetched batch of existing IDs', {
project: targetProject,
offset,
batchSize: metadatas.length
});
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to fetch existing IDs', { project: targetProject }, error as Error);
throw error;
}
offset += limit;
logger.debug('CHROMA_SYNC', 'Fetched batch of existing IDs', {
project: targetProject,
offset,
batchSize: metadatas.length
});
}
logger.info('CHROMA_SYNC', 'Existing IDs fetched', {
@@ -635,7 +502,7 @@ export class ChromaSync {
const backfillProject = projectOverride ?? this.project;
logger.info('CHROMA_SYNC', 'Starting smart backfill', { project: backfillProject });
await this.ensureCollection();
await this.ensureCollectionExists();
// Fetch existing IDs from Chroma (fast, metadata only)
const existing = await this.getExistingChromaIds(backfillProject);
@@ -644,7 +511,8 @@ export class ChromaSync {
try {
// Build exclusion list for observations
const existingObsIds = Array.from(existing.observations);
// Filter to validated positive integers before interpolating into SQL
const existingObsIds = Array.from(existing.observations).filter(id => Number.isInteger(id) && id > 0);
const obsExclusionClause = existingObsIds.length > 0
? `AND id NOT IN (${existingObsIds.join(',')})`
: '';
@@ -685,7 +553,7 @@ export class ChromaSync {
}
// Build exclusion list for summaries
const existingSummaryIds = Array.from(existing.summaries);
const existingSummaryIds = Array.from(existing.summaries).filter(id => Number.isInteger(id) && id > 0);
const summaryExclusionClause = existingSummaryIds.length > 0
? `AND id NOT IN (${existingSummaryIds.join(',')})`
: '';
@@ -726,7 +594,7 @@ export class ChromaSync {
}
// Build exclusion list for prompts
const existingPromptIds = Array.from(existing.prompts);
const existingPromptIds = Array.from(existing.prompts).filter(id => Number.isInteger(id) && id > 0);
const promptExclusionClause = existingPromptIds.length > 0
? `AND up.id NOT IN (${existingPromptIds.join(',')})`
: '';
@@ -797,7 +665,7 @@ export class ChromaSync {
}
/**
* Query Chroma collection for semantic search
* Query Chroma collection for semantic search via MCP
* Used by SearchManager for vector-based search
*/
async queryChroma(
@@ -805,27 +673,34 @@ export class ChromaSync {
limit: number,
whereFilter?: Record<string, any>
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
await this.ensureCollection();
if (!this.collection) {
throw new Error(
'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${this.project}`
);
}
await this.ensureCollectionExists();
try {
const results = await this.collection.query({
queryTexts: [query],
nResults: limit,
where: whereFilter,
const chromaMcp = ChromaMcpManager.getInstance();
const results = await chromaMcp.callTool('chroma_query_documents', {
collection_name: this.collectionName,
query_texts: [query],
n_results: limit,
...(whereFilter && { where: whereFilter }),
include: ['documents', 'metadatas', 'distances']
});
}) as any;
// Extract unique SQLite IDs from document IDs
// chroma_query_documents returns nested arrays (one per query text)
// We always pass a single query text, so we access [0]
const ids: number[] = [];
const docIds = results.ids?.[0] || [];
for (const docId of docIds) {
const seen = new Set<number>();
const docIds = results?.ids?.[0] || [];
const rawMetadatas = results?.metadatas?.[0] || [];
const rawDistances = results?.distances?.[0] || [];
// Build deduplicated arrays that stay index-aligned:
// Multiple Chroma docs map to the same SQLite ID (one per field).
// Keep the first (best-ranked) distance and metadata per SQLite ID.
const metadatas: any[] = [];
const distances: number[] = [];
for (let i = 0; i < docIds.length; i++) {
const docId = docIds[i];
// Extract sqlite_id from document ID (supports three formats):
// - obs_{id}_narrative, obs_{id}_fact_0, etc (observations)
// - summary_{id}_request, summary_{id}_learned, etc (session summaries)
@@ -843,16 +718,15 @@ export class ChromaSync {
sqliteId = parseInt(promptMatch[1], 10);
}
if (sqliteId !== null && !ids.includes(sqliteId)) {
if (sqliteId !== null && !seen.has(sqliteId)) {
seen.add(sqliteId);
ids.push(sqliteId);
metadatas.push(rawMetadatas[i] ?? null);
distances.push(rawDistances[i] ?? 0);
}
}
return {
ids,
distances: results.distances?.[0] || [],
metadatas: results.metadatas?.[0] || []
};
return { ids, distances, metadatas };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -860,12 +734,13 @@ export class ChromaSync {
const isConnectionError =
errorMessage.includes('ECONNREFUSED') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('fetch failed');
errorMessage.includes('fetch failed') ||
errorMessage.includes('subprocess closed') ||
errorMessage.includes('timed out');
if (isConnectionError) {
// Reset connection state so next call attempts reconnect
this.chromaClient = null;
this.collection = null;
// Reset collection state so next call attempts reconnect
this.collectionCreated = false;
logger.error('CHROMA_SYNC', 'Connection lost during query',
{ project: this.project, query }, error as Error);
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
@@ -909,13 +784,13 @@ export class ChromaSync {
}
/**
* Close the Chroma client connection
* Server lifecycle is managed by ChromaServerManager, not here
* Close the ChromaSync instance
* ChromaMcpManager is a singleton and manages its own lifecycle
* We don't close it here - it's closed during graceful shutdown
*/
async close(): Promise<void> {
// Just clear references - server lifecycle managed by ChromaServerManager
this.chromaClient = null;
this.collection = null;
logger.info('CHROMA_SYNC', 'Chroma client closed', { project: this.project });
// ChromaMcpManager is a singleton and manages its own lifecycle
// We don't close it here - it's closed during graceful shutdown
logger.info('CHROMA_SYNC', 'ChromaSync closed', { project: this.project });
}
}
+8 -27
View File
@@ -18,7 +18,7 @@ import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import { getAuthMethodDescription } from '../shared/EnvManager.js';
import { logger } from '../utils/logger.js';
import { ChromaServerManager } from './sync/ChromaServerManager.js';
import { ChromaMcpManager } from './sync/ChromaMcpManager.js';
import { ChromaSync } from './sync/ChromaSync.js';
// Windows: avoid repeated spawn popups when startup fails (issue #921)
@@ -166,8 +166,8 @@ export class WorkerService {
// Route handlers
private searchRoutes: SearchRoutes | null = null;
// Chroma server (local mode)
private chromaServer: ChromaServerManager | null = null;
// Chroma MCP manager (lazy - connects on first use)
private chromaMcpManager: ChromaMcpManager | null = null;
// Initialization tracking
private initializationComplete: Promise<void>;
@@ -373,31 +373,12 @@ export class WorkerService {
const { ModeManager } = await import('./domain/ModeManager.js');
const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js');
const { USER_SETTINGS_PATH } = await import('../shared/paths.js');
const os = await import('os');
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
// Start Chroma server if in local mode
const chromaMode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
if (chromaMode === 'local') {
logger.info('SYSTEM', 'Starting local Chroma server...');
this.chromaServer = ChromaServerManager.getInstance({
dataDir: path.join(os.homedir(), '.claude-mem', 'vector-db'),
host: settings.CLAUDE_MEM_CHROMA_HOST || '127.0.0.1',
port: parseInt(settings.CLAUDE_MEM_CHROMA_PORT || '8000', 10)
});
const ready = await this.chromaServer.start(60000);
if (ready) {
logger.success('SYSTEM', 'Chroma server ready');
} else {
logger.warn('SYSTEM', 'Chroma server failed to start - vector search disabled');
this.chromaServer = null;
}
} else {
logger.info('SYSTEM', 'Chroma remote mode - skipping local server');
}
// Initialize ChromaMcpManager (lazy - connects on first use via ChromaSync)
this.chromaMcpManager = ChromaMcpManager.getInstance();
logger.info('SYSTEM', 'ChromaMcpManager initialized (lazy - connects on first use)');
const modeId = settings.CLAUDE_MEM_MODE;
ModeManager.getInstance().loadMode(modeId);
@@ -428,7 +409,7 @@ export class WorkerService {
logger.info('WORKER', 'SearchManager initialized and search routes registered');
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
if (this.chromaServer !== null || chromaMode !== 'local') {
if (this.chromaMcpManager) {
ChromaSync.backfillAllProjects().then(() => {
logger.info('CHROMA_SYNC', 'Backfill check complete for all projects');
}).catch(error => {
@@ -855,7 +836,7 @@ export class WorkerService {
sessionManager: this.sessionManager,
mcpClient: this.mcpClient,
dbManager: this.dbManager,
chromaServer: this.chromaServer || undefined
chromaMcpManager: this.chromaMcpManager || undefined
});
}
+1 -1
View File
@@ -37,7 +37,7 @@ export class DatabaseManager {
* Close database connection and cleanup all resources
*/
async close(): Promise<void> {
// Close ChromaSync first (terminates uvx/python processes)
// Close ChromaSync first (MCP connection lifecycle managed by ChromaMcpManager)
if (this.chromaSync) {
await this.chromaSync.close();
this.chromaSync = null;
+1 -1
View File
@@ -118,7 +118,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' starts npx chroma run, 'remote' connects to existing server
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
CLAUDE_MEM_CHROMA_PORT: '8000',
CLAUDE_MEM_CHROMA_SSL: 'false',
+1 -1
View File
@@ -15,7 +15,7 @@ export enum LogLevel {
SILENT = 4
}
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM' | 'HTTP' | 'SESSION' | 'CHROMA' | 'CHROMA_SYNC' | 'FOLDER_INDEX' | 'CLAUDE_MD' | 'QUEUE';
export type Component = 'HOOK' | 'WORKER' | 'SDK' | 'PARSER' | 'DB' | 'SYSTEM' | 'HTTP' | 'SESSION' | 'CHROMA' | 'CHROMA_MCP' | 'CHROMA_SYNC' | 'FOLDER_INDEX' | 'CLAUDE_MD' | 'QUEUE';
interface LogContext {
sessionId?: number;