Integrate Context-Engine RAG service for enhanced LLM responses
Backend: - Created context-engine/client.ts - HTTP client for Context-Engine API - Created context-engine/service.ts - Lifecycle management of Context-Engine sidecar - Created context-engine/index.ts - Module exports - Created server/routes/context-engine.ts - API endpoints for status/health/query Integration: - workspaces/manager.ts: Trigger indexing when workspace becomes ready (non-blocking) - index.ts: Initialize ContextEngineService on server start (lazy mode) - ollama-cloud.ts: Inject RAG context into chat requests when available Frontend: - model-selector.tsx: Added Context-Engine status indicator - Green dot = Ready (RAG enabled) - Blue pulsing dot = Indexing - Red dot = Error - Hidden when Context-Engine not running All operations are non-blocking with graceful fallback when Context-Engine is unavailable.
This commit is contained in:
350
packages/server/src/context-engine/service.ts
Normal file
350
packages/server/src/context-engine/service.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Context Engine Service
|
||||
* Manages the lifecycle of the Context-Engine process (Python sidecar)
|
||||
* and provides access to the Context-Engine client.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { Logger } from "../logger"
|
||||
import { ContextEngineClient, ContextEngineConfig, HealthResponse } from "./client"
|
||||
|
||||
export type ContextEngineStatus = "stopped" | "starting" | "ready" | "indexing" | "error"
|
||||
|
||||
export interface ContextEngineServiceConfig {
|
||||
/** Path to the context-engine executable or Python script */
|
||||
binaryPath?: string
|
||||
/** Arguments to pass to the context-engine process */
|
||||
args?: string[]
|
||||
/** Port for the Context-Engine API (default: 8000) */
|
||||
port: number
|
||||
/** Host for the Context-Engine API (default: localhost) */
|
||||
host: string
|
||||
/** Whether to auto-start the engine when first needed (lazy start) */
|
||||
lazyStart: boolean
|
||||
/** Health check interval in milliseconds */
|
||||
healthCheckInterval: number
|
||||
/** Max retries for health check before marking as error */
|
||||
maxHealthCheckRetries: number
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICE_CONFIG: ContextEngineServiceConfig = {
|
||||
binaryPath: "context-engine",
|
||||
args: [],
|
||||
port: 8000,
|
||||
host: "localhost",
|
||||
lazyStart: true,
|
||||
healthCheckInterval: 5000,
|
||||
maxHealthCheckRetries: 3,
|
||||
}
|
||||
|
||||
export class ContextEngineService extends EventEmitter {
|
||||
private config: ContextEngineServiceConfig
|
||||
private logger: Logger
|
||||
private process: ChildProcess | null = null
|
||||
private client: ContextEngineClient
|
||||
private status: ContextEngineStatus = "stopped"
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null
|
||||
private healthCheckFailures = 0
|
||||
private indexingPaths = new Set<string>()
|
||||
|
||||
constructor(config: Partial<ContextEngineServiceConfig> = {}, logger: Logger) {
|
||||
super()
|
||||
this.config = { ...DEFAULT_SERVICE_CONFIG, ...config }
|
||||
this.logger = logger
|
||||
|
||||
const clientConfig: Partial<ContextEngineConfig> = {
|
||||
baseUrl: `http://${this.config.host}:${this.config.port}`,
|
||||
timeout: 30000,
|
||||
}
|
||||
this.client = new ContextEngineClient(clientConfig, logger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the Context-Engine
|
||||
*/
|
||||
getStatus(): ContextEngineStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Context-Engine is ready to accept requests
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.status === "ready" || this.status === "indexing"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Context-Engine client for making API calls
|
||||
*/
|
||||
getClient(): ContextEngineClient {
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Context-Engine process
|
||||
*/
|
||||
async start(): Promise<boolean> {
|
||||
if (this.status === "ready" || this.status === "starting") {
|
||||
this.logger.debug("Context-Engine already started or starting")
|
||||
return true
|
||||
}
|
||||
|
||||
this.setStatus("starting")
|
||||
this.logger.info({ config: this.config }, "Starting Context-Engine service")
|
||||
|
||||
// First, check if an external Context-Engine is already running
|
||||
const externalHealth = await this.client.health()
|
||||
if (externalHealth.status === "healthy") {
|
||||
this.logger.info("External Context-Engine detected and healthy")
|
||||
this.setStatus("ready")
|
||||
this.startHealthCheck()
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to spawn the process
|
||||
if (!this.config.binaryPath) {
|
||||
this.logger.warn("No binary path configured for Context-Engine")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const args = [
|
||||
...(this.config.args || []),
|
||||
"--port", String(this.config.port),
|
||||
"--host", this.config.host,
|
||||
]
|
||||
|
||||
this.logger.info({ binary: this.config.binaryPath, args }, "Spawning Context-Engine process")
|
||||
|
||||
this.process = spawn(this.config.binaryPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
detached: false,
|
||||
})
|
||||
|
||||
this.process.stdout?.on("data", (data) => {
|
||||
this.logger.debug({ output: data.toString().trim() }, "Context-Engine stdout")
|
||||
})
|
||||
|
||||
this.process.stderr?.on("data", (data) => {
|
||||
this.logger.debug({ output: data.toString().trim() }, "Context-Engine stderr")
|
||||
})
|
||||
|
||||
this.process.on("error", (error) => {
|
||||
this.logger.error({ error }, "Context-Engine process error")
|
||||
this.setStatus("error")
|
||||
})
|
||||
|
||||
this.process.on("exit", (code, signal) => {
|
||||
this.logger.info({ code, signal }, "Context-Engine process exited")
|
||||
this.process = null
|
||||
if (this.status !== "stopped") {
|
||||
this.setStatus("error")
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the process to become ready
|
||||
const ready = await this.waitForReady(30000)
|
||||
if (ready) {
|
||||
this.setStatus("ready")
|
||||
this.startHealthCheck()
|
||||
return true
|
||||
} else {
|
||||
this.logger.error("Context-Engine failed to become ready")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, "Failed to spawn Context-Engine process")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Context-Engine process
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.stopHealthCheck()
|
||||
this.setStatus("stopped")
|
||||
|
||||
if (this.process) {
|
||||
this.logger.info("Stopping Context-Engine process")
|
||||
this.process.kill("SIGTERM")
|
||||
|
||||
// Wait for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.process) {
|
||||
this.logger.warn("Context-Engine did not exit gracefully, killing")
|
||||
this.process.kill("SIGKILL")
|
||||
}
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
if (this.process) {
|
||||
this.process.once("exit", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
this.process = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger indexing for a workspace path (non-blocking)
|
||||
*/
|
||||
async indexPath(path: string): Promise<void> {
|
||||
if (!this.config.lazyStart && !this.isReady()) {
|
||||
this.logger.debug({ path }, "Context-Engine not ready, skipping indexing")
|
||||
return
|
||||
}
|
||||
|
||||
// Lazy start if needed
|
||||
if (this.config.lazyStart && this.status === "stopped") {
|
||||
this.logger.info({ path }, "Lazy-starting Context-Engine for indexing")
|
||||
const started = await this.start()
|
||||
if (!started) {
|
||||
this.logger.warn({ path }, "Failed to start Context-Engine for indexing")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.indexingPaths.has(path)) {
|
||||
this.logger.debug({ path }, "Path already being indexed")
|
||||
return
|
||||
}
|
||||
|
||||
this.indexingPaths.add(path)
|
||||
this.setStatus("indexing")
|
||||
|
||||
// Fire and forget - don't block workspace creation
|
||||
this.client.index(path).then((response) => {
|
||||
this.indexingPaths.delete(path)
|
||||
if (response.status === "error") {
|
||||
this.logger.warn({ path, response }, "Context-Engine indexing failed")
|
||||
} else {
|
||||
this.logger.info({ path, indexed_files: response.indexed_files }, "Context-Engine indexing completed")
|
||||
}
|
||||
if (this.indexingPaths.size === 0 && this.status === "indexing") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
this.emit("indexComplete", { path, response })
|
||||
}).catch((error) => {
|
||||
this.indexingPaths.delete(path)
|
||||
this.logger.error({ path, error }, "Context-Engine indexing error")
|
||||
if (this.indexingPaths.size === 0 && this.status === "indexing") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Context-Engine for relevant code snippets
|
||||
*/
|
||||
async query(prompt: string, contextWindow?: number): Promise<string | null> {
|
||||
if (!this.isReady()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.query(prompt, contextWindow)
|
||||
if (response.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Format the results as a context block
|
||||
const contextParts = response.results.map((result, index) => {
|
||||
return `// File: ${result.file_path} (relevance: ${(result.score * 100).toFixed(1)}%)\n${result.content}`
|
||||
})
|
||||
|
||||
return `<context_engine_retrieval>\n${contextParts.join("\n\n")}\n</context_engine_retrieval>`
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Context-Engine query failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: ContextEngineStatus): void {
|
||||
if (this.status !== status) {
|
||||
this.logger.info({ oldStatus: this.status, newStatus: status }, "Context-Engine status changed")
|
||||
this.status = status
|
||||
this.emit("statusChange", status)
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForReady(timeoutMs: number): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
const checkInterval = 500
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const health = await this.client.health()
|
||||
if (health.status === "healthy") {
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) return
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const health = await this.client.health()
|
||||
if (health.status === "healthy") {
|
||||
this.healthCheckFailures = 0
|
||||
if (this.status === "error") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
} else {
|
||||
this.healthCheckFailures++
|
||||
if (this.healthCheckFailures >= this.config.maxHealthCheckRetries) {
|
||||
this.logger.warn("Context-Engine health check failed multiple times")
|
||||
this.setStatus("error")
|
||||
}
|
||||
}
|
||||
}, this.config.healthCheckInterval)
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer)
|
||||
this.healthCheckTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global access
|
||||
let globalContextEngineService: ContextEngineService | null = null
|
||||
|
||||
export function getContextEngineService(): ContextEngineService | null {
|
||||
return globalContextEngineService
|
||||
}
|
||||
|
||||
export function initializeContextEngineService(
|
||||
config: Partial<ContextEngineServiceConfig>,
|
||||
logger: Logger
|
||||
): ContextEngineService {
|
||||
if (globalContextEngineService) {
|
||||
return globalContextEngineService
|
||||
}
|
||||
globalContextEngineService = new ContextEngineService(config, logger)
|
||||
return globalContextEngineService
|
||||
}
|
||||
|
||||
export async function shutdownContextEngineService(): Promise<void> {
|
||||
if (globalContextEngineService) {
|
||||
await globalContextEngineService.stop()
|
||||
globalContextEngineService = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user