feat: add heartbeat monitor and vector memory search (Tier 2)

Heartbeat:
- HeartbeatMonitor with 5 checks: gateway, model, channels, memory, disk
- Configurable interval, failure threshold, notification channel
- Recovery notifications when health restores
- 25 new tests

Vector Memory Search:
- EmbeddingProvider interface with OpenAI, Gemini, Ollama, LlamaCpp backends
- SQLite-backed VectorStore with cosine similarity search
- Text chunker with paragraph-aware splitting and overlap
- HybridSearch merging keyword + vector results with configurable weight
- Background indexer with dirty-namespace tracking
- Graceful fallback to keyword search when embeddings unavailable
- 51 new tests

Config: automation.heartbeat + memory.embedding schema sections
Total: 950 tests passing, all types clean
This commit is contained in:
William Valentin
2026-02-07 14:45:11 -08:00
parent b50c140d25
commit 88731a50e3
17 changed files with 2354 additions and 7 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* Embedding provider interface and implementations for multiple backends.
*/
import type { EmbeddingConfig } from '../config/schema.js';
/**
* Interface for embedding providers that convert text to vectors.
*/
export interface EmbeddingProvider {
/** Generate embeddings for one or more texts. Returns one vector per text. */
embed(texts: string[]): Promise<number[][]>;
/** The dimensionality of the embedding vectors. */
dimensions: number;
}
// ---------------------------------------------------------------------------
// OpenAI
// ---------------------------------------------------------------------------
export class OpenAIEmbeddingProvider implements EmbeddingProvider {
private _model: string;
private _dimensions: number;
private _apiKey: string;
private _endpoint?: string;
constructor(config: EmbeddingConfig) {
this._model = config.model;
this._dimensions = config.dimensions ?? 1536;
this._apiKey = config.api_key ?? process.env.OPENAI_API_KEY ?? '';
this._endpoint = config.endpoint;
}
get dimensions(): number {
return this._dimensions;
}
async embed(texts: string[]): Promise<number[][]> {
const { default: OpenAI } = await import('openai');
const client = new OpenAI({
apiKey: this._apiKey,
...(this._endpoint ? { baseURL: this._endpoint } : {}),
});
const response = await client.embeddings.create({
model: this._model,
input: texts,
...(this._dimensions ? { dimensions: this._dimensions } : {}),
});
// Sort by index to ensure order matches input
const sorted = response.data.sort((a, b) => a.index - b.index);
return sorted.map((item) => item.embedding);
}
}
// ---------------------------------------------------------------------------
// Gemini
// ---------------------------------------------------------------------------
export class GeminiEmbeddingProvider implements EmbeddingProvider {
private _model: string;
private _dimensions: number;
private _apiKey: string;
constructor(config: EmbeddingConfig) {
this._model = config.model;
this._dimensions = config.dimensions ?? 768;
this._apiKey = config.api_key ?? process.env.GOOGLE_API_KEY ?? '';
}
get dimensions(): number {
return this._dimensions;
}
async embed(texts: string[]): Promise<number[][]> {
const { GoogleGenerativeAI } = await import('@google/generative-ai');
const genAI = new GoogleGenerativeAI(this._apiKey);
const model = genAI.getGenerativeModel({ model: this._model });
// Use batchEmbedContents for efficiency
const requests = texts.map((text) => ({
content: { role: 'user' as const, parts: [{ text }] },
}));
const response = await model.batchEmbedContents({ requests });
return response.embeddings.map((e) => e.values);
}
}
// ---------------------------------------------------------------------------
// Ollama
// ---------------------------------------------------------------------------
export class OllamaEmbeddingProvider implements EmbeddingProvider {
private _model: string;
private _dimensions: number;
private _host?: string;
constructor(config: EmbeddingConfig) {
this._model = config.model;
this._dimensions = config.dimensions ?? 768;
this._host = config.endpoint;
}
get dimensions(): number {
return this._dimensions;
}
async embed(texts: string[]): Promise<number[][]> {
const { Ollama } = await import('ollama');
const client = new Ollama({ host: this._host });
const response = await client.embed({
model: this._model,
input: texts,
});
return response.embeddings;
}
}
// ---------------------------------------------------------------------------
// LlamaCpp
// ---------------------------------------------------------------------------
export class LlamaCppEmbeddingProvider implements EmbeddingProvider {
private _dimensions: number;
private _endpoint: string;
constructor(config: EmbeddingConfig) {
this._dimensions = config.dimensions ?? 768;
this._endpoint = config.endpoint ?? 'http://localhost:8080';
}
get dimensions(): number {
return this._dimensions;
}
async embed(texts: string[]): Promise<number[][]> {
const results: number[][] = [];
for (const text of texts) {
const response = await fetch(`${this._endpoint}/embedding`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
if (!response.ok) {
throw new Error(`LlamaCpp embedding request failed: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { embedding: number[] };
results.push(data.embedding);
}
return results;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create an embedding provider from config.
*/
export function createEmbeddingProvider(config: EmbeddingConfig): EmbeddingProvider {
switch (config.provider) {
case 'openai':
return new OpenAIEmbeddingProvider(config);
case 'gemini':
return new GeminiEmbeddingProvider(config);
case 'ollama':
return new OllamaEmbeddingProvider(config);
case 'llamacpp':
return new LlamaCppEmbeddingProvider(config);
default:
throw new Error(`Unknown embedding provider: ${(config as Record<string, unknown>).provider}`);
}
}