# 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<ChatResponse> {
    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<ChatResponse> {
    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<ModelTier, ModelClient>;
  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<ChatResponse> {
    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<string, unknown>;
  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<string, PendingConfirmation> = 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<string, unknown>): Promise<HookResult> {
    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, unknown>): 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<string> {
    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<DaemonContext> {
  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



