04fa56d3f1
8 tasks covering: - Project scaffolding - Config schema and loading - Daemon lifecycle management - Anthropic client wrapper - Native agent with conversation history - Telegram bot frontend - Integration wiring - Documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1321 lines
32 KiB
Markdown
1321 lines
32 KiB
Markdown
# 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<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
|