# Flynn Phase 1 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:** 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