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
+79 -2
View File
@@ -12,10 +12,12 @@ import { HookEngine } from '../hooks/index.js';
import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from '../tools/index.js';
import type { Tool } from '../tools/types.js';
import { MemoryStore } from '../memory/index.js';
import { VectorStore, HybridSearch, createEmbeddingProvider, chunkText, contentHash } from '../memory/index.js';
import type { EmbeddingProvider as EmbeddingProviderInterface } from '../memory/index.js';
import { createMemoryTools } from '../tools/builtin/index.js';
import { GatewayServer } from '../gateway/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/index.js';
import { CronScheduler, WebhookHandler } from '../automation/index.js';
import { CronScheduler, WebhookHandler, HeartbeatMonitor } from '../automation/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { McpManager } from '../mcp/index.js';
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
@@ -568,8 +570,65 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
}
// Register memory tools if memory is enabled
let hybridSearch: HybridSearch | undefined;
if (memoryStore && config.memory.embedding.enabled) {
try {
const embeddingProvider: EmbeddingProviderInterface = createEmbeddingProvider(config.memory.embedding);
const vectorStore = new VectorStore(resolve(dataDir, 'vectors.db'));
hybridSearch = new HybridSearch(
memoryStore,
vectorStore,
embeddingProvider,
config.memory.embedding.hybrid_weight,
);
// Background indexer: re-embed dirty namespaces every 30 seconds
const indexerInterval = setInterval(async () => {
const dirtyNamespaces = memoryStore.getDirtyNamespaces();
for (const ns of dirtyNamespaces) {
try {
const content = memoryStore.read(ns);
if (content.length === 0) {
vectorStore.deleteNamespace(ns);
continue;
}
const hash = contentHash(content);
if (vectorStore.hasContentHash(ns, hash)) continue;
const chunks = chunkText(content, ns, {
chunkSize: config.memory.embedding.chunk_size,
chunkOverlap: config.memory.embedding.chunk_overlap,
});
if (chunks.length > 0) {
const embeddings = await embeddingProvider.embed(chunks.map((c) => c.text));
vectorStore.upsertChunks(chunks, embeddings, hash);
}
} catch (err) {
console.error(`Failed to index namespace "${ns}":`, err);
}
}
}, 30_000);
// Initial full index — mark all existing namespaces as dirty
memoryStore.markAllDirty();
lifecycle.onShutdown(async () => {
clearInterval(indexerInterval);
vectorStore.close();
console.log('Vector store closed');
});
console.log(`Vector memory search enabled (provider=${config.memory.embedding.provider}, model=${config.memory.embedding.model})`);
} catch (err) {
console.error('Failed to initialize vector search:', err);
}
}
if (memoryStore) {
for (const tool of createMemoryTools(memoryStore)) {
for (const tool of createMemoryTools(memoryStore, hybridSearch)) {
toolRegistry.register(tool);
}
}
@@ -878,6 +937,24 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
await gateway.start();
// ── Heartbeat Monitor ──────────────────────────────────────────
const heartbeatMonitor = new HeartbeatMonitor({
config: config.automation.heartbeat,
getGatewayPort: () => config.server.port,
modelRouter,
channelLister: channelRegistry,
memoryDir: config.memory.enabled ? memoryDir : undefined,
dataDir,
channelLookup: channelRegistry,
});
heartbeatMonitor.start();
lifecycle.onShutdown(async () => {
heartbeatMonitor.stop();
console.log('Heartbeat monitor stopped');
});
console.log('Flynn daemon started');
return {