diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index ac540c3..8630b54 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -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 { - 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[] { diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 3b3a264..b9dc44d 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 { 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 { console.log('Flynn daemon started'); - return { config, lifecycle, bot, agent }; + return { config, lifecycle, bot, agent, sessionStore, hookEngine, modelRouter }; } export { Lifecycle } from './lifecycle.js'; diff --git a/src/frontends/telegram/bot.ts b/src/frontends/telegram/bot.ts index c1f1199..5ada652 100644 --- a/src/frontends/telegram/bot.ts +++ b/src/frontends/telegram/bot.ts @@ -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