feat: integrate model router, session persistence, and hook engine

- NativeAgent now loads/saves messages to SessionStore
- Daemon creates ModelRouter with fallback chain support
- Telegram bot handles confirmation callbacks from HookEngine
- Session data stored in ~/.local/share/flynn/sessions.db

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-05 00:05:42 -08:00
parent 26bd6ce65d
commit 6e6c263e14
3 changed files with 143 additions and 12 deletions
+27 -2
View File
@@ -1,35 +1,60 @@
import type { ModelClient, Message } from '../../models/types.js';
import type { SessionStore } from '../../session/index.js';
export interface NativeAgentConfig {
modelClient: ModelClient;
systemPrompt: string;
sessionStore?: SessionStore;
sessionId?: string;
}
export class NativeAgent {
private modelClient: ModelClient;
private systemPrompt: string;
private sessionStore?: SessionStore;
private sessionId: string;
private history: Message[] = [];
constructor(config: NativeAgentConfig) {
this.modelClient = config.modelClient;
this.systemPrompt = config.systemPrompt;
this.sessionStore = config.sessionStore;
this.sessionId = config.sessionId ?? 'default';
// Load existing history from store
if (this.sessionStore) {
this.history = this.sessionStore.getMessages(this.sessionId);
}
}
async process(userMessage: string): Promise<string> {
this.history.push({ role: 'user', content: userMessage });
const userMsg: Message = { role: 'user', content: userMessage };
this.history.push(userMsg);
if (this.sessionStore) {
this.sessionStore.addMessage(this.sessionId, userMsg);
}
const response = await this.modelClient.chat({
messages: [...this.history],
system: this.systemPrompt,
});
this.history.push({ role: 'assistant', content: response.content });
const assistantMsg: Message = { role: 'assistant', content: response.content };
this.history.push(assistantMsg);
if (this.sessionStore) {
this.sessionStore.addMessage(this.sessionId, assistantMsg);
}
return response.content;
}
reset(): void {
this.history = [];
if (this.sessionStore) {
this.sessionStore.clearSession(this.sessionId);
}
}
getHistory(): Message[] {
+80 -8
View File
@@ -1,39 +1,111 @@
import { Bot } from 'grammy';
import { Lifecycle } from './lifecycle.js';
import type { Config } from '../config/index.js';
import { AnthropicClient } from '../models/index.js';
import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from '../models/index.js';
import { NativeAgent } from '../backends/index.js';
import { createTelegramBot } from '../frontends/telegram/index.js';
import { SessionStore } from '../session/index.js';
import { HookEngine } from '../hooks/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import { mkdirSync } from 'fs';
export interface DaemonContext {
config: Config;
lifecycle: Lifecycle;
bot: Bot;
agent: NativeAgent;
sessionStore: SessionStore;
hookEngine: HookEngine;
modelRouter: ModelRouter;
}
const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations.
Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`;
function createModelRouter(config: Config): ModelRouter {
const models = config.models;
// Create default client (required)
const defaultClient = new AnthropicClient({
model: models.default.model,
});
// Create optional tier clients
let fastClient;
let complexClient;
let localClient;
if (models.fast) {
fastClient = new AnthropicClient({ model: models.fast.model });
}
if (models.complex) {
complexClient = new AnthropicClient({ model: models.complex.model });
}
if (models.local) {
if (models.local.provider === 'ollama') {
localClient = new OllamaClient({
model: models.local.model,
host: models.local.endpoint,
});
}
}
// Build fallback chain
const fallbackChain = [];
for (const providerName of models.fallback_chain) {
if (providerName === 'openai') {
fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' }));
} else if (providerName === 'local' && localClient) {
fallbackChain.push(localClient);
}
}
return new ModelRouter({
default: defaultClient,
fast: fastClient,
complex: complexClient,
local: localClient,
fallbackChain,
});
}
export async function startDaemon(config: Config): Promise<DaemonContext> {
const lifecycle = new Lifecycle();
// Initialize model client
const modelClient = new AnthropicClient({
model: config.models.default.model,
// Ensure data directory exists
const dataDir = resolve(homedir(), '.local/share/flynn');
mkdirSync(dataDir, { recursive: true });
// Initialize session store
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
lifecycle.onShutdown(async () => {
sessionStore.close();
console.log('Session store closed');
});
// Initialize native agent
// Initialize hook engine
const hookEngine = new HookEngine(config.hooks);
// Initialize model router
const modelRouter = createModelRouter(config);
// Initialize native agent with session persistence
const agent = new NativeAgent({
modelClient,
modelClient: modelRouter,
systemPrompt: SYSTEM_PROMPT,
sessionStore,
sessionId: `telegram-${config.telegram.allowed_chat_ids[0]}`,
});
// Initialize Telegram bot
// Initialize Telegram bot with hook engine
const bot = createTelegramBot({
telegram: config.telegram,
agent,
hookEngine,
});
// Register signal handlers
@@ -64,7 +136,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
console.log('Flynn daemon started');
return { config, lifecycle, bot, agent };
return { config, lifecycle, bot, agent, sessionStore, hookEngine, modelRouter };
}
export { Lifecycle } from './lifecycle.js';
+36 -2
View File
@@ -1,11 +1,14 @@
import { Bot, Context } from 'grammy';
import { Bot } from 'grammy';
import type { NativeAgent } from '../../backends/index.js';
import type { TelegramConfig } from '../../config/index.js';
import type { HookEngine } from '../../hooks/index.js';
import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';
import { parseConfirmationCallback } from './confirmations.js';
export interface TelegramBotConfig {
telegram: TelegramConfig;
agent: NativeAgent;
hookEngine?: HookEngine;
}
export function createTelegramBot(config: TelegramBotConfig): Bot {
@@ -13,6 +16,7 @@ export function createTelegramBot(config: TelegramBotConfig): Bot {
const handleMessage = createMessageHandler(config.agent);
const handleReset = createResetHandler(config.agent);
const allowedChatIds = config.telegram.allowed_chat_ids;
const hookEngine = config.hookEngine;
// Middleware to check chat ID
bot.use(async (ctx, next) => {
@@ -24,6 +28,34 @@ export function createTelegramBot(config: TelegramBotConfig): Bot {
await next();
});
// Handle confirmation callbacks
bot.on('callback_query:data', async (ctx) => {
const data = ctx.callbackQuery.data;
const parsed = parseConfirmationCallback(data);
if (!parsed || !hookEngine) {
await ctx.answerCallbackQuery({ text: 'Invalid action' });
return;
}
const resolved = hookEngine.resolveConfirmation(parsed.id, {
approved: parsed.approved,
reason: parsed.approved ? undefined : 'Denied by user',
});
if (resolved) {
await ctx.answerCallbackQuery({
text: parsed.approved ? '✅ Approved' : '❌ Denied',
});
await ctx.editMessageText(
ctx.callbackQuery.message?.text + `\n\n${parsed.approved ? '✅ Approved' : '❌ Denied'}`,
{ parse_mode: 'Markdown' }
);
} else {
await ctx.answerCallbackQuery({ text: 'Confirmation expired or not found' });
}
});
// Command handlers
bot.command('start', async (ctx) => {
await ctx.reply('Flynn is ready. Send me a message!');
@@ -35,7 +67,9 @@ export function createTelegramBot(config: TelegramBotConfig): Bot {
});
bot.command('status', async (ctx) => {
await ctx.reply('Flynn is running.');
const pending = hookEngine?.getPendingConfirmations() ?? [];
const statusMsg = `Flynn is running.\nPending confirmations: ${pending.length}`;
await ctx.reply(statusMsg);
});
// Message handler