Files
flynn/docs/plans/2026-02-02-flynn-phase2-implementation.md
T

37 KiB

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:

"better-sqlite3": "^11.0.0",
"ollama": "^0.5.0",
"openai": "^4.0.0"

Add to devDependencies:

"@types/better-sqlite3": "^7.6.0"

Step 2: Install dependencies

Run: pnpm install

Step 3: Commit

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:

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:

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:

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

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:

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:

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:

export { OllamaClient, type OllamaClientConfig } from './ollama.js';

Step 5: Update models index

Add to src/models/index.ts:

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

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:

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:

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:

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

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:

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:

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:

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

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:

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:

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:

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:

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

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:

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:

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:

export {
  formatConfirmationMessage,
  createConfirmationKeyboard,
  parseConfirmationCallback,
  type ConfirmationCallbackData,
} from './confirmations.js';

Step 6: Commit

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:

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:

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:

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

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:
    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