feat: Switch to persistent Chroma HTTP server

Replace MCP subprocess approach with persistent Chroma HTTP server for
improved performance and reliability. This re-enables Chroma on Windows
by eliminating the subprocess spawning that caused console popups.

Changes:
- NEW: ChromaServerManager.ts - Manages local Chroma server lifecycle
  via `npx chroma run`
- REFACTOR: ChromaSync.ts - Uses chromadb npm package's ChromaClient
  instead of MCP subprocess (removes Windows disabling)
- UPDATE: worker-service.ts - Starts Chroma server on initialization
- UPDATE: GracefulShutdown.ts - Stops Chroma server on shutdown
- UPDATE: SettingsDefaultsManager.ts - New Chroma configuration options
- UPDATE: build-hooks.js - Mark optional chromadb deps as external

Benefits:
- Eliminates subprocess spawn latency on first query
- Single server process instead of per-operation subprocesses
- No Python/uvx dependency for local mode
- Re-enables Chroma vector search on Windows
- Future-ready for cloud-hosted Chroma (claude-mem pro)
- Cross-platform: Linux, macOS, Windows

Configuration:
  CLAUDE_MEM_CHROMA_MODE=local|remote
  CLAUDE_MEM_CHROMA_HOST=127.0.0.1
  CLAUDE_MEM_CHROMA_PORT=8000
  CLAUDE_MEM_CHROMA_SSL=false

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
bigphoot
2026-01-23 23:07:56 -08:00
committed by bigphoot
parent d3331d1e22
commit 5b3804ac08
10 changed files with 761 additions and 570 deletions
+1
View File
@@ -87,6 +87,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.76", "@anthropic-ai/claude-agent-sdk": "^0.1.76",
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"chromadb": "^1.9.2",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"express": "^4.18.2", "express": "^4.18.2",
"glob": "^11.0.3", "glob": "^11.0.3",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -92,7 +92,12 @@ async function buildHooks() {
outfile: `${hooksDir}/${WORKER_SERVICE.name}.cjs`, outfile: `${hooksDir}/${WORKER_SERVICE.name}.cjs`,
minify: true, minify: true,
logLevel: 'error', // Suppress warnings (import.meta warning is benign) logLevel: 'error', // Suppress warnings (import.meta warning is benign)
external: ['bun:sqlite'], external: [
'bun:sqlite',
// Optional chromadb embedding providers (not needed for HTTP client mode)
'cohere-ai',
'ollama'
],
define: { define: {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"` '__DEFAULT_PACKAGE_VERSION__': `"${version}"`
}, },
@@ -29,6 +29,13 @@ export interface CloseableDatabase {
close(): Promise<void>; close(): Promise<void>;
} }
/**
* Stoppable service interface for Chroma server
*/
export interface StoppableServer {
stop(): Promise<void>;
}
/** /**
* Configuration for graceful shutdown * Configuration for graceful shutdown
*/ */
@@ -37,6 +44,7 @@ export interface GracefulShutdownConfig {
sessionManager: ShutdownableService; sessionManager: ShutdownableService;
mcpClient?: CloseableClient; mcpClient?: CloseableClient;
dbManager?: CloseableDatabase; dbManager?: CloseableDatabase;
chromaServer?: StoppableServer;
} }
/** /**
@@ -76,7 +84,14 @@ export async function performGracefulShutdown(config: GracefulShutdownConfig): P
await config.dbManager.close(); await config.dbManager.close();
} }
// STEP 6: Force kill any remaining child processes (Windows zombie port fix) // STEP 6: 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 7: Force kill any remaining child processes (Windows zombie port fix)
if (childPids.length > 0) { if (childPids.length > 0) {
logger.info('SYSTEM', 'Force killing remaining children'); logger.info('SYSTEM', 'Force killing remaining children');
for (const pid of childPids) { for (const pid of childPids) {
+251
View File
@@ -0,0 +1,251 @@
/**
* 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 } from 'child_process';
import path from 'path';
import os from 'os';
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 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
* Spawns `npx chroma run` as a background process
*/
async start(): Promise<void> {
if (this.ready || this.starting) {
logger.debug('CHROMA_SERVER', 'Server already started or starting', {
ready: this.ready,
starting: this.starting
});
return;
}
this.starting = true;
// Cross-platform: use npx.cmd on Windows
const isWindows = process.platform === 'win32';
const command = isWindows ? 'npx.cmd' : 'npx';
const args = [
'chroma', '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
});
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
});
// 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;
});
}
/**
* Wait for the server to become ready
* Polls the heartbeat endpoint until success or timeout
*/
async waitForReady(timeoutMs: number = 60000): Promise<boolean> {
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/v1/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
*/
isRunning(): boolean {
return this.ready && this.serverProcess !== null;
}
/**
* 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;
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);
});
}
/**
* 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;
}
}
+107 -232
View File
@@ -1,27 +1,27 @@
/** /**
* ChromaSync Service * ChromaSync Service
* *
* Automatically syncs observations and session summaries to ChromaDB via MCP. * Automatically syncs observations and session summaries to ChromaDB via HTTP.
* This service provides real-time semantic search capabilities by maintaining * This service provides real-time semantic search capabilities by maintaining
* a vector database synchronized with SQLite. * 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.
*
* Design: Fail-fast with no fallbacks - if Chroma is unavailable, syncing fails. * Design: Fail-fast with no fallbacks - if Chroma is unavailable, syncing fails.
*/ */
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ChromaClient, Collection } from 'chromadb';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { ParsedObservation, ParsedSummary } from '../../sdk/parser.js'; import { ParsedObservation, ParsedSummary } from '../../sdk/parser.js';
import { SessionStore } from '../sqlite/SessionStore.js'; import { SessionStore } from '../sqlite/SessionStore.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { ChromaServerManager } from './ChromaServerManager.js';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
// Version injected at build time by esbuild define
declare const __DEFAULT_PACKAGE_VERSION__: string;
const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
interface ChromaDocument { interface ChromaDocument {
id: string; id: string;
document: string; document: string;
@@ -75,94 +75,66 @@ interface StoredUserPrompt {
} }
export class ChromaSync { export class ChromaSync {
private client: Client | null = null; private chromaClient: ChromaClient | null = null;
private transport: StdioClientTransport | null = null; private collection: Collection | null = null;
private connected: boolean = false;
private project: string; private project: string;
private collectionName: string; private collectionName: string;
private readonly VECTOR_DB_DIR: string; private readonly VECTOR_DB_DIR: string;
private readonly BATCH_SIZE = 100; private readonly BATCH_SIZE = 100;
// Windows: Chroma disabled due to MCP SDK spawning console popups
// See: https://github.com/anthropics/claude-mem/issues/675
// Will be re-enabled when we migrate to persistent HTTP server
private readonly disabled: boolean;
constructor(project: string) { constructor(project: string) {
this.project = project; this.project = project;
this.collectionName = `cm__${project}`; this.collectionName = `cm__${project}`;
this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db'); this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db');
// Disable on Windows to prevent console popups from MCP subprocess spawning
// The MCP SDK's StdioClientTransport spawns Python processes that create visible windows
this.disabled = process.platform === 'win32';
if (this.disabled) {
logger.warn('CHROMA_SYNC', 'Vector search disabled on Windows (prevents console popups)', {
project: this.project,
reason: 'MCP SDK subprocess spawning causes visible console windows'
});
}
} }
/** /**
* Check if Chroma is disabled (Windows) * Ensure HTTP client is connected to Chroma server
*/ * In local mode, verifies ChromaServerManager has started the server
isDisabled(): boolean { * In remote mode, connects directly to configured host
return this.disabled;
}
/**
* Ensure MCP client is connected to Chroma server
* Throws error if connection fails * Throws error if connection fails
*/ */
private async ensureConnection(): Promise<void> { private async ensureConnection(): Promise<void> {
if (this.connected && this.client) { if (this.chromaClient) {
return; return;
} }
logger.info('CHROMA_SYNC', 'Connecting to Chroma MCP server...', { project: this.project }); logger.info('CHROMA_SYNC', 'Connecting to Chroma HTTP server...', { project: this.project });
try { try {
// Use Python 3.13 by default to avoid onnxruntime compatibility issues with Python 3.14+
// See: https://github.com/thedotmack/claude-mem/issues/170 (Python 3.14 incompatibility)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const pythonVersion = settings.CLAUDE_MEM_PYTHON_VERSION; const mode = settings.CLAUDE_MEM_CHROMA_MODE || 'local';
const isWindows = process.platform === 'win32'; 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';
const transportOptions: any = { // In local mode, verify server is running
command: 'uvx', if (mode === 'local') {
args: [ const serverManager = ChromaServerManager.getInstance();
'--python', pythonVersion, if (!serverManager.isRunning()) {
'chroma-mcp', throw new Error('Chroma server not running. Ensure worker started correctly.');
'--client-type', 'persistent', }
'--data-dir', this.VECTOR_DB_DIR
],
stderr: 'ignore'
};
// CRITICAL: On Windows, try to hide console window to prevent PowerShell popups
// Note: windowsHide may not be supported by MCP SDK's StdioClientTransport
if (isWindows) {
transportOptions.windowsHide = true;
logger.debug('CHROMA_SYNC', 'Windows detected, attempting to hide console window', { project: this.project });
} }
this.transport = new StdioClientTransport(transportOptions); // Create HTTP client
const protocol = ssl ? 'https' : 'http';
const chromaPath = `${protocol}://${host}:${port}`;
// Empty capabilities object: this client only calls Chroma tools, doesn't expose any this.chromaClient = new ChromaClient({ path: chromaPath });
this.client = new Client({
name: 'claude-mem-chroma-sync', // Verify connection with heartbeat
version: packageVersion await this.chromaClient.heartbeat();
}, {
capabilities: {} logger.info('CHROMA_SYNC', 'Connected to Chroma HTTP server', {
project: this.project,
host,
port,
ssl,
mode
}); });
await this.client.connect(this.transport);
this.connected = true;
logger.info('CHROMA_SYNC', 'Connected to Chroma MCP server', { project: this.project });
} catch (error) { } catch (error) {
logger.error('CHROMA_SYNC', 'Failed to connect to Chroma MCP server', { project: this.project }, error as 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)}`); throw new Error(`Chroma connection failed: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
@@ -174,7 +146,11 @@ export class ChromaSync {
private async ensureCollection(): Promise<void> { private async ensureCollection(): Promise<void> {
await this.ensureConnection(); await this.ensureConnection();
if (!this.client) { if (this.collection) {
return;
}
if (!this.chromaClient) {
throw new Error( throw new Error(
'Chroma client not initialized. Call ensureConnection() before using client methods.' + 'Chroma client not initialized. Call ensureConnection() before using client methods.' +
` Project: ${this.project}` ` Project: ${this.project}`
@@ -182,50 +158,15 @@ export class ChromaSync {
} }
try { try {
// Try to get collection info (will fail if doesn't exist) // getOrCreateCollection handles both cases
await this.client.callTool({ this.collection = await this.chromaClient.getOrCreateCollection({
name: 'chroma_get_collection_info', name: this.collectionName
arguments: {
collection_name: this.collectionName
}
}); });
logger.debug('CHROMA_SYNC', 'Collection exists', { collection: this.collectionName }); logger.debug('CHROMA_SYNC', 'Collection ready', { collection: this.collectionName });
} catch (error) { } catch (error) {
// Check if this is a connection error - don't try to create collection logger.error('CHROMA_SYNC', 'Failed to get/create collection', { collection: this.collectionName }, error as Error);
const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Collection setup failed: ${error instanceof Error ? error.message : String(error)}`);
const isConnectionError =
errorMessage.includes('Not connected') ||
errorMessage.includes('Connection closed') ||
errorMessage.includes('MCP error -32000');
if (isConnectionError) {
// Reset connection state so next call attempts reconnect
this.connected = false;
this.client = null;
logger.error('CHROMA_SYNC', 'Connection lost during collection check',
{ collection: this.collectionName }, error as Error);
throw new Error(`Chroma connection lost: ${errorMessage}`);
}
// Only attempt creation if it's genuinely a "collection not found" error
logger.error('CHROMA_SYNC', 'Collection check failed, attempting to create', { collection: this.collectionName }, error as Error);
logger.info('CHROMA_SYNC', 'Creating collection', { collection: this.collectionName });
try {
await this.client.callTool({
name: 'chroma_create_collection',
arguments: {
collection_name: this.collectionName,
embedding_function_name: 'default'
}
});
logger.info('CHROMA_SYNC', 'Collection created', { collection: this.collectionName });
} catch (createError) {
logger.error('CHROMA_SYNC', 'Failed to create collection', { collection: this.collectionName }, createError as Error);
throw new Error(`Collection creation failed: ${createError instanceof Error ? createError.message : String(createError)}`);
}
} }
} }
@@ -375,22 +316,18 @@ export class ChromaSync {
await this.ensureCollection(); await this.ensureCollection();
if (!this.client) { if (!this.collection) {
throw new Error( throw new Error(
'Chroma client not initialized. Call ensureConnection() before using client methods.' + 'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${this.project}` ` Project: ${this.project}`
); );
} }
try { try {
await this.client.callTool({ await this.collection.add({
name: 'chroma_add_documents',
arguments: {
collection_name: this.collectionName,
documents: documents.map(d => d.document),
ids: documents.map(d => d.id), ids: documents.map(d => d.id),
documents: documents.map(d => d.document),
metadatas: documents.map(d => d.metadata) metadatas: documents.map(d => d.metadata)
}
}); });
logger.debug('CHROMA_SYNC', 'Documents added', { logger.debug('CHROMA_SYNC', 'Documents added', {
@@ -409,7 +346,6 @@ export class ChromaSync {
/** /**
* Sync a single observation to Chroma * Sync a single observation to Chroma
* Blocks until sync completes, throws on error * Blocks until sync completes, throws on error
* No-op on Windows (Chroma disabled to prevent console popups)
*/ */
async syncObservation( async syncObservation(
observationId: number, observationId: number,
@@ -420,8 +356,6 @@ export class ChromaSync {
createdAtEpoch: number, createdAtEpoch: number,
discoveryTokens: number = 0 discoveryTokens: number = 0
): Promise<void> { ): Promise<void> {
if (this.disabled) return;
// Convert ParsedObservation to StoredObservation format // Convert ParsedObservation to StoredObservation format
const stored: StoredObservation = { const stored: StoredObservation = {
id: observationId, id: observationId,
@@ -456,7 +390,6 @@ export class ChromaSync {
/** /**
* Sync a single summary to Chroma * Sync a single summary to Chroma
* Blocks until sync completes, throws on error * Blocks until sync completes, throws on error
* No-op on Windows (Chroma disabled to prevent console popups)
*/ */
async syncSummary( async syncSummary(
summaryId: number, summaryId: number,
@@ -467,8 +400,6 @@ export class ChromaSync {
createdAtEpoch: number, createdAtEpoch: number,
discoveryTokens: number = 0 discoveryTokens: number = 0
): Promise<void> { ): Promise<void> {
if (this.disabled) return;
// Convert ParsedSummary to StoredSummary format // Convert ParsedSummary to StoredSummary format
const stored: StoredSummary = { const stored: StoredSummary = {
id: summaryId, id: summaryId,
@@ -519,7 +450,6 @@ export class ChromaSync {
/** /**
* Sync a single user prompt to Chroma * Sync a single user prompt to Chroma
* Blocks until sync completes, throws on error * Blocks until sync completes, throws on error
* No-op on Windows (Chroma disabled to prevent console popups)
*/ */
async syncUserPrompt( async syncUserPrompt(
promptId: number, promptId: number,
@@ -529,8 +459,6 @@ export class ChromaSync {
promptNumber: number, promptNumber: number,
createdAtEpoch: number createdAtEpoch: number
): Promise<void> { ): Promise<void> {
if (this.disabled) return;
// Create StoredUserPrompt format // Create StoredUserPrompt format
const stored: StoredUserPrompt = { const stored: StoredUserPrompt = {
id: promptId, id: promptId,
@@ -562,11 +490,11 @@ export class ChromaSync {
summaries: Set<number>; summaries: Set<number>;
prompts: Set<number>; prompts: Set<number>;
}> { }> {
await this.ensureConnection(); await this.ensureCollection();
if (!this.client) { if (!this.collection) {
throw new Error( throw new Error(
'Chroma client not initialized. Call ensureConnection() before using client methods.' + 'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${this.project}` ` Project: ${this.project}`
); );
} }
@@ -582,24 +510,14 @@ export class ChromaSync {
while (true) { while (true) {
try { try {
const result = await this.client.callTool({ const result = await this.collection.get({
name: 'chroma_get_documents',
arguments: {
collection_name: this.collectionName,
limit, limit,
offset, offset,
where: { project: this.project }, // Filter by project where: { project: this.project },
include: ['metadatas'] include: ['metadatas']
}
}); });
const data = result.content[0]; const metadatas = result.metadatas || [];
if (data.type !== 'text') {
throw new Error('Unexpected response type from chroma_get_documents');
}
const parsed = JSON.parse(data.text);
const metadatas = parsed.metadatas || [];
if (metadatas.length === 0) { if (metadatas.length === 0) {
break; // No more documents break; // No more documents
@@ -607,13 +525,14 @@ export class ChromaSync {
// Extract SQLite IDs from metadata // Extract SQLite IDs from metadata
for (const meta of metadatas) { for (const meta of metadatas) {
if (meta.sqlite_id) { if (meta && meta.sqlite_id) {
const sqliteId = meta.sqlite_id as number;
if (meta.doc_type === 'observation') { if (meta.doc_type === 'observation') {
observationIds.add(meta.sqlite_id); observationIds.add(sqliteId);
} else if (meta.doc_type === 'session_summary') { } else if (meta.doc_type === 'session_summary') {
summaryIds.add(meta.sqlite_id); summaryIds.add(sqliteId);
} else if (meta.doc_type === 'user_prompt') { } else if (meta.doc_type === 'user_prompt') {
promptIds.add(meta.sqlite_id); promptIds.add(sqliteId);
} }
} }
} }
@@ -645,11 +564,8 @@ export class ChromaSync {
* Backfill: Sync all observations missing from Chroma * Backfill: Sync all observations missing from Chroma
* Reads from SQLite and syncs in batches * Reads from SQLite and syncs in batches
* Throws error if backfill fails * Throws error if backfill fails
* No-op on Windows (Chroma disabled to prevent console popups)
*/ */
async ensureBackfilled(): Promise<void> { async ensureBackfilled(): Promise<void> {
if (this.disabled) return;
logger.info('CHROMA_SYNC', 'Starting smart backfill', { project: this.project }); logger.info('CHROMA_SYNC', 'Starting smart backfill', { project: this.project });
await this.ensureCollection(); await this.ensureCollection();
@@ -816,80 +732,32 @@ export class ChromaSync {
/** /**
* Query Chroma collection for semantic search * Query Chroma collection for semantic search
* Used by SearchManager for vector-based search * Used by SearchManager for vector-based search
* Returns empty results on Windows (Chroma disabled to prevent console popups)
*/ */
async queryChroma( async queryChroma(
query: string, query: string,
limit: number, limit: number,
whereFilter?: Record<string, any> whereFilter?: Record<string, any>
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> { ): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
if (this.disabled) { await this.ensureCollection();
return { ids: [], distances: [], metadatas: [] };
}
await this.ensureConnection(); if (!this.collection) {
if (!this.client) {
throw new Error( throw new Error(
'Chroma client not initialized. Call ensureConnection() before using client methods.' + 'Chroma collection not initialized. Call ensureCollection() before using collection methods.' +
` Project: ${this.project}` ` Project: ${this.project}`
); );
} }
const whereStringified = whereFilter ? JSON.stringify(whereFilter) : undefined;
const arguments_obj = {
collection_name: this.collectionName,
query_texts: [query],
n_results: limit,
include: ['documents', 'metadatas', 'distances'],
where: whereStringified
};
let result;
try { try {
result = await this.client.callTool({ const results = await this.collection.query({
name: 'chroma_query_documents', queryTexts: [query],
arguments: arguments_obj nResults: limit,
where: whereFilter,
include: ['documents', 'metadatas', 'distances']
}); });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isConnectionError =
errorMessage.includes('Not connected') ||
errorMessage.includes('Connection closed') ||
errorMessage.includes('MCP error -32000');
if (isConnectionError) { // Extract unique SQLite IDs from document IDs
// Reset connection state so next call attempts reconnect
this.connected = false;
this.client = null;
logger.error('CHROMA_SYNC', 'Connection lost during query',
{ project: this.project, query }, error as Error);
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
}
throw error;
}
const resultText = result.content[0]?.text || (() => {
logger.error('CHROMA', 'Missing text in MCP chroma_query_documents result', {
project: this.project,
query_text: query
});
return '';
})();
// Parse JSON response
let parsed: any;
try {
parsed = JSON.parse(resultText);
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to parse Chroma response', { project: this.project }, error as Error);
return { ids: [], distances: [], metadatas: [] };
}
// Extract unique IDs from document IDs
const ids: number[] = []; const ids: number[] = [];
const docIds = parsed.ids?.[0] || []; const docIds = results.ids?.[0] || [];
for (const docId of docIds) { for (const docId of docIds) {
// Extract sqlite_id from document ID (supports three formats): // Extract sqlite_id from document ID (supports three formats):
// - obs_{id}_narrative, obs_{id}_fact_0, etc (observations) // - obs_{id}_narrative, obs_{id}_fact_0, etc (observations)
@@ -913,35 +781,42 @@ export class ChromaSync {
} }
} }
const distances = parsed.distances?.[0] || []; return {
const metadatas = parsed.metadatas?.[0] || []; ids,
distances: results.distances?.[0] || [],
metadatas: results.metadatas?.[0] || []
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { ids, distances, metadatas }; // Check for connection errors
const isConnectionError =
errorMessage.includes('ECONNREFUSED') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('fetch failed');
if (isConnectionError) {
// Reset connection state so next call attempts reconnect
this.chromaClient = null;
this.collection = null;
logger.error('CHROMA_SYNC', 'Connection lost during query',
{ project: this.project, query }, error as Error);
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
}
logger.error('CHROMA_SYNC', 'Query failed', { project: this.project, query }, error as Error);
throw error;
}
} }
/** /**
* Close the Chroma client connection and cleanup subprocess * Close the Chroma client connection
* Server lifecycle is managed by ChromaServerManager, not here
*/ */
async close(): Promise<void> { async close(): Promise<void> {
if (!this.connected && !this.client && !this.transport) { // Just clear references - server lifecycle managed by ChromaServerManager
return; this.chromaClient = null;
} this.collection = null;
logger.info('CHROMA_SYNC', 'Chroma client closed', { project: this.project });
// Close client first
if (this.client) {
await this.client.close();
}
// Explicitly close transport to kill subprocess
if (this.transport) {
await this.transport.close();
}
logger.info('CHROMA_SYNC', 'Chroma client and subprocess closed', { project: this.project });
// Always reset state
this.connected = false;
this.client = null;
this.transport = null;
} }
} }
+31 -1
View File
@@ -14,6 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js'; import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { ChromaServerManager } from './sync/ChromaServerManager.js';
// Version injected at build time by esbuild define // Version injected at build time by esbuild define
declare const __DEFAULT_PACKAGE_VERSION__: string; declare const __DEFAULT_PACKAGE_VERSION__: string;
@@ -120,6 +121,9 @@ export class WorkerService {
// Route handlers // Route handlers
private searchRoutes: SearchRoutes | null = null; private searchRoutes: SearchRoutes | null = null;
// Chroma server (local mode)
private chromaServer: ChromaServerManager | null = null;
// Initialization tracking // Initialization tracking
private initializationComplete: Promise<void>; private initializationComplete: Promise<void>;
private resolveInitialization!: () => void; private resolveInitialization!: () => void;
@@ -256,8 +260,33 @@ export class WorkerService {
const { ModeManager } = await import('./domain/ModeManager.js'); const { ModeManager } = await import('./domain/ModeManager.js');
const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js'); const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js');
const { USER_SETTINGS_PATH } = await import('../shared/paths.js'); const { USER_SETTINGS_PATH } = await import('../shared/paths.js');
const os = await import('os');
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); 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)
});
await this.chromaServer.start();
const ready = await this.chromaServer.waitForReady(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');
}
const modeId = settings.CLAUDE_MEM_MODE; const modeId = settings.CLAUDE_MEM_MODE;
ModeManager.getInstance().loadMode(modeId); ModeManager.getInstance().loadMode(modeId);
logger.info('SYSTEM', `Mode loaded: ${modeId}`); logger.info('SYSTEM', `Mode loaded: ${modeId}`);
@@ -430,7 +459,8 @@ export class WorkerService {
server: this.server.getHttpServer(), server: this.server.getHttpServer(),
sessionManager: this.sessionManager, sessionManager: this.sessionManager,
mcpClient: this.mcpClient, mcpClient: this.mcpClient,
dbManager: this.dbManager dbManager: this.dbManager,
chromaServer: this.chromaServer || undefined
}); });
} }
+18
View File
@@ -50,6 +50,15 @@ export interface SettingsDefaults {
// Feature Toggles // Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string; CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string; CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
CLAUDE_MEM_CHROMA_HOST: string;
CLAUDE_MEM_CHROMA_PORT: string;
CLAUDE_MEM_CHROMA_SSL: string;
// Future cloud support
CLAUDE_MEM_CHROMA_API_KEY: string;
CLAUDE_MEM_CHROMA_TENANT: string;
CLAUDE_MEM_CHROMA_DATABASE: string;
} }
export class SettingsDefaultsManager { export class SettingsDefaultsManager {
@@ -94,6 +103,15 @@ export class SettingsDefaultsManager {
// Feature Toggles // Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true', CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false', CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
// Chroma Vector Database Configuration
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' starts npx chroma run, 'remote' connects to existing server
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
CLAUDE_MEM_CHROMA_PORT: '8000',
CLAUDE_MEM_CHROMA_SSL: 'false',
// Future cloud support (claude-mem pro)
CLAUDE_MEM_CHROMA_API_KEY: '',
CLAUDE_MEM_CHROMA_TENANT: 'default_tenant',
CLAUDE_MEM_CHROMA_DATABASE: 'default_database',
}; };
/** /**