diff --git a/docs/plans/2026-02-02-flynn-phase2-implementation.md b/docs/plans/2026-02-02-flynn-phase2-implementation.md new file mode 100644 index 0000000..e977eb7 --- /dev/null +++ b/docs/plans/2026-02-02-flynn-phase2-implementation.md @@ -0,0 +1,1426 @@ +# Flynn Phase 2 Implementation Plan + +> **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