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

1327 lines
32 KiB
Markdown

# 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<typeof configSchema>;
export type TelegramConfig = z.infer<typeof telegramSchema>;
export type ModelConfig = z.infer<typeof modelConfigSchema>;
```
**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<string, unknown> = {};
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<void>;
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<void> {
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<DaemonContext> {
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<void>((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<ChatResponse>;
}
```
**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<ChatResponse> {
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<string> {
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<string> {
return async (text: string): Promise<string> => {
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<DaemonContext> {
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<void>((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