diff --git a/docs/plans/2026-02-02-flynn-phase1-implementation.md b/docs/plans/2026-02-02-flynn-phase1-implementation.md new file mode 100644 index 0000000..6589ae9 --- /dev/null +++ b/docs/plans/2026-02-02-flynn-phase1-implementation.md @@ -0,0 +1,1320 @@ +# Flynn Phase 1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build the foundation - a working daemon that receives Telegram messages and responds via native agent using Anthropic API. + +**Architecture:** TypeScript/Node.js daemon with grammY Telegram bot, YAML config loading, and a minimal native agent that calls Claude Sonnet. The daemon exposes an internal WebSocket API for future frontends. + +**Tech Stack:** TypeScript, Node.js 22+, pnpm, grammY, @anthropic-ai/sdk, yaml, zod (config validation) + +--- + +## Task 1: Project Scaffolding + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `.gitignore` +- Create: `src/index.ts` + +**Step 1: Initialize package.json** + +```json +{ + "name": "flynn", + "version": "0.1.0", + "description": "Self-hosted personal AI agent", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "vitest", + "test:run": "vitest run", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "keywords": ["ai", "agent", "telegram", "personal-assistant"], + "license": "MIT", + "devDependencies": { + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "tsx": "^4.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "grammy": "^1.35.0", + "yaml": "^2.7.0", + "zod": "^3.24.0" + }, + "engines": { + "node": ">=22.0.0" + } +} +``` + +**Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +**Step 3: Create .gitignore** + +``` +node_modules/ +dist/ +*.log +.env +.env.* +!.env.example +``` + +**Step 4: Create minimal src/index.ts** + +```typescript +console.log('Flynn starting...'); +``` + +**Step 5: Install dependencies** + +Run: `pnpm install` +Expected: Dependencies installed, lockfile created + +**Step 6: Verify build** + +Run: `pnpm build` +Expected: `dist/index.js` created + +**Step 7: Commit** + +```bash +git add package.json tsconfig.json .gitignore src/index.ts pnpm-lock.yaml +git commit -m "chore: initialize project scaffolding" +``` + +--- + +## Task 2: Config Schema and Loading + +**Files:** +- Create: `src/config/schema.ts` +- Create: `src/config/loader.ts` +- Create: `src/config/index.ts` +- Create: `config/default.yaml` +- Test: `src/config/loader.test.ts` + +**Step 1: Write failing test for config loading** + +Create `src/config/loader.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { loadConfig } from './loader.js'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('loadConfig', () => { + const testDir = join(tmpdir(), 'flynn-test-config'); + + it('loads valid config from file', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123456789] +server: + port: 18800 +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const config = loadConfig(configPath); + + expect(config.telegram.bot_token).toBe('test-token'); + expect(config.telegram.allowed_chat_ids).toContain(123456789); + expect(config.server.port).toBe(18800); + expect(config.models.default.provider).toBe('anthropic'); + + rmSync(testDir, { recursive: true }); + }); + + it('expands environment variables', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + process.env.TEST_BOT_TOKEN = 'env-token-value'; + writeFileSync(configPath, ` +telegram: + bot_token: \${TEST_BOT_TOKEN} + allowed_chat_ids: [123] +server: + port: 18800 +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const config = loadConfig(configPath); + + expect(config.telegram.bot_token).toBe('env-token-value'); + + delete process.env.TEST_BOT_TOKEN; + rmSync(testDir, { recursive: true }); + }); + + it('throws on invalid config', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "" +`); + + expect(() => loadConfig(configPath)).toThrow(); + + rmSync(testDir, { recursive: true }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/config/loader.test.ts` +Expected: FAIL - module not found + +**Step 3: Create config schema** + +Create `src/config/schema.ts`: + +```typescript +import { z } from 'zod'; + +const telegramSchema = z.object({ + bot_token: z.string().min(1, 'Bot token is required'), + allowed_chat_ids: z.array(z.number()).min(1, 'At least one chat ID required'), +}); + +const serverSchema = z.object({ + tailscale_only: z.boolean().default(true), + localhost: z.boolean().default(true), + port: z.number().default(18800), +}); + +const modelConfigSchema = z.object({ + provider: z.enum(['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp']), + model: z.string(), + endpoint: z.string().optional(), + for: z.array(z.string()).optional(), +}); + +const modelsSchema = z.object({ + local: modelConfigSchema.optional(), + fast: modelConfigSchema.optional(), + default: modelConfigSchema, + complex: modelConfigSchema.optional(), + fallback_chain: z.array(z.string()).default(['anthropic']), +}); + +const backendsSchema = z.object({ + claude_code: z.object({ + enabled: z.boolean().default(false), + path: z.string().optional(), + }).default({ enabled: false }), + opencode: z.object({ + enabled: z.boolean().default(false), + path: z.string().optional(), + }).default({ enabled: false }), + native: z.object({ + enabled: z.boolean().default(true), + }).default({ enabled: true }), +}).default({}); + +const hooksSchema = z.object({ + confirm: z.array(z.string()).default([]), + log: z.array(z.string()).default([]), + silent: z.array(z.string()).default([]), +}).default({}); + +const mcpServerSchema = z.object({ + name: z.string(), + command: z.string(), + args: z.array(z.string()).default([]), +}); + +const mcpSchema = z.object({ + servers: z.array(mcpServerSchema).default([]), +}).default({ servers: [] }); + +export const configSchema = z.object({ + telegram: telegramSchema, + server: serverSchema.default({}), + models: modelsSchema, + backends: backendsSchema.default({}), + hooks: hooksSchema.default({}), + mcp: mcpSchema.default({ servers: [] }), +}); + +export type Config = z.infer; +export type TelegramConfig = z.infer; +export type ModelConfig = z.infer; +``` + +**Step 4: Create config loader** + +Create `src/config/loader.ts`: + +```typescript +import { readFileSync } from 'fs'; +import { parse } from 'yaml'; +import { configSchema, type Config } from './schema.js'; + +function expandEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (envValue === undefined) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +function expandEnvVarsInObject(obj: unknown): unknown { + if (typeof obj === 'string') { + return expandEnvVars(obj); + } + if (Array.isArray(obj)) { + return obj.map(expandEnvVarsInObject); + } + if (obj !== null && typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = expandEnvVarsInObject(value); + } + return result; + } + return obj; +} + +export function loadConfig(configPath: string): Config { + const rawContent = readFileSync(configPath, 'utf-8'); + const rawConfig = parse(rawContent); + const expandedConfig = expandEnvVarsInObject(rawConfig); + return configSchema.parse(expandedConfig); +} +``` + +**Step 5: Create config index** + +Create `src/config/index.ts`: + +```typescript +export { loadConfig } from './loader.js'; +export { configSchema, type Config, type TelegramConfig, type ModelConfig } from './schema.js'; +``` + +**Step 6: Run test to verify it passes** + +Run: `pnpm test:run src/config/loader.test.ts` +Expected: PASS + +**Step 7: Create default config template** + +Create `config/default.yaml`: + +```yaml +# Flynn Configuration +# Copy to ~/.config/flynn/config.yaml and customize + +telegram: + bot_token: ${FLYNN_TELEGRAM_TOKEN} + allowed_chat_ids: [] # Add your Telegram chat ID + +server: + tailscale_only: true + localhost: true + port: 18800 + +models: + default: + provider: anthropic + model: claude-sonnet-4-20250514 + +hooks: + confirm: + - shell.* + - file.write + log: + - web.* + - file.read + silent: + - notify +``` + +**Step 8: Commit** + +```bash +git add src/config/ config/default.yaml +git commit -m "feat: add config schema and loader with env var expansion" +``` + +--- + +## Task 3: Daemon Skeleton with Graceful Shutdown + +**Files:** +- Create: `src/daemon/index.ts` +- Create: `src/daemon/lifecycle.ts` +- Modify: `src/index.ts` +- Test: `src/daemon/lifecycle.test.ts` + +**Step 1: Write failing test for lifecycle** + +Create `src/daemon/lifecycle.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { Lifecycle } from './lifecycle.js'; + +describe('Lifecycle', () => { + it('registers and calls shutdown handlers in reverse order', async () => { + const lifecycle = new Lifecycle(); + const calls: number[] = []; + + lifecycle.onShutdown(async () => { calls.push(1); }); + lifecycle.onShutdown(async () => { calls.push(2); }); + lifecycle.onShutdown(async () => { calls.push(3); }); + + await lifecycle.shutdown(); + + expect(calls).toEqual([3, 2, 1]); + }); + + it('only shuts down once', async () => { + const lifecycle = new Lifecycle(); + let count = 0; + + lifecycle.onShutdown(async () => { count++; }); + + await lifecycle.shutdown(); + await lifecycle.shutdown(); + + expect(count).toBe(1); + }); + + it('reports running state', async () => { + const lifecycle = new Lifecycle(); + + expect(lifecycle.isRunning).toBe(true); + + await lifecycle.shutdown(); + + expect(lifecycle.isRunning).toBe(false); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/daemon/lifecycle.test.ts` +Expected: FAIL - module not found + +**Step 3: Implement lifecycle manager** + +Create `src/daemon/lifecycle.ts`: + +```typescript +type ShutdownHandler = () => Promise; + +export class Lifecycle { + private shutdownHandlers: ShutdownHandler[] = []; + private shuttingDown = false; + private _isRunning = true; + + get isRunning(): boolean { + return this._isRunning; + } + + onShutdown(handler: ShutdownHandler): void { + this.shutdownHandlers.push(handler); + } + + async shutdown(): Promise { + if (this.shuttingDown) return; + this.shuttingDown = true; + this._isRunning = false; + + console.log('Shutting down...'); + + // Execute handlers in reverse order (LIFO) + for (const handler of [...this.shutdownHandlers].reverse()) { + try { + await handler(); + } catch (error) { + console.error('Shutdown handler error:', error); + } + } + + console.log('Shutdown complete'); + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test:run src/daemon/lifecycle.test.ts` +Expected: PASS + +**Step 5: Create daemon entry** + +Create `src/daemon/index.ts`: + +```typescript +import { Lifecycle } from './lifecycle.js'; +import type { Config } from '../config/index.js'; + +export interface DaemonContext { + config: Config; + lifecycle: Lifecycle; +} + +export async function startDaemon(config: Config): Promise { + const lifecycle = new Lifecycle(); + + // 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); + }); + + console.log('Flynn daemon started'); + + return { config, lifecycle }; +} + +export { Lifecycle } from './lifecycle.js'; +``` + +**Step 6: Update main entry** + +Replace `src/index.ts`: + +```typescript +import { loadConfig } from './config/index.js'; +import { startDaemon } from './daemon/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +async function main() { + console.log('Flynn starting...'); + console.log(`Loading config from: ${CONFIG_PATH}`); + + try { + const config = loadConfig(CONFIG_PATH); + const daemon = await startDaemon(config); + + console.log(`Telegram bot configured for chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + console.log(`Server port: ${config.server.port}`); + + // Keep process alive + await new Promise((resolve) => { + daemon.lifecycle.onShutdown(async () => resolve()); + }); + } catch (error) { + console.error('Failed to start Flynn:', error); + process.exit(1); + } +} + +main(); +``` + +**Step 7: Verify build** + +Run: `pnpm build` +Expected: No errors + +**Step 8: Commit** + +```bash +git add src/daemon/ src/index.ts +git commit -m "feat: add daemon skeleton with lifecycle management" +``` + +--- + +## Task 4: Anthropic Client Wrapper + +**Files:** +- Create: `src/models/anthropic.ts` +- Create: `src/models/types.ts` +- Create: `src/models/index.ts` +- Test: `src/models/anthropic.test.ts` + +**Step 1: Create types** + +Create `src/models/types.ts`: + +```typescript +export interface Message { + role: 'user' | 'assistant'; + content: string; +} + +export interface ChatRequest { + messages: Message[]; + system?: string; + maxTokens?: number; +} + +export interface ChatResponse { + content: string; + stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | string; + usage: { + inputTokens: number; + outputTokens: number; + }; +} + +export interface ModelClient { + chat(request: ChatRequest): Promise; +} +``` + +**Step 2: Write failing test** + +Create `src/models/anthropic.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AnthropicClient } from './anthropic.js'; + +// Mock the SDK +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn().mockImplementation(() => ({ + messages: { + create: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Hello from Claude!' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }, + })), +})); + +describe('AnthropicClient', () => { + it('sends messages and returns response', async () => { + const client = new AnthropicClient({ + apiKey: 'test-key', + model: 'claude-sonnet-4-20250514', + }); + + const response = await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(response.content).toBe('Hello from Claude!'); + expect(response.stopReason).toBe('end_turn'); + expect(response.usage.inputTokens).toBe(10); + expect(response.usage.outputTokens).toBe(5); + }); +}); +``` + +**Step 3: Run test to verify it fails** + +Run: `pnpm test:run src/models/anthropic.test.ts` +Expected: FAIL - module not found + +**Step 4: Implement Anthropic client** + +Create `src/models/anthropic.ts`: + +```typescript +import Anthropic from '@anthropic-ai/sdk'; +import type { ChatRequest, ChatResponse, ModelClient } from './types.js'; + +export interface AnthropicClientConfig { + apiKey?: string; // Falls back to ANTHROPIC_API_KEY env var + model: string; + maxTokens?: number; +} + +export class AnthropicClient implements ModelClient { + private client: Anthropic; + private model: string; + private defaultMaxTokens: number; + + constructor(config: AnthropicClientConfig) { + this.client = new Anthropic({ + apiKey: config.apiKey, + }); + this.model = config.model; + this.defaultMaxTokens = config.maxTokens ?? 4096; + } + + async chat(request: ChatRequest): Promise { + const response = await this.client.messages.create({ + model: this.model, + max_tokens: request.maxTokens ?? this.defaultMaxTokens, + system: request.system, + messages: request.messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }); + + const textContent = response.content.find((c) => c.type === 'text'); + const content = textContent?.type === 'text' ? textContent.text : ''; + + return { + content, + stopReason: response.stop_reason ?? 'end_turn', + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } +} +``` + +**Step 5: Create models index** + +Create `src/models/index.ts`: + +```typescript +export { AnthropicClient, type AnthropicClientConfig } from './anthropic.js'; +export type { Message, ChatRequest, ChatResponse, ModelClient } from './types.js'; +``` + +**Step 6: Run test to verify it passes** + +Run: `pnpm test:run src/models/anthropic.test.ts` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/models/ +git commit -m "feat: add Anthropic client wrapper" +``` + +--- + +## Task 5: Native Agent + +**Files:** +- Create: `src/backends/native/agent.ts` +- Create: `src/backends/native/index.ts` +- Create: `src/backends/index.ts` +- Test: `src/backends/native/agent.test.ts` + +**Step 1: Write failing test** + +Create `src/backends/native/agent.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { NativeAgent } from './agent.js'; +import type { ModelClient, ChatResponse } from '../../models/types.js'; + +describe('NativeAgent', () => { + it('processes message and returns response', async () => { + const mockClient: ModelClient = { + chat: vi.fn().mockResolvedValue({ + content: 'Hello! How can I help you?', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 8 }, + } satisfies ChatResponse), + }; + + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'You are Flynn, a helpful assistant.', + }); + + const response = await agent.process('Hello'); + + expect(response).toBe('Hello! How can I help you?'); + expect(mockClient.chat).toHaveBeenCalledWith({ + messages: [{ role: 'user', content: 'Hello' }], + system: 'You are Flynn, a helpful assistant.', + }); + }); + + it('maintains conversation history', async () => { + const mockClient: ModelClient = { + chat: vi.fn().mockResolvedValue({ + content: 'Response', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + } satisfies ChatResponse), + }; + + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'System', + }); + + await agent.process('First message'); + await agent.process('Second message'); + + expect(mockClient.chat).toHaveBeenLastCalledWith({ + messages: [ + { role: 'user', content: 'First message' }, + { role: 'assistant', content: 'Response' }, + { role: 'user', content: 'Second message' }, + ], + system: 'System', + }); + }); + + it('resets conversation history', async () => { + const mockClient: ModelClient = { + chat: vi.fn().mockResolvedValue({ + content: 'Response', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + } satisfies ChatResponse), + }; + + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'System', + }); + + await agent.process('Message 1'); + agent.reset(); + await agent.process('Message 2'); + + expect(mockClient.chat).toHaveBeenLastCalledWith({ + messages: [{ role: 'user', content: 'Message 2' }], + system: 'System', + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/backends/native/agent.test.ts` +Expected: FAIL - module not found + +**Step 3: Implement native agent** + +Create `src/backends/native/agent.ts`: + +```typescript +import type { ModelClient, Message } from '../../models/types.js'; + +export interface NativeAgentConfig { + modelClient: ModelClient; + systemPrompt: string; +} + +export class NativeAgent { + private modelClient: ModelClient; + private systemPrompt: string; + private history: Message[] = []; + + constructor(config: NativeAgentConfig) { + this.modelClient = config.modelClient; + this.systemPrompt = config.systemPrompt; + } + + async process(userMessage: string): Promise { + this.history.push({ role: 'user', content: userMessage }); + + const response = await this.modelClient.chat({ + messages: [...this.history], + system: this.systemPrompt, + }); + + this.history.push({ role: 'assistant', content: response.content }); + + return response.content; + } + + reset(): void { + this.history = []; + } + + getHistory(): Message[] { + return [...this.history]; + } +} +``` + +**Step 4: Create native index** + +Create `src/backends/native/index.ts`: + +```typescript +export { NativeAgent, type NativeAgentConfig } from './agent.js'; +``` + +**Step 5: Create backends index** + +Create `src/backends/index.ts`: + +```typescript +export { NativeAgent, type NativeAgentConfig } from './native/index.js'; +``` + +**Step 6: Run test to verify it passes** + +Run: `pnpm test:run src/backends/native/agent.test.ts` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/backends/ +git commit -m "feat: add native agent with conversation history" +``` + +--- + +## Task 6: Telegram Bot Frontend + +**Files:** +- Create: `src/frontends/telegram/bot.ts` +- Create: `src/frontends/telegram/handlers.ts` +- Create: `src/frontends/telegram/index.ts` +- Test: `src/frontends/telegram/handlers.test.ts` + +**Step 1: Write failing test for handlers** + +Create `src/frontends/telegram/handlers.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { createMessageHandler, isAllowedChat } from './handlers.js'; +import type { NativeAgent } from '../../backends/native/agent.js'; + +describe('isAllowedChat', () => { + it('returns true for allowed chat ID', () => { + expect(isAllowedChat(123, [123, 456])).toBe(true); + }); + + it('returns false for disallowed chat ID', () => { + expect(isAllowedChat(789, [123, 456])).toBe(false); + }); +}); + +describe('createMessageHandler', () => { + it('processes message and returns response', async () => { + const mockAgent: NativeAgent = { + process: vi.fn().mockResolvedValue('Agent response'), + reset: vi.fn(), + getHistory: vi.fn(), + } as unknown as NativeAgent; + + const handler = createMessageHandler(mockAgent); + const response = await handler('Hello'); + + expect(response).toBe('Agent response'); + expect(mockAgent.process).toHaveBeenCalledWith('Hello'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/frontends/telegram/handlers.test.ts` +Expected: FAIL - module not found + +**Step 3: Implement handlers** + +Create `src/frontends/telegram/handlers.ts`: + +```typescript +import type { NativeAgent } from '../../backends/index.js'; + +export function isAllowedChat(chatId: number, allowedIds: number[]): boolean { + return allowedIds.includes(chatId); +} + +export function createMessageHandler(agent: NativeAgent): (text: string) => Promise { + return async (text: string): Promise => { + return agent.process(text); + }; +} + +export function createResetHandler(agent: NativeAgent): () => void { + return (): void => { + agent.reset(); + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test:run src/frontends/telegram/handlers.test.ts` +Expected: PASS + +**Step 5: Implement Telegram bot** + +Create `src/frontends/telegram/bot.ts`: + +```typescript +import { Bot, Context } from 'grammy'; +import type { NativeAgent } from '../../backends/index.js'; +import type { TelegramConfig } from '../../config/index.js'; +import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js'; + +export interface TelegramBotConfig { + telegram: TelegramConfig; + agent: NativeAgent; +} + +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; + + // 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(); + }); + + // 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) => { + await ctx.reply('Flynn is running.'); + }); + + // Message handler + bot.on('message:text', async (ctx) => { + const text = ctx.message.text; + + // Show typing indicator + await ctx.replyWithChatAction('typing'); + + try { + const response = await handleMessage(text); + + // Telegram has a 4096 character limit per message + if (response.length <= 4096) { + await ctx.reply(response, { parse_mode: 'Markdown' }); + } else { + // Split into chunks + 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; + } + + // Try to split at a newline + let splitIndex = remaining.lastIndexOf('\n', maxLength); + if (splitIndex === -1 || splitIndex < maxLength / 2) { + // Fall back to splitting at space + splitIndex = remaining.lastIndexOf(' ', maxLength); + } + if (splitIndex === -1 || splitIndex < maxLength / 2) { + // Hard split + splitIndex = maxLength; + } + + chunks.push(remaining.slice(0, splitIndex)); + remaining = remaining.slice(splitIndex).trimStart(); + } + + return chunks; +} +``` + +**Step 6: Create telegram index** + +Create `src/frontends/telegram/index.ts`: + +```typescript +export { createTelegramBot, type TelegramBotConfig } from './bot.js'; +export { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js'; +``` + +**Step 7: Commit** + +```bash +git add src/frontends/ +git commit -m "feat: add Telegram bot frontend with message handling" +``` + +--- + +## Task 7: Wire Everything Together + +**Files:** +- Modify: `src/daemon/index.ts` +- Modify: `src/index.ts` + +**Step 1: Update daemon to initialize components** + +Replace `src/daemon/index.ts`: + +```typescript +import { Bot } from 'grammy'; +import { Lifecycle } from './lifecycle.js'; +import type { Config } from '../config/index.js'; +import { AnthropicClient } from '../models/index.js'; +import { NativeAgent } from '../backends/index.js'; +import { createTelegramBot } from '../frontends/telegram/index.js'; + +export interface DaemonContext { + config: Config; + lifecycle: Lifecycle; + bot: Bot; + agent: NativeAgent; +} + +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.`; + +export async function startDaemon(config: Config): Promise { + const lifecycle = new Lifecycle(); + + // Initialize model client + const modelClient = new AnthropicClient({ + model: config.models.default.model, + }); + + // Initialize native agent + const agent = new NativeAgent({ + modelClient, + systemPrompt: SYSTEM_PROMPT, + }); + + // Initialize Telegram bot + const bot = createTelegramBot({ + telegram: config.telegram, + agent, + }); + + // 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'); + }); + + // Use long polling (no webhook, no internet exposure) + bot.start({ + onStart: (botInfo) => { + console.log(`Telegram bot started: @${botInfo.username}`); + }, + }); + + console.log('Flynn daemon started'); + + return { config, lifecycle, bot, agent }; +} + +export { Lifecycle } from './lifecycle.js'; +``` + +**Step 2: Update main entry** + +Replace `src/index.ts`: + +```typescript +import { loadConfig } from './config/index.js'; +import { startDaemon } from './daemon/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { existsSync } from 'fs'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +async function main() { + console.log('Flynn starting...'); + console.log(`Loading config from: ${CONFIG_PATH}`); + + if (!existsSync(CONFIG_PATH)) { + console.error(`Config file not found: ${CONFIG_PATH}`); + console.error('Copy config/default.yaml to ~/.config/flynn/config.yaml and configure it.'); + process.exit(1); + } + + try { + const config = loadConfig(CONFIG_PATH); + const daemon = await startDaemon(config); + + console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + + // Keep process alive + await new Promise((resolve) => { + daemon.lifecycle.onShutdown(async () => resolve()); + }); + } catch (error) { + console.error('Failed to start Flynn:', error); + process.exit(1); + } +} + +main(); +``` + +**Step 3: Verify build** + +Run: `pnpm build` +Expected: No errors + +**Step 4: Run all tests** + +Run: `pnpm test:run` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add src/daemon/index.ts src/index.ts +git commit -m "feat: wire daemon, agent, and telegram bot together" +``` + +--- + +## Task 8: Integration Test & Documentation + +**Files:** +- Create: `.env.example` +- Modify: `config/default.yaml` + +**Step 1: Create .env.example** + +Create `.env.example`: + +```bash +# Telegram Bot Token from @BotFather +FLYNN_TELEGRAM_TOKEN=your-bot-token-here + +# Anthropic API Key +ANTHROPIC_API_KEY=your-anthropic-api-key-here + +# Optional: Custom config path +# FLYNN_CONFIG=/path/to/config.yaml +``` + +**Step 2: Verify final build** + +Run: `pnpm build && pnpm test:run` +Expected: Build succeeds, all tests pass + +**Step 3: Final commit** + +```bash +git add .env.example +git commit -m "docs: add environment variable example" +``` + +--- + +## Verification Checklist + +After completing all tasks, verify: + +1. [ ] `pnpm build` succeeds with no errors +2. [ ] `pnpm test:run` passes all tests +3. [ ] `pnpm lint` passes (if eslint configured) +4. [ ] Config loads from `~/.config/flynn/config.yaml` +5. [ ] Environment variables are expanded in config +6. [ ] Bot starts and responds to `/start` command +7. [ ] Only allowed chat IDs can interact +8. [ ] Messages are processed by native agent +9. [ ] `/reset` clears conversation history +10. [ ] Graceful shutdown on SIGINT/SIGTERM + +## Manual Testing Steps + +1. Create `~/.config/flynn/config.yaml` from `config/default.yaml` +2. Set `FLYNN_TELEGRAM_TOKEN` and `ANTHROPIC_API_KEY` +3. Add your Telegram chat ID to `allowed_chat_ids` +4. Run `pnpm dev` +5. Send `/start` to your bot +6. Send a test message +7. Verify response from Claude +8. Send `/reset` to clear history +9. Press Ctrl+C to verify graceful shutdown