# Flynn Phase 2 Implementation Plan > **Archived (2026-02-18):** Historical implementation checklist. Canonical status is tracked in `docs/plans/state.json`; unchecked boxes here are not active backlog unless explicitly re-opened. > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add model routing with fallback chain, hook engine for sensitive operations, session persistence, and local LLM support via Ollama. **Architecture:** Extend the existing daemon with a ModelRouter that selects between providers (Anthropic, OpenAI, Gemini, Ollama), a HookEngine that intercepts tool calls and requests Telegram confirmation for sensitive operations, and SQLite-based session persistence. **Tech Stack:** TypeScript, better-sqlite3, ollama (npm package), openai (npm package) --- ## Task 1: Add New Dependencies **Files:** - Modify: `package.json` **Step 1: Add dependencies** Add to package.json dependencies: ```json "better-sqlite3": "^11.0.0", "ollama": "^0.5.0", "openai": "^4.0.0" ``` Add to devDependencies: ```json "@types/better-sqlite3": "^7.6.0" ``` **Step 2: Install dependencies** Run: `pnpm install` **Step 3: Commit** ```bash git add package.json pnpm-lock.yaml git commit -m "chore: add dependencies for phase 2 (sqlite, ollama, openai)" ``` --- ## Task 2: OpenAI Client (Fallback Provider) **Files:** - Create: `src/models/openai.ts` - Modify: `src/models/index.ts` - Test: `src/models/openai.test.ts` **Step 1: Write failing test** Create `src/models/openai.test.ts`: ```typescript import { describe, it, expect, vi } from 'vitest'; import { OpenAIClient } from './openai.js'; vi.mock('openai', () => ({ default: vi.fn().mockImplementation(() => ({ chat: { completions: { create: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5 }, }), }, }, })), })); describe('OpenAIClient', () => { it('sends messages and returns response', async () => { const client = new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); const response = await client.chat({ messages: [{ role: 'user', content: 'Hello' }], }); expect(response.content).toBe('Hello from GPT!'); expect(response.stopReason).toBe('stop'); expect(response.usage.inputTokens).toBe(10); expect(response.usage.outputTokens).toBe(5); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/models/openai.test.ts` **Step 3: Implement OpenAI client** Create `src/models/openai.ts`: ```typescript import OpenAI from 'openai'; import type { ChatRequest, ChatResponse, ModelClient } from './types.js'; export interface OpenAIClientConfig { apiKey?: string; model: string; maxTokens?: number; baseURL?: string; } export class OpenAIClient implements ModelClient { private client: OpenAI; private model: string; private defaultMaxTokens: number; constructor(config: OpenAIClientConfig) { this.client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL, }); this.model = config.model; this.defaultMaxTokens = config.maxTokens ?? 4096; } async chat(request: ChatRequest): Promise { const messages: OpenAI.ChatCompletionMessageParam[] = []; if (request.system) { messages.push({ role: 'system', content: request.system }); } for (const msg of request.messages) { messages.push({ role: msg.role, content: msg.content }); } const response = await this.client.chat.completions.create({ model: this.model, max_tokens: request.maxTokens ?? this.defaultMaxTokens, messages, }); const choice = response.choices[0]; const content = choice?.message?.content ?? ''; return { content, stopReason: choice?.finish_reason ?? 'stop', usage: { inputTokens: response.usage?.prompt_tokens ?? 0, outputTokens: response.usage?.completion_tokens ?? 0, }, }; } } ``` **Step 4: Update models index** Add to `src/models/index.ts`: ```typescript export { OpenAIClient, type OpenAIClientConfig } from './openai.js'; ``` **Step 5: Run test to verify it passes** Run: `pnpm test:run src/models/openai.test.ts` **Step 6: Commit** ```bash git add src/models/openai.ts src/models/openai.test.ts src/models/index.ts git commit -m "feat: add OpenAI client for fallback support" ``` --- ## Task 3: Ollama Client (Local LLM) **Files:** - Create: `src/models/local/ollama.ts` - Create: `src/models/local/index.ts` - Modify: `src/models/index.ts` - Test: `src/models/local/ollama.test.ts` **Step 1: Write failing test** Create `src/models/local/ollama.test.ts`: ```typescript import { describe, it, expect, vi } from 'vitest'; import { OllamaClient } from './ollama.js'; vi.mock('ollama', () => ({ Ollama: vi.fn().mockImplementation(() => ({ chat: vi.fn().mockResolvedValue({ message: { content: 'Hello from Ollama!' }, done_reason: 'stop', prompt_eval_count: 10, eval_count: 5, }), })), })); describe('OllamaClient', () => { it('sends messages and returns response', async () => { const client = new OllamaClient({ model: 'llama3.2', }); const response = await client.chat({ messages: [{ role: 'user', content: 'Hello' }], }); expect(response.content).toBe('Hello from Ollama!'); expect(response.stopReason).toBe('stop'); expect(response.usage.inputTokens).toBe(10); expect(response.usage.outputTokens).toBe(5); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/models/local/ollama.test.ts` **Step 3: Implement Ollama client** Create `src/models/local/ollama.ts`: ```typescript import { Ollama } from 'ollama'; import type { ChatRequest, ChatResponse, ModelClient } from '../types.js'; export interface OllamaClientConfig { host?: string; model: string; } export class OllamaClient implements ModelClient { private client: Ollama; private model: string; constructor(config: OllamaClientConfig) { this.client = new Ollama({ host: config.host ?? 'http://localhost:11434', }); this.model = config.model; } async chat(request: ChatRequest): Promise { const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []; if (request.system) { messages.push({ role: 'system', content: request.system }); } for (const msg of request.messages) { messages.push({ role: msg.role, content: msg.content }); } const response = await this.client.chat({ model: this.model, messages, }); return { content: response.message.content, stopReason: response.done_reason ?? 'stop', usage: { inputTokens: response.prompt_eval_count ?? 0, outputTokens: response.eval_count ?? 0, }, }; } } ``` **Step 4: Create local index** Create `src/models/local/index.ts`: ```typescript export { OllamaClient, type OllamaClientConfig } from './ollama.js'; ``` **Step 5: Update models index** Add to `src/models/index.ts`: ```typescript export { OllamaClient, type OllamaClientConfig } from './local/index.js'; ``` **Step 6: Run test to verify it passes** Run: `pnpm test:run src/models/local/ollama.test.ts` **Step 7: Commit** ```bash git add src/models/local/ src/models/index.ts git commit -m "feat: add Ollama client for local LLM support" ``` --- ## Task 4: Model Router **Files:** - Create: `src/models/router.ts` - Modify: `src/models/index.ts` - Test: `src/models/router.test.ts` **Step 1: Write failing test** Create `src/models/router.test.ts`: ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ModelRouter } from './router.js'; import type { ModelClient, ChatResponse } from './types.js'; describe('ModelRouter', () => { const createMockClient = (name: string, shouldFail = false): ModelClient => ({ chat: vi.fn().mockImplementation(async () => { if (shouldFail) { throw new Error(`${name} failed`); } return { content: `Response from ${name}`, stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 5 }, } satisfies ChatResponse; }), }); it('uses default client when available', async () => { const defaultClient = createMockClient('default'); const router = new ModelRouter({ default: defaultClient, fallbackChain: [], }); const response = await router.chat({ messages: [{ role: 'user', content: 'Hi' }] }); expect(response.content).toBe('Response from default'); expect(defaultClient.chat).toHaveBeenCalled(); }); it('falls back to next provider on failure', async () => { const failingClient = createMockClient('primary', true); const fallbackClient = createMockClient('fallback'); const router = new ModelRouter({ default: failingClient, fallbackChain: [fallbackClient], }); const response = await router.chat({ messages: [{ role: 'user', content: 'Hi' }] }); expect(response.content).toBe('Response from fallback'); expect(failingClient.chat).toHaveBeenCalled(); expect(fallbackClient.chat).toHaveBeenCalled(); }); it('throws when all providers fail', async () => { const failing1 = createMockClient('primary', true); const failing2 = createMockClient('fallback', true); const router = new ModelRouter({ default: failing1, fallbackChain: [failing2], }); await expect(router.chat({ messages: [{ role: 'user', content: 'Hi' }] })) .rejects.toThrow('All model providers failed'); }); it('uses tier-specific client when specified', async () => { const defaultClient = createMockClient('default'); const fastClient = createMockClient('fast'); const router = new ModelRouter({ default: defaultClient, fast: fastClient, fallbackChain: [], }); const response = await router.chat( { messages: [{ role: 'user', content: 'Hi' }] }, 'fast' ); expect(response.content).toBe('Response from fast'); expect(fastClient.chat).toHaveBeenCalled(); expect(defaultClient.chat).not.toHaveBeenCalled(); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/models/router.test.ts` **Step 3: Implement model router** Create `src/models/router.ts`: ```typescript import type { ChatRequest, ChatResponse, ModelClient } from './types.js'; export type ModelTier = 'fast' | 'default' | 'complex' | 'local'; export interface ModelRouterConfig { default: ModelClient; fast?: ModelClient; complex?: ModelClient; local?: ModelClient; fallbackChain: ModelClient[]; } export class ModelRouter implements ModelClient { private clients: Map; private defaultClient: ModelClient; private fallbackChain: ModelClient[]; constructor(config: ModelRouterConfig) { this.clients = new Map(); this.defaultClient = config.default; this.fallbackChain = config.fallbackChain; this.clients.set('default', config.default); if (config.fast) this.clients.set('fast', config.fast); if (config.complex) this.clients.set('complex', config.complex); if (config.local) this.clients.set('local', config.local); } async chat(request: ChatRequest, tier?: ModelTier): Promise { const primaryClient = tier ? this.clients.get(tier) ?? this.defaultClient : this.defaultClient; const errors: Error[] = []; // Try primary client try { return await primaryClient.chat(request); } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); console.warn(`Primary model failed: ${errors[0].message}`); } // Try fallback chain for (const fallbackClient of this.fallbackChain) { try { console.log('Trying fallback model...'); return await fallbackClient.chat(request); } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); console.warn(`Fallback model failed: ${errors[errors.length - 1].message}`); } } throw new Error(`All model providers failed: ${errors.map(e => e.message).join(', ')}`); } getClient(tier: ModelTier): ModelClient | undefined { return this.clients.get(tier); } } ``` **Step 4: Update models index** Add to `src/models/index.ts`: ```typescript export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js'; ``` **Step 5: Run test to verify it passes** Run: `pnpm test:run src/models/router.test.ts` **Step 6: Commit** ```bash git add src/models/router.ts src/models/router.test.ts src/models/index.ts git commit -m "feat: add model router with fallback chain support" ``` --- ## Task 5: Session Persistence (SQLite) **Files:** - Create: `src/session/store.ts` - Create: `src/session/index.ts` - Test: `src/session/store.test.ts` **Step 1: Write failing test** Create `src/session/store.test.ts`: ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { SessionStore } from './store.js'; import { unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; describe('SessionStore', () => { const dbPath = join(tmpdir(), 'flynn-test-sessions.db'); let store: SessionStore; beforeEach(() => { store = new SessionStore(dbPath); }); afterEach(() => { store.close(); if (existsSync(dbPath)) { unlinkSync(dbPath); } }); it('saves and retrieves messages', () => { const sessionId = 'test-session'; store.addMessage(sessionId, { role: 'user', content: 'Hello' }); store.addMessage(sessionId, { role: 'assistant', content: 'Hi there!' }); const messages = store.getMessages(sessionId); expect(messages).toHaveLength(2); expect(messages[0].role).toBe('user'); expect(messages[0].content).toBe('Hello'); expect(messages[1].role).toBe('assistant'); expect(messages[1].content).toBe('Hi there!'); }); it('clears session messages', () => { const sessionId = 'test-session'; store.addMessage(sessionId, { role: 'user', content: 'Hello' }); store.clearSession(sessionId); const messages = store.getMessages(sessionId); expect(messages).toHaveLength(0); }); it('handles multiple sessions independently', () => { store.addMessage('session-1', { role: 'user', content: 'Session 1' }); store.addMessage('session-2', { role: 'user', content: 'Session 2' }); expect(store.getMessages('session-1')).toHaveLength(1); expect(store.getMessages('session-2')).toHaveLength(1); expect(store.getMessages('session-1')[0].content).toBe('Session 1'); }); it('lists all sessions', () => { store.addMessage('session-a', { role: 'user', content: 'A' }); store.addMessage('session-b', { role: 'user', content: 'B' }); const sessions = store.listSessions(); expect(sessions).toContain('session-a'); expect(sessions).toContain('session-b'); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/session/store.test.ts` **Step 3: Implement session store** Create `src/session/store.ts`: ```typescript import Database from 'better-sqlite3'; import type { Message } from '../models/types.js'; export class SessionStore { private db: Database.Database; constructor(dbPath: string) { this.db = new Database(dbPath); this.init(); } private init(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); `); } addMessage(sessionId: string, message: Message): void { const stmt = this.db.prepare( 'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)' ); stmt.run(sessionId, message.role, message.content); } getMessages(sessionId: string): Message[] { const stmt = this.db.prepare( 'SELECT role, content FROM messages WHERE session_id = ? ORDER BY id ASC' ); const rows = stmt.all(sessionId) as Array<{ role: string; content: string }>; return rows.map(row => ({ role: row.role as 'user' | 'assistant', content: row.content, })); } clearSession(sessionId: string): void { const stmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?'); stmt.run(sessionId); } listSessions(): string[] { const stmt = this.db.prepare('SELECT DISTINCT session_id FROM messages'); const rows = stmt.all() as Array<{ session_id: string }>; return rows.map(row => row.session_id); } close(): void { this.db.close(); } } ``` **Step 4: Create session index** Create `src/session/index.ts`: ```typescript export { SessionStore } from './store.js'; ``` **Step 5: Run test to verify it passes** Run: `pnpm test:run src/session/store.test.ts` **Step 6: Commit** ```bash git add src/session/ git commit -m "feat: add SQLite session persistence" ``` --- ## Task 6: Hook Engine **Files:** - Create: `src/hooks/types.ts` - Create: `src/hooks/engine.ts` - Create: `src/hooks/index.ts` - Test: `src/hooks/engine.test.ts` **Step 1: Create hook types** Create `src/hooks/types.ts`: ```typescript export type HookAction = 'confirm' | 'log' | 'silent'; export interface HookResult { approved: boolean; reason?: string; } export interface PendingConfirmation { id: string; tool: string; args: Record; resolve: (result: HookResult) => void; createdAt: Date; } export interface HookConfig { confirm: string[]; log: string[]; silent: string[]; } ``` **Step 2: Write failing test** Create `src/hooks/engine.test.ts`: ```typescript import { describe, it, expect, vi } from 'vitest'; import { HookEngine } from './engine.js'; describe('HookEngine', () => { it('returns silent action for non-matching tools', async () => { const engine = new HookEngine({ confirm: ['shell.*'], log: ['web.*'], silent: [], }); const action = engine.getAction('unknown.tool'); expect(action).toBe('silent'); }); it('returns confirm action for matching confirm patterns', () => { const engine = new HookEngine({ confirm: ['shell.*', 'file.write'], log: [], silent: [], }); expect(engine.getAction('shell.exec')).toBe('confirm'); expect(engine.getAction('shell.run')).toBe('confirm'); expect(engine.getAction('file.write')).toBe('confirm'); expect(engine.getAction('file.read')).toBe('silent'); }); it('returns log action for matching log patterns', () => { const engine = new HookEngine({ confirm: [], log: ['web.*'], silent: [], }); expect(engine.getAction('web.fetch')).toBe('log'); expect(engine.getAction('web.search')).toBe('log'); }); it('queues confirmation and resolves when approved', async () => { const engine = new HookEngine({ confirm: ['shell.*'], log: [], silent: [], }); const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'ls' }); const pending = engine.getPendingConfirmations(); expect(pending).toHaveLength(1); expect(pending[0].tool).toBe('shell.exec'); engine.resolveConfirmation(pending[0].id, { approved: true }); const result = await confirmPromise; expect(result.approved).toBe(true); }); it('resolves with denied when rejected', async () => { const engine = new HookEngine({ confirm: ['shell.*'], log: [], silent: [], }); const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'rm -rf' }); const pending = engine.getPendingConfirmations(); engine.resolveConfirmation(pending[0].id, { approved: false, reason: 'Too dangerous' }); const result = await confirmPromise; expect(result.approved).toBe(false); expect(result.reason).toBe('Too dangerous'); }); }); ``` **Step 3: Run test to verify it fails** Run: `pnpm test:run src/hooks/engine.test.ts` **Step 4: Implement hook engine** Create `src/hooks/engine.ts`: ```typescript import { randomUUID } from 'crypto'; import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; export class HookEngine { private confirmPatterns: RegExp[]; private logPatterns: RegExp[]; private pendingConfirmations: Map = new Map(); constructor(config: HookConfig) { this.confirmPatterns = config.confirm.map(p => this.patternToRegex(p)); this.logPatterns = config.log.map(p => this.patternToRegex(p)); } private patternToRegex(pattern: string): RegExp { // Convert glob-like patterns to regex // shell.* -> ^shell\..*$ // file.write -> ^file\.write$ const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*'); return new RegExp(`^${escaped}$`); } getAction(tool: string): HookAction { if (this.confirmPatterns.some(p => p.test(tool))) { return 'confirm'; } if (this.logPatterns.some(p => p.test(tool))) { return 'log'; } return 'silent'; } async requestConfirmation(tool: string, args: Record): Promise { const id = randomUUID(); return new Promise((resolve) => { const pending: PendingConfirmation = { id, tool, args, resolve, createdAt: new Date(), }; this.pendingConfirmations.set(id, pending); }); } resolveConfirmation(id: string, result: HookResult): boolean { const pending = this.pendingConfirmations.get(id); if (!pending) { return false; } pending.resolve(result); this.pendingConfirmations.delete(id); return true; } getPendingConfirmations(): PendingConfirmation[] { return Array.from(this.pendingConfirmations.values()); } clearExpiredConfirmations(maxAgeMs: number = 5 * 60 * 1000): number { const now = Date.now(); let cleared = 0; for (const [id, pending] of this.pendingConfirmations) { if (now - pending.createdAt.getTime() > maxAgeMs) { pending.resolve({ approved: false, reason: 'Confirmation timed out' }); this.pendingConfirmations.delete(id); cleared++; } } return cleared; } } ``` **Step 5: Create hooks index** Create `src/hooks/index.ts`: ```typescript export { HookEngine } from './engine.js'; export type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; ``` **Step 6: Run test to verify it passes** Run: `pnpm test:run src/hooks/engine.test.ts` **Step 7: Commit** ```bash git add src/hooks/ git commit -m "feat: add hook engine for sensitive operation confirmation" ``` --- ## Task 7: Telegram Hook Confirmation UI **Files:** - Create: `src/frontends/telegram/confirmations.ts` - Modify: `src/frontends/telegram/bot.ts` - Modify: `src/frontends/telegram/index.ts` - Test: `src/frontends/telegram/confirmations.test.ts` **Step 1: Write failing test** Create `src/frontends/telegram/confirmations.test.ts`: ```typescript import { describe, it, expect } from 'vitest'; import { formatConfirmationMessage, parseConfirmationCallback } from './confirmations.js'; describe('formatConfirmationMessage', () => { it('formats tool and args into readable message', () => { const message = formatConfirmationMessage('shell.exec', { cmd: 'ls -la' }); expect(message).toContain('shell.exec'); expect(message).toContain('ls -la'); }); }); describe('parseConfirmationCallback', () => { it('parses approve callback data', () => { const result = parseConfirmationCallback('confirm:abc123:approve'); expect(result).toEqual({ id: 'abc123', approved: true, }); }); it('parses deny callback data', () => { const result = parseConfirmationCallback('confirm:abc123:deny'); expect(result).toEqual({ id: 'abc123', approved: false, }); }); it('returns null for invalid callback data', () => { expect(parseConfirmationCallback('invalid')).toBeNull(); expect(parseConfirmationCallback('other:data')).toBeNull(); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/frontends/telegram/confirmations.test.ts` **Step 3: Implement confirmations module** Create `src/frontends/telegram/confirmations.ts`: ```typescript import { InlineKeyboard } from 'grammy'; export function formatConfirmationMessage(tool: string, args: Record): string { const argsStr = Object.entries(args) .map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`) .join('\n'); return `🔐 **Confirmation Required** Tool: \`${tool}\` Arguments: ${argsStr || ' (none)'} Approve this action?`; } export function createConfirmationKeyboard(confirmationId: string): InlineKeyboard { return new InlineKeyboard() .text('✅ Approve', `confirm:${confirmationId}:approve`) .text('❌ Deny', `confirm:${confirmationId}:deny`); } export interface ConfirmationCallbackData { id: string; approved: boolean; } export function parseConfirmationCallback(data: string): ConfirmationCallbackData | null { const parts = data.split(':'); if (parts.length !== 3 || parts[0] !== 'confirm') { return null; } const [, id, action] = parts; if (action !== 'approve' && action !== 'deny') { return null; } return { id, approved: action === 'approve', }; } ``` **Step 4: Run test to verify it passes** Run: `pnpm test:run src/frontends/telegram/confirmations.test.ts` **Step 5: Update telegram index** Add to `src/frontends/telegram/index.ts`: ```typescript export { formatConfirmationMessage, createConfirmationKeyboard, parseConfirmationCallback, type ConfirmationCallbackData, } from './confirmations.js'; ``` **Step 6: Commit** ```bash git add src/frontends/telegram/confirmations.ts src/frontends/telegram/confirmations.test.ts src/frontends/telegram/index.ts git commit -m "feat: add Telegram confirmation UI components" ``` --- ## Task 8: Integrate Components into Daemon **Files:** - Modify: `src/daemon/index.ts` - Modify: `src/frontends/telegram/bot.ts` - Modify: `src/backends/native/agent.ts` **Step 1: Update native agent to use session store** Modify `src/backends/native/agent.ts`: ```typescript 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 { 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, }); 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[] { return [...this.history]; } } ``` **Step 2: Update daemon to use model router and session store** Modify `src/daemon/index.ts`: ```typescript import { Bot } from 'grammy'; import { Lifecycle } from './lifecycle.js'; import type { Config } from '../config/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(); // 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 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: modelRouter, systemPrompt: SYSTEM_PROMPT, sessionStore, sessionId: `telegram-${config.telegram.allowed_chat_ids[0]}`, }); // Initialize Telegram bot with hook engine const bot = createTelegramBot({ telegram: config.telegram, agent, hookEngine, }); // Register signal handlers const signalHandler = () => { lifecycle.shutdown().then(() => process.exit(0)); }; process.on('SIGINT', signalHandler); process.on('SIGTERM', signalHandler); lifecycle.onShutdown(async () => { process.off('SIGINT', signalHandler); process.off('SIGTERM', signalHandler); }); // Start bot lifecycle.onShutdown(async () => { await bot.stop(); console.log('Telegram bot stopped'); }); bot.start({ onStart: (botInfo) => { console.log(`Telegram bot started: @${botInfo.username}`); }, }); console.log('Flynn daemon started'); return { config, lifecycle, bot, agent, sessionStore, hookEngine, modelRouter }; } export { Lifecycle } from './lifecycle.js'; ``` **Step 3: Update telegram bot to handle confirmations** Modify `src/frontends/telegram/bot.ts` to add hook engine and callback query handler: ```typescript 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 { formatConfirmationMessage, createConfirmationKeyboard, parseConfirmationCallback, } from './confirmations.js'; export interface TelegramBotConfig { telegram: TelegramConfig; agent: NativeAgent; hookEngine?: HookEngine; } export function createTelegramBot(config: TelegramBotConfig): Bot { const bot = new Bot(config.telegram.bot_token); 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) => { const chatId = ctx.chat?.id; if (chatId === undefined || !isAllowedChat(chatId, allowedChatIds)) { console.log(`Rejected message from unauthorized chat: ${chatId}`); return; } 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!'); }); bot.command('reset', async (ctx) => { handleReset(); await ctx.reply('Conversation reset.'); }); bot.command('status', async (ctx) => { const pending = hookEngine?.getPendingConfirmations() ?? []; const statusMsg = `Flynn is running.\nPending confirmations: ${pending.length}`; await ctx.reply(statusMsg); }); // Message handler bot.on('message:text', async (ctx) => { const text = ctx.message.text; await ctx.replyWithChatAction('typing'); try { const response = await handleMessage(text); if (response.length <= 4096) { await ctx.reply(response, { parse_mode: 'Markdown' }); } else { const chunks = splitMessage(response, 4096); for (const chunk of chunks) { await ctx.reply(chunk, { parse_mode: 'Markdown' }); } } } catch (error) { console.error('Error processing message:', error); await ctx.reply('Sorry, an error occurred while processing your message.'); } }); return bot; } function splitMessage(text: string, maxLength: number): string[] { const chunks: string[] = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= maxLength) { chunks.push(remaining); break; } let splitIndex = remaining.lastIndexOf('\n', maxLength); if (splitIndex === -1 || splitIndex < maxLength / 2) { splitIndex = remaining.lastIndexOf(' ', maxLength); } if (splitIndex === -1 || splitIndex < maxLength / 2) { splitIndex = maxLength; } chunks.push(remaining.slice(0, splitIndex)); remaining = remaining.slice(splitIndex).trimStart(); } return chunks; } ``` **Step 4: Verify build** Run: `pnpm build` **Step 5: Run all tests** Run: `pnpm test:run` **Step 6: Commit** ```bash git add src/daemon/index.ts src/frontends/telegram/bot.ts src/backends/native/agent.ts git commit -m "feat: integrate model router, session persistence, and hook engine" ``` --- ## Verification Checklist After completing all tasks, verify: 1. [ ] `pnpm build` succeeds with no errors 2. [ ] `pnpm test:run` passes all tests 3. [ ] Model router tries fallback on primary failure 4. [ ] Session messages persist across restarts 5. [ ] Hook engine classifies tools correctly 6. [ ] Telegram shows confirmation buttons for sensitive operations 7. [ ] Ollama integration works when Ollama is running ## Manual Testing Steps 1. Ensure Ollama is running with a model: `ollama run llama3.2` 2. Update config to include local model: ```yaml models: local: provider: ollama model: llama3.2 default: provider: anthropic model: claude-sonnet-4-20250514 fallback_chain: [openai, local] ``` 3. Start Flynn: `pnpm dev` 4. Send a message - verify response 5. Stop Flynn (Ctrl+C), start again 6. Send `/status` - verify session persists 7. Unset ANTHROPIC_API_KEY temporarily - verify fallback to OpenAI/Ollama