Merge pull request #792 from bigph00t/feat/chroma-http-server
feat: Replace MCP subprocess with persistent Chroma HTTP server
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mem",
|
||||||
|
"version": "9.0.6",
|
||||||
|
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||||
|
"author": {
|
||||||
|
"name": "Alex Newman"
|
||||||
|
},
|
||||||
|
"repository": "https://github.com/thedotmack/claude-mem",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"keywords": [
|
||||||
|
"memory",
|
||||||
|
"context",
|
||||||
|
"persistence",
|
||||||
|
"hooks",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -97,8 +97,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||||
|
"@chroma-core/default-embed": "^0.1.9",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
|
"chromadb": "^3.2.2",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"@chroma-core/default-embed": "^0.1.9"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
"bun": ">=1.0.0"
|
"bun": ">=1.0.0"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+261
-266
File diff suppressed because one or more lines are too long
+13
-2
@@ -58,7 +58,10 @@ async function buildHooks() {
|
|||||||
private: true,
|
private: true,
|
||||||
description: 'Runtime dependencies for claude-mem bundled hooks',
|
description: 'Runtime dependencies for claude-mem bundled hooks',
|
||||||
type: 'module',
|
type: 'module',
|
||||||
dependencies: {},
|
dependencies: {
|
||||||
|
// Chroma embedding function with native ONNX binaries (can't be bundled)
|
||||||
|
'@chroma-core/default-embed': '^0.1.9'
|
||||||
|
},
|
||||||
engines: {
|
engines: {
|
||||||
node: '>=18.0.0',
|
node: '>=18.0.0',
|
||||||
bun: '>=1.0.0'
|
bun: '>=1.0.0'
|
||||||
@@ -92,7 +95,15 @@ 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
|
||||||
|
'cohere-ai',
|
||||||
|
'ollama',
|
||||||
|
// Default embedding function with native binaries
|
||||||
|
'@chroma-core/default-embed',
|
||||||
|
'onnxruntime-node'
|
||||||
|
],
|
||||||
define: {
|
define: {
|
||||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,10 +61,20 @@ function getPluginVersion() {
|
|||||||
console.log('Syncing to marketplace...');
|
console.log('Syncing to marketplace...');
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(
|
||||||
'rsync -av --delete --exclude=.git --exclude=/.mcp.json ./ ~/.claude/plugins/marketplaces/thedotmack/',
|
'rsync -av --delete --exclude=.git --exclude=/.mcp.json --exclude=bun.lock --exclude=package-lock.json ./ ~/.claude/plugins/marketplaces/thedotmack/',
|
||||||
{ stdio: 'inherit' }
|
{ stdio: 'inherit' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Remove stale lockfiles before install — they pin old native dep versions
|
||||||
|
const { unlinkSync } = require('fs');
|
||||||
|
for (const lockfile of ['package-lock.json', 'bun.lock']) {
|
||||||
|
const lockpath = path.join(INSTALLED_PATH, lockfile);
|
||||||
|
if (existsSync(lockpath)) {
|
||||||
|
unlinkSync(lockpath);
|
||||||
|
console.log(`Removed stale ${lockfile}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Running npm install in marketplace...');
|
console.log('Running npm install in marketplace...');
|
||||||
execSync(
|
execSync(
|
||||||
'cd ~/.claude/plugins/marketplaces/thedotmack/ && npm install',
|
'cd ~/.claude/plugins/marketplaces/thedotmack/ && npm install',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,12 +79,19 @@ export async function performGracefulShutdown(config: GracefulShutdownConfig): P
|
|||||||
logger.info('SYSTEM', 'MCP client closed');
|
logger.info('SYSTEM', 'MCP client closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 5: Close database connection (includes ChromaSync cleanup)
|
// 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 6: Close database connection (includes ChromaSync cleanup)
|
||||||
if (config.dbManager) {
|
if (config.dbManager) {
|
||||||
await config.dbManager.close();
|
await config.dbManager.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 6: Force kill any remaining child processes (Windows zombie port fix)
|
// 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) {
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* 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 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';
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
const spawnEnv = this.getSpawnEnv();
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-343
@@ -1,28 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* 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';
|
||||||
import fs from 'fs';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -77,19 +75,13 @@ 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 popup concern resolved: the worker daemon starts with -WindowStyle Hidden,
|
|
||||||
// so child processes (uvx/chroma-mcp) inherit the hidden console and don't create new windows.
|
|
||||||
// MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited.
|
|
||||||
private readonly disabled: boolean = false;
|
|
||||||
|
|
||||||
constructor(project: string) {
|
constructor(project: string) {
|
||||||
this.project = project;
|
this.project = project;
|
||||||
this.collectionName = `cm__${project}`;
|
this.collectionName = `cm__${project}`;
|
||||||
@@ -97,150 +89,83 @@ export class ChromaSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create combined SSL certificate bundle for Zscaler/corporate proxy environments
|
* Ensure HTTP client is connected to Chroma server
|
||||||
* Combines standard certifi certificates with enterprise security certificates (e.g., Zscaler)
|
* In local mode, verifies ChromaServerManager has started the server
|
||||||
*/
|
* In remote mode, connects directly to configured host
|
||||||
private getCombinedCertPath(): string | undefined {
|
|
||||||
const combinedCertPath = path.join(os.homedir(), '.claude-mem', 'combined_certs.pem');
|
|
||||||
|
|
||||||
// If combined certs already exist and are recent (less than 24 hours old), use them
|
|
||||||
if (fs.existsSync(combinedCertPath)) {
|
|
||||||
const stats = fs.statSync(combinedCertPath);
|
|
||||||
const ageMs = Date.now() - stats.mtimeMs;
|
|
||||||
if (ageMs < 24 * 60 * 60 * 1000) {
|
|
||||||
return combinedCertPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only create on macOS (Zscaler certificate extraction uses macOS security command)
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use uvx to resolve the correct certifi path for the exact Python environment it uses
|
|
||||||
// This is more reliable than scanning the uv cache directory structure
|
|
||||||
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 {
|
|
||||||
// uvx or certifi not available
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!certifiPath || !fs.existsSync(certifiPath)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract Zscaler certificate from macOS keychain
|
|
||||||
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 {
|
|
||||||
// Zscaler not found, which is fine - not all environments have it
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate PEM certificate format (must have both BEGIN and END markers)
|
|
||||||
if (!zscalerCert ||
|
|
||||||
!zscalerCert.includes('-----BEGIN CERTIFICATE-----') ||
|
|
||||||
!zscalerCert.includes('-----END CERTIFICATE-----')) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create combined certificate bundle with atomic write (write to temp, then rename)
|
|
||||||
const certifiContent = fs.readFileSync(certifiPath, 'utf8');
|
|
||||||
const tempPath = combinedCertPath + '.tmp';
|
|
||||||
fs.writeFileSync(tempPath, certifiContent + '\n' + zscalerCert);
|
|
||||||
fs.renameSync(tempPath, combinedCertPath);
|
|
||||||
logger.info('CHROMA_SYNC', 'Created combined SSL certificate bundle for Zscaler', {
|
|
||||||
path: combinedCertPath
|
|
||||||
});
|
|
||||||
|
|
||||||
return combinedCertPath;
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('CHROMA_SYNC', 'Could not create combined cert bundle', {}, error as Error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Chroma is disabled (Windows)
|
|
||||||
*/
|
|
||||||
isDisabled(): boolean {
|
|
||||||
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 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';
|
||||||
|
|
||||||
// Get combined SSL certificate bundle for Zscaler/corporate proxy environments
|
// Multi-tenancy settings (used in remote/pro mode)
|
||||||
const combinedCertPath = this.getCombinedCertPath();
|
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 || '';
|
||||||
|
|
||||||
const transportOptions: any = {
|
// In local mode, verify server is reachable
|
||||||
command: 'uvx',
|
if (mode === 'local') {
|
||||||
args: [
|
const serverManager = ChromaServerManager.getInstance();
|
||||||
'--python', pythonVersion,
|
const reachable = await serverManager.isServerReachable();
|
||||||
'chroma-mcp',
|
if (!reachable) {
|
||||||
'--client-type', 'persistent',
|
throw new Error('Chroma server not reachable. Ensure worker started correctly.');
|
||||||
'--data-dir', this.VECTOR_DB_DIR
|
}
|
||||||
],
|
}
|
||||||
stderr: 'ignore'
|
|
||||||
|
// 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
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add SSL certificate environment variables for corporate proxy/Zscaler environments
|
// In remote mode, use tenant isolation for pro users
|
||||||
if (combinedCertPath) {
|
if (mode === 'remote') {
|
||||||
transportOptions.env = {
|
clientOptions.tenant = tenant;
|
||||||
...process.env,
|
clientOptions.database = database;
|
||||||
SSL_CERT_FILE: combinedCertPath,
|
|
||||||
REQUESTS_CA_BUNDLE: combinedCertPath,
|
// Add API key header if configured
|
||||||
CURL_CA_BUNDLE: combinedCertPath
|
if (apiKey) {
|
||||||
};
|
clientOptions.headers = {
|
||||||
logger.info('CHROMA_SYNC', 'Using combined SSL certificates for Zscaler compatibility', {
|
'Authorization': `Bearer ${apiKey}`
|
||||||
certPath: combinedCertPath
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('CHROMA_SYNC', 'Connecting with tenant isolation', {
|
||||||
|
tenant,
|
||||||
|
database,
|
||||||
|
hasApiKey: !!apiKey
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: windowsHide is not needed here because the worker daemon starts with
|
this.chromaClient = new ChromaClient(clientOptions);
|
||||||
// -WindowStyle Hidden, so child processes inherit the hidden console.
|
|
||||||
// The MCP SDK ignores custom windowsHide anyway (overridden internally).
|
|
||||||
|
|
||||||
this.transport = new StdioClientTransport(transportOptions);
|
// Verify connection with heartbeat
|
||||||
|
await this.chromaClient.heartbeat();
|
||||||
|
|
||||||
// Empty capabilities object: this client only calls Chroma tools, doesn't expose any
|
logger.info('CHROMA_SYNC', 'Connected to Chroma HTTP server', {
|
||||||
this.client = new Client({
|
project: this.project,
|
||||||
name: 'claude-mem-chroma-sync',
|
host,
|
||||||
version: packageVersion
|
port,
|
||||||
}, {
|
ssl,
|
||||||
capabilities: {}
|
mode,
|
||||||
|
tenant: mode === 'remote' ? tenant : 'default_tenant'
|
||||||
});
|
});
|
||||||
|
|
||||||
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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +177,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}`
|
||||||
@@ -260,60 +189,20 @@ export class ChromaSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get collection info (will fail if doesn't exist)
|
// getOrCreateCollection handles both cases
|
||||||
await this.client.callTool({
|
// Lazy-load DefaultEmbeddingFunction to avoid eagerly pulling in
|
||||||
name: 'chroma_get_collection_info',
|
// @huggingface/transformers → sharp native binaries at bundle startup
|
||||||
arguments: {
|
const { DefaultEmbeddingFunction } = await import('@chroma-core/default-embed');
|
||||||
collection_name: this.collectionName
|
const embeddingFunction = new DefaultEmbeddingFunction();
|
||||||
}
|
this.collection = await this.chromaClient.getOrCreateCollection({
|
||||||
|
name: this.collectionName,
|
||||||
|
embeddingFunction
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
|
||||||
// FIX: Close transport to kill subprocess before resetting state
|
|
||||||
// Without this, old chroma-mcp processes leak as zombies
|
|
||||||
if (this.transport) {
|
|
||||||
try {
|
|
||||||
await this.transport.close();
|
|
||||||
} catch (closeErr) {
|
|
||||||
logger.debug('CHROMA_SYNC', 'Transport close error (expected if already dead)', {}, closeErr as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset connection state so next call attempts reconnect
|
|
||||||
this.connected = false;
|
|
||||||
this.client = null;
|
|
||||||
this.transport = 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)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,22 +352,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',
|
ids: documents.map(d => d.id),
|
||||||
arguments: {
|
documents: documents.map(d => d.document),
|
||||||
collection_name: this.collectionName,
|
metadatas: documents.map(d => d.metadata)
|
||||||
documents: documents.map(d => d.document),
|
|
||||||
ids: documents.map(d => d.id),
|
|
||||||
metadatas: documents.map(d => d.metadata)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug('CHROMA_SYNC', 'Documents added', {
|
logger.debug('CHROMA_SYNC', 'Documents added', {
|
||||||
@@ -497,7 +382,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,
|
||||||
@@ -508,8 +392,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,
|
||||||
@@ -544,7 +426,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,
|
||||||
@@ -555,8 +436,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,
|
||||||
@@ -607,7 +486,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,
|
||||||
@@ -617,8 +495,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,
|
||||||
@@ -650,11 +526,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}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -670,24 +546,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',
|
limit,
|
||||||
arguments: {
|
offset,
|
||||||
collection_name: this.collectionName,
|
where: { project: this.project },
|
||||||
limit,
|
include: ['metadatas']
|
||||||
offset,
|
|
||||||
where: { project: this.project }, // Filter by project
|
|
||||||
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
|
||||||
@@ -695,13 +561,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,11 +600,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();
|
||||||
@@ -904,141 +768,91 @@ 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']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract unique SQLite IDs from document IDs
|
||||||
|
const ids: number[] = [];
|
||||||
|
const docIds = results.ids?.[0] || [];
|
||||||
|
for (const docId of docIds) {
|
||||||
|
// 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)
|
||||||
|
// - prompt_{id} (user prompts)
|
||||||
|
const obsMatch = docId.match(/obs_(\d+)_/);
|
||||||
|
const summaryMatch = docId.match(/summary_(\d+)_/);
|
||||||
|
const promptMatch = docId.match(/prompt_(\d+)/);
|
||||||
|
|
||||||
|
let sqliteId: number | null = null;
|
||||||
|
if (obsMatch) {
|
||||||
|
sqliteId = parseInt(obsMatch[1], 10);
|
||||||
|
} else if (summaryMatch) {
|
||||||
|
sqliteId = parseInt(summaryMatch[1], 10);
|
||||||
|
} else if (promptMatch) {
|
||||||
|
sqliteId = parseInt(promptMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sqliteId !== null && !ids.includes(sqliteId)) {
|
||||||
|
ids.push(sqliteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ids,
|
||||||
|
distances: results.distances?.[0] || [],
|
||||||
|
metadatas: results.metadatas?.[0] || []
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Check for connection errors
|
||||||
const isConnectionError =
|
const isConnectionError =
|
||||||
errorMessage.includes('Not connected') ||
|
errorMessage.includes('ECONNREFUSED') ||
|
||||||
errorMessage.includes('Connection closed') ||
|
errorMessage.includes('ENOTFOUND') ||
|
||||||
errorMessage.includes('MCP error -32000');
|
errorMessage.includes('fetch failed');
|
||||||
|
|
||||||
if (isConnectionError) {
|
if (isConnectionError) {
|
||||||
// FIX: Close transport to kill subprocess before resetting state
|
|
||||||
if (this.transport) {
|
|
||||||
try {
|
|
||||||
await this.transport.close();
|
|
||||||
} catch (closeErr) {
|
|
||||||
logger.debug('CHROMA_SYNC', 'Transport close error (expected if already dead)', {}, closeErr as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset connection state so next call attempts reconnect
|
// Reset connection state so next call attempts reconnect
|
||||||
this.connected = false;
|
this.chromaClient = null;
|
||||||
this.client = null;
|
this.collection = null;
|
||||||
this.transport = null;
|
|
||||||
logger.error('CHROMA_SYNC', 'Connection lost during query',
|
logger.error('CHROMA_SYNC', 'Connection lost during query',
|
||||||
{ project: this.project, query }, error as Error);
|
{ project: this.project, query }, error as Error);
|
||||||
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
|
throw new Error(`Chroma query failed - connection lost: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.error('CHROMA_SYNC', 'Query failed', { project: this.project, query }, error as Error);
|
||||||
throw error;
|
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 docIds = parsed.ids?.[0] || [];
|
|
||||||
for (const docId of docIds) {
|
|
||||||
// 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)
|
|
||||||
// - prompt_{id} (user prompts)
|
|
||||||
const obsMatch = docId.match(/obs_(\d+)_/);
|
|
||||||
const summaryMatch = docId.match(/summary_(\d+)_/);
|
|
||||||
const promptMatch = docId.match(/prompt_(\d+)/);
|
|
||||||
|
|
||||||
let sqliteId: number | null = null;
|
|
||||||
if (obsMatch) {
|
|
||||||
sqliteId = parseInt(obsMatch[1], 10);
|
|
||||||
} else if (summaryMatch) {
|
|
||||||
sqliteId = parseInt(summaryMatch[1], 10);
|
|
||||||
} else if (promptMatch) {
|
|
||||||
sqliteId = parseInt(promptMatch[1], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sqliteId !== null && !ids.includes(sqliteId)) {
|
|
||||||
ids.push(sqliteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const distances = parsed.distances?.[0] || [];
|
|
||||||
const metadatas = parsed.metadatas?.[0] || [];
|
|
||||||
|
|
||||||
return { ids, distances, metadatas };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
|
|||||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||||
import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { ChromaServerManager } from './sync/ChromaServerManager.js';
|
||||||
|
|
||||||
// Windows: avoid repeated spawn popups when startup fails (issue #921)
|
// Windows: avoid repeated spawn popups when startup fails (issue #921)
|
||||||
const WINDOWS_SPAWN_COOLDOWN_MS = 2 * 60 * 1000;
|
const WINDOWS_SPAWN_COOLDOWN_MS = 2 * 60 * 1000;
|
||||||
@@ -164,6 +165,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;
|
||||||
@@ -365,8 +369,32 @@ 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)
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
@@ -776,7 +804,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,12 +290,22 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
|||||||
windowsHide: true
|
windowsHide: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Capture stderr for debugging spawn failures
|
||||||
|
if (child.stderr) {
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
logger.debug('SDK_SPAWN', `[session-${sessionDbId}] stderr: ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Register PID
|
// Register PID
|
||||||
if (child.pid) {
|
if (child.pid) {
|
||||||
registerProcess(child.pid, sessionDbId, child);
|
registerProcess(child.pid, sessionDbId, child);
|
||||||
|
|
||||||
// Auto-unregister on exit
|
// Auto-unregister on exit
|
||||||
child.on('exit', () => {
|
child.on('exit', (code: number | null, signal: string | null) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
logger.warn('SDK_SPAWN', `[session-${sessionDbId}] Claude process exited`, { code, signal, pid: child.pid });
|
||||||
|
}
|
||||||
if (child.pid) {
|
if (child.pid) {
|
||||||
unregisterProcess(child.pid);
|
unregisterProcess(child.pid);
|
||||||
}
|
}
|
||||||
@@ -306,6 +316,7 @@ export function createPidCapturingSpawn(sessionDbId: number) {
|
|||||||
return {
|
return {
|
||||||
stdin: child.stdin,
|
stdin: child.stdin,
|
||||||
stdout: child.stdout,
|
stdout: child.stdout,
|
||||||
|
stderr: child.stderr,
|
||||||
get killed() { return child.killed; },
|
get killed() { return child.killed; },
|
||||||
get exitCode() { return child.exitCode; },
|
get exitCode() { return child.exitCode; },
|
||||||
kill: child.kill.bind(child),
|
kill: child.kill.bind(child),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const ENV_FILE_PATH = join(DATA_DIR, '.env');
|
|||||||
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
||||||
const BLOCKED_ENV_VARS = [
|
const BLOCKED_ENV_VARS = [
|
||||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||||
|
'CLAUDECODE', // Prevent "cannot be launched inside another Claude Code session" error
|
||||||
];
|
];
|
||||||
|
|
||||||
// Credential keys that claude-mem manages
|
// Credential keys that claude-mem manages
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export interface SettingsDefaults {
|
|||||||
// Exclusion Settings
|
// Exclusion Settings
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||||
|
// 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 {
|
||||||
@@ -104,6 +113,15 @@ export class SettingsDefaultsManager {
|
|||||||
// Exclusion Settings
|
// Exclusion Settings
|
||||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
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
|
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_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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ describe('GracefulShutdown', () => {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockChromaServer = {
|
||||||
|
stop: mock(async () => {
|
||||||
|
callOrder.push('chromaServer.stop');
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Create a PID file so we can verify it's removed
|
// Create a PID file so we can verify it's removed
|
||||||
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
||||||
expect(existsSync(PID_FILE)).toBe(true);
|
expect(existsSync(PID_FILE)).toBe(true);
|
||||||
@@ -95,16 +101,18 @@ describe('GracefulShutdown', () => {
|
|||||||
server: mockServer,
|
server: mockServer,
|
||||||
sessionManager: mockSessionManager,
|
sessionManager: mockSessionManager,
|
||||||
mcpClient: mockMcpClient,
|
mcpClient: mockMcpClient,
|
||||||
dbManager: mockDbManager
|
dbManager: mockDbManager,
|
||||||
|
chromaServer: mockChromaServer
|
||||||
};
|
};
|
||||||
|
|
||||||
await performGracefulShutdown(config);
|
await performGracefulShutdown(config);
|
||||||
|
|
||||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then DB
|
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then Chroma, then DB
|
||||||
expect(callOrder).toContain('closeAllConnections');
|
expect(callOrder).toContain('closeAllConnections');
|
||||||
expect(callOrder).toContain('serverClose');
|
expect(callOrder).toContain('serverClose');
|
||||||
expect(callOrder).toContain('sessionManager.shutdownAll');
|
expect(callOrder).toContain('sessionManager.shutdownAll');
|
||||||
expect(callOrder).toContain('mcpClient.close');
|
expect(callOrder).toContain('mcpClient.close');
|
||||||
|
expect(callOrder).toContain('chromaServer.stop');
|
||||||
expect(callOrder).toContain('dbManager.close');
|
expect(callOrder).toContain('dbManager.close');
|
||||||
|
|
||||||
// Verify server closes before session manager
|
// Verify server closes before session manager
|
||||||
@@ -115,6 +123,9 @@ describe('GracefulShutdown', () => {
|
|||||||
|
|
||||||
// Verify MCP closes before database
|
// Verify MCP closes before database
|
||||||
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||||
|
|
||||||
|
// Verify Chroma stops before DB closes
|
||||||
|
expect(callOrder.indexOf('chromaServer.stop')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove PID file during shutdown', async () => {
|
it('should remove PID file during shutdown', async () => {
|
||||||
@@ -184,7 +195,7 @@ describe('GracefulShutdown', () => {
|
|||||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
|
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close database after MCP client', async () => {
|
it('should stop chroma server before database close', async () => {
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
|
|
||||||
const mockSessionManager: ShutdownableService = {
|
const mockSessionManager: ShutdownableService = {
|
||||||
@@ -205,16 +216,23 @@ describe('GracefulShutdown', () => {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockChromaServer = {
|
||||||
|
stop: mock(async () => {
|
||||||
|
callOrder.push('chromaServer');
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
const config: GracefulShutdownConfig = {
|
const config: GracefulShutdownConfig = {
|
||||||
server: null,
|
server: null,
|
||||||
sessionManager: mockSessionManager,
|
sessionManager: mockSessionManager,
|
||||||
mcpClient: mockMcpClient,
|
mcpClient: mockMcpClient,
|
||||||
dbManager: mockDbManager
|
dbManager: mockDbManager,
|
||||||
|
chromaServer: mockChromaServer
|
||||||
};
|
};
|
||||||
|
|
||||||
await performGracefulShutdown(config);
|
await performGracefulShutdown(config);
|
||||||
|
|
||||||
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'dbManager']);
|
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'chromaServer', 'dbManager']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle shutdown when PID file does not exist', async () => {
|
it('should handle shutdown when PID file does not exist', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
|
import { ChromaServerManager } from '../../../src/services/sync/ChromaServerManager.js';
|
||||||
|
|
||||||
|
function createFakeProcess(pid: number = 4242): childProcess.ChildProcess {
|
||||||
|
const proc = new EventEmitter() as childProcess.ChildProcess & EventEmitter;
|
||||||
|
let exited = false;
|
||||||
|
|
||||||
|
(proc as any).stdout = new EventEmitter();
|
||||||
|
(proc as any).stderr = new EventEmitter();
|
||||||
|
(proc as any).pid = pid;
|
||||||
|
(proc as any).kill = mock(() => {
|
||||||
|
if (!exited) {
|
||||||
|
exited = true;
|
||||||
|
setTimeout(() => proc.emit('exit', 0, 'SIGTERM'), 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return proc as childProcess.ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChromaServerManager', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
ChromaServerManager.reset();
|
||||||
|
|
||||||
|
// Avoid macOS cert bundle shelling in tests; these tests only exercise startup races.
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'linux',
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
mock.restore();
|
||||||
|
ChromaServerManager.reset();
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses in-flight startup and only spawns one server process', async () => {
|
||||||
|
const fetchMock = mock(async () => {
|
||||||
|
// First call: existing server check fails, second call: waitForReady succeeds.
|
||||||
|
if (fetchMock.mock.calls.length === 1) {
|
||||||
|
throw new Error('no server yet');
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
});
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
|
||||||
|
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = ChromaServerManager.getInstance({
|
||||||
|
dataDir: '/tmp/chroma-test',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
manager.start(2000),
|
||||||
|
manager.start(2000)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses existing reachable server without spawning', async () => {
|
||||||
|
global.fetch = mock(async () => new Response(null, { status: 200 })) as typeof fetch;
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
|
||||||
|
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = ChromaServerManager.getInstance({
|
||||||
|
dataDir: '/tmp/chroma-test',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = await manager.start(2000);
|
||||||
|
expect(ready).toBe(true);
|
||||||
|
expect(spawnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for ongoing startup instead of returning early', async () => {
|
||||||
|
let resolveReady: ((value: Response) => void) | null = null;
|
||||||
|
const delayedReady = new Promise<Response>((resolve) => {
|
||||||
|
resolveReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMock = mock(async () => {
|
||||||
|
// 1st: existing server check -> fail, 2nd: waitForReady -> block until we resolve.
|
||||||
|
if (fetchMock.mock.calls.length === 1) {
|
||||||
|
throw new Error('no server yet');
|
||||||
|
}
|
||||||
|
return delayedReady;
|
||||||
|
});
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
spyOn(childProcess, 'spawn').mockImplementation(
|
||||||
|
() => createFakeProcess() as unknown as ReturnType<typeof childProcess.spawn>
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = ChromaServerManager.getInstance({
|
||||||
|
dataDir: '/tmp/chroma-test',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStart = manager.start(5000);
|
||||||
|
let secondResolved = false;
|
||||||
|
const secondStart = manager.start(5000).then((value) => {
|
||||||
|
secondResolved = true;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(secondResolved).toBe(false);
|
||||||
|
|
||||||
|
resolveReady!(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
expect(await firstStart).toBe(true);
|
||||||
|
expect(await secondStart).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user