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:
+79
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user