fleet-memory/hindsight-integrations/openclaw/src/embed-manager.ts
Anton Evseev d5e62162e8
fix(openclaw): remove unused imports, retry health check, suppress unhandled rejection (#373)
- Remove unused `fs` and `execSync` imports from `embed-manager.ts`
- Remove unused `join` import from `index.ts`
- Add retry logic to external API health check (3 attempts, 2s delay) —
  container DNS may not be ready on first boot
- Use ES2022 `{ cause: error }` for better error chain preservation
- Add `.catch(() => {})` to `initPromise` to suppress Node.js unhandled
  rejection warnings (error is properly handled later in `service.start()`)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:02:19 +01:00

247 lines
7.5 KiB
TypeScript

import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
import { homedir } from 'os';
export class HindsightEmbedManager {
private process: ChildProcess | null = null;
private port: number;
private baseUrl: string;
private embedDir: string;
private llmProvider: string;
private llmApiKey: string;
private llmModel?: string;
private llmBaseUrl?: string;
private daemonIdleTimeout: number;
private embedVersion: string;
private embedPackagePath?: string;
constructor(
port: number,
llmProvider: string,
llmApiKey: string,
llmModel?: string,
llmBaseUrl?: string,
daemonIdleTimeout: number = 0, // Default: never timeout
embedVersion: string = 'latest', // Default: latest
embedPackagePath?: string // Local path to hindsight package
) {
// Use the configured port (default: 9077 from config)
this.port = port;
this.baseUrl = `http://127.0.0.1:${port}`;
this.embedDir = join(homedir(), '.openclaw', 'hindsight-embed');
this.llmProvider = llmProvider;
this.llmApiKey = llmApiKey;
this.llmModel = llmModel;
this.llmBaseUrl = llmBaseUrl;
this.daemonIdleTimeout = daemonIdleTimeout;
this.embedVersion = embedVersion || 'latest';
this.embedPackagePath = embedPackagePath;
}
/**
* Get the command to run hindsight-embed (either local or from PyPI)
*/
private getEmbedCommand(): string[] {
if (this.embedPackagePath) {
// Local package: uv run --directory <path> hindsight-embed
return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed'];
} else {
// PyPI package: uvx hindsight-embed@version
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
return ['uvx', embedPackage];
}
}
async start(): Promise<void> {
console.log(`[Hindsight] Starting hindsight-embed daemon...`);
// Build environment variables using standard HINDSIGHT_API_LLM_* variables
const env: NodeJS.ProcessEnv = {
...process.env,
HINDSIGHT_API_LLM_PROVIDER: this.llmProvider,
HINDSIGHT_API_LLM_API_KEY: this.llmApiKey,
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: this.daemonIdleTimeout.toString(),
};
if (this.llmModel) {
env['HINDSIGHT_API_LLM_MODEL'] = this.llmModel;
}
// Pass through base URL for OpenAI-compatible providers (OpenRouter, etc.)
if (this.llmBaseUrl) {
env['HINDSIGHT_API_LLM_BASE_URL'] = this.llmBaseUrl;
}
// On macOS, force CPU for embeddings/reranker to avoid MPS/Metal issues in daemon mode
if (process.platform === 'darwin') {
env['HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
env['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
}
// Configure "openclaw" profile using hindsight-embed configure (non-interactive)
console.log('[Hindsight] Configuring "openclaw" profile...');
await this.configureProfile(env);
// Start hindsight-embed daemon with openclaw profile
const embedCmd = this.getEmbedCommand();
const startDaemon = spawn(
embedCmd[0],
[...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'start'],
{
stdio: 'pipe',
}
);
// Collect output
let output = '';
startDaemon.stdout?.on('data', (data) => {
const text = data.toString();
output += text;
console.log(`[Hindsight] ${text.trim()}`);
});
startDaemon.stderr?.on('data', (data) => {
const text = data.toString();
output += text;
console.error(`[Hindsight] ${text.trim()}`);
});
// Wait for daemon start command to complete
await new Promise<void>((resolve, reject) => {
startDaemon.on('exit', (code) => {
if (code === 0) {
console.log('[Hindsight] Daemon start command completed');
resolve();
} else {
reject(new Error(`Daemon start failed with code ${code}: ${output}`));
}
});
startDaemon.on('error', (error) => {
reject(error);
});
});
// Wait for server to be ready
await this.waitForReady();
console.log('[Hindsight] Daemon is ready');
}
async stop(): Promise<void> {
console.log('[Hindsight] Stopping hindsight-embed daemon...');
const embedCmd = this.getEmbedCommand();
const stopDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'stop'], {
stdio: 'pipe',
});
await new Promise<void>((resolve) => {
stopDaemon.on('exit', () => {
console.log('[Hindsight] Daemon stopped');
resolve();
});
stopDaemon.on('error', (error) => {
console.error('[Hindsight] Error stopping daemon:', error);
resolve(); // Resolve anyway
});
// Timeout after 5 seconds
setTimeout(() => {
console.log('[Hindsight] Daemon stop timeout');
resolve();
}, 5000);
});
}
private async waitForReady(maxAttempts = 30): Promise<void> {
console.log('[Hindsight] Waiting for daemon to be ready...');
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`${this.baseUrl}/health`);
if (response.ok) {
console.log('[Hindsight] Daemon health check passed');
return;
}
} catch {
// Not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error('Hindsight daemon failed to become ready within 30 seconds');
}
getBaseUrl(): string {
return this.baseUrl;
}
isRunning(): boolean {
return this.process !== null;
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
return response.ok;
} catch {
return false;
}
}
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
// Build profile create command args with --merge, --port and --env flags
// Use --merge to allow updating existing profile
const createArgs = ['profile', 'create', 'openclaw', '--merge', '--port', this.port.toString()];
// Add all environment variables as --env flags
const envVars = [
'HINDSIGHT_API_LLM_PROVIDER',
'HINDSIGHT_API_LLM_MODEL',
'HINDSIGHT_API_LLM_API_KEY',
'HINDSIGHT_API_LLM_BASE_URL',
'HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT',
'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
];
for (const envVar of envVars) {
if (env[envVar]) {
createArgs.push('--env', `${envVar}=${env[envVar]}`);
}
}
// Run profile create command (non-interactive, overwrites if exists)
const embedCmd = this.getEmbedCommand();
const create = spawn(embedCmd[0], [...embedCmd.slice(1), ...createArgs], {
stdio: 'pipe',
});
let output = '';
create.stdout?.on('data', (data) => {
const text = data.toString();
output += text;
console.log(`[Hindsight] ${text.trim()}`);
});
create.stderr?.on('data', (data) => {
const text = data.toString();
output += text;
console.error(`[Hindsight] ${text.trim()}`);
});
await new Promise<void>((resolve, reject) => {
create.on('exit', (code) => {
if (code === 0) {
console.log('[Hindsight] Profile "openclaw" configured successfully');
resolve();
} else {
reject(new Error(`Profile create failed with code ${code}: ${output}`));
}
});
create.on('error', (error) => {
reject(error);
});
});
}
}