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:
pnpm buildsucceeds with no errorspnpm test:runpasses all tests- Model router tries fallback on primary failure
- Session messages persist across restarts
- Hook engine classifies tools correctly
- Telegram shows confirmation buttons for sensitive operations
- Ollama integration works when Ollama is running
Manual Testing Steps
- Ensure Ollama is running with a model:
ollama run llama3.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] - Start Flynn:
pnpm dev - Send a message - verify response
- Stop Flynn (Ctrl+C), start again
- Send
/status- verify session persists - Unset ANTHROPIC_API_KEY temporarily - verify fallback to OpenAI/Ollama