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:
@@ -1,35 +1,60 @@
|
|||||||
import type { ModelClient, Message } from '../../models/types.js';
|
import type { ModelClient, Message } from '../../models/types.js';
|
||||||
|
import type { SessionStore } from '../../session/index.js';
|
||||||
|
|
||||||
export interface NativeAgentConfig {
|
export interface NativeAgentConfig {
|
||||||
modelClient: ModelClient;
|
modelClient: ModelClient;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
sessionStore?: SessionStore;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NativeAgent {
|
export class NativeAgent {
|
||||||
private modelClient: ModelClient;
|
private modelClient: ModelClient;
|
||||||
private systemPrompt: string;
|
private systemPrompt: string;
|
||||||
|
private sessionStore?: SessionStore;
|
||||||
|
private sessionId: string;
|
||||||
private history: Message[] = [];
|
private history: Message[] = [];
|
||||||
|
|
||||||
constructor(config: NativeAgentConfig) {
|
constructor(config: NativeAgentConfig) {
|
||||||
this.modelClient = config.modelClient;
|
this.modelClient = config.modelClient;
|
||||||
this.systemPrompt = config.systemPrompt;
|
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> {
|
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({
|
const response = await this.modelClient.chat({
|
||||||
messages: [...this.history],
|
messages: [...this.history],
|
||||||
system: this.systemPrompt,
|
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;
|
return response.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
if (this.sessionStore) {
|
||||||
|
this.sessionStore.clearSession(this.sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getHistory(): Message[] {
|
getHistory(): Message[] {
|
||||||
|
|||||||
+80
-8
@@ -1,39 +1,111 @@
|
|||||||
import { Bot } from 'grammy';
|
import { Bot } from 'grammy';
|
||||||
import { Lifecycle } from './lifecycle.js';
|
import { Lifecycle } from './lifecycle.js';
|
||||||
import type { Config } from '../config/index.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 { NativeAgent } from '../backends/index.js';
|
||||||
import { createTelegramBot } from '../frontends/telegram/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 {
|
export interface DaemonContext {
|
||||||
config: Config;
|
config: Config;
|
||||||
lifecycle: Lifecycle;
|
lifecycle: Lifecycle;
|
||||||
bot: Bot;
|
bot: Bot;
|
||||||
agent: NativeAgent;
|
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.
|
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.`;
|
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> {
|
export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||||
const lifecycle = new Lifecycle();
|
const lifecycle = new Lifecycle();
|
||||||
|
|
||||||
// Initialize model client
|
// Ensure data directory exists
|
||||||
const modelClient = new AnthropicClient({
|
const dataDir = resolve(homedir(), '.local/share/flynn');
|
||||||
model: config.models.default.model,
|
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({
|
const agent = new NativeAgent({
|
||||||
modelClient,
|
modelClient: modelRouter,
|
||||||
systemPrompt: SYSTEM_PROMPT,
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
|
sessionStore,
|
||||||
|
sessionId: `telegram-${config.telegram.allowed_chat_ids[0]}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Telegram bot
|
// Initialize Telegram bot with hook engine
|
||||||
const bot = createTelegramBot({
|
const bot = createTelegramBot({
|
||||||
telegram: config.telegram,
|
telegram: config.telegram,
|
||||||
agent,
|
agent,
|
||||||
|
hookEngine,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register signal handlers
|
// Register signal handlers
|
||||||
@@ -64,7 +136,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
|
|
||||||
console.log('Flynn daemon started');
|
console.log('Flynn daemon started');
|
||||||
|
|
||||||
return { config, lifecycle, bot, agent };
|
return { config, lifecycle, bot, agent, sessionStore, hookEngine, modelRouter };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Lifecycle } from './lifecycle.js';
|
export { Lifecycle } from './lifecycle.js';
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Bot, Context } from 'grammy';
|
import { Bot } from 'grammy';
|
||||||
import type { NativeAgent } from '../../backends/index.js';
|
import type { NativeAgent } from '../../backends/index.js';
|
||||||
import type { TelegramConfig } from '../../config/index.js';
|
import type { TelegramConfig } from '../../config/index.js';
|
||||||
|
import type { HookEngine } from '../../hooks/index.js';
|
||||||
import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';
|
import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';
|
||||||
|
import { parseConfirmationCallback } from './confirmations.js';
|
||||||
|
|
||||||
export interface TelegramBotConfig {
|
export interface TelegramBotConfig {
|
||||||
telegram: TelegramConfig;
|
telegram: TelegramConfig;
|
||||||
agent: NativeAgent;
|
agent: NativeAgent;
|
||||||
|
hookEngine?: HookEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTelegramBot(config: TelegramBotConfig): Bot {
|
export function createTelegramBot(config: TelegramBotConfig): Bot {
|
||||||
@@ -13,6 +16,7 @@ export function createTelegramBot(config: TelegramBotConfig): Bot {
|
|||||||
const handleMessage = createMessageHandler(config.agent);
|
const handleMessage = createMessageHandler(config.agent);
|
||||||
const handleReset = createResetHandler(config.agent);
|
const handleReset = createResetHandler(config.agent);
|
||||||
const allowedChatIds = config.telegram.allowed_chat_ids;
|
const allowedChatIds = config.telegram.allowed_chat_ids;
|
||||||
|
const hookEngine = config.hookEngine;
|
||||||
|
|
||||||
// Middleware to check chat ID
|
// Middleware to check chat ID
|
||||||
bot.use(async (ctx, next) => {
|
bot.use(async (ctx, next) => {
|
||||||
@@ -24,6 +28,34 @@ export function createTelegramBot(config: TelegramBotConfig): Bot {
|
|||||||
await next();
|
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
|
// Command handlers
|
||||||
bot.command('start', async (ctx) => {
|
bot.command('start', async (ctx) => {
|
||||||
await ctx.reply('Flynn is ready. Send me a message!');
|
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) => {
|
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
|
// Message handler
|
||||||
|
|||||||
Reference in New Issue
Block a user