# Flynn Phase 3 Implementation Plan > **Archived (2026-02-18):** Historical implementation checklist. Canonical status is tracked in `docs/plans/state.json`; unchecked boxes here are not active backlog unless explicitly re-opened. > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a Terminal User Interface (TUI) frontend with minimal readline mode and full-screen panel mode, sharing sessions with the daemon. **Architecture:** The TUI connects to the same daemon components as Telegram. It has two modes: minimal (simple prompt with streaming) and fullscreen (Ink-based React UI with conversation pane and status bar). Sessions are separate per frontend but can be transferred via `/transfer` command. **Tech Stack:** TypeScript, Ink 6.x (React for CLI), ink-text-input, readline/node:readline --- ## Task 1: Add TUI Dependencies **Files:** - Modify: `package.json` **Step 1: Add dependencies** Add to package.json dependencies: ```json "ink": "^6.0.0", "ink-text-input": "^6.0.0", "react": "^19.0.0" ``` Add to devDependencies: ```json "@types/react": "^19.0.0" ``` **Step 2: Install dependencies** Run: `pnpm install` **Step 3: Commit** ```bash git add package.json pnpm-lock.yaml git commit -m "chore: add dependencies for TUI (ink, react)" ``` --- ## Task 2: Session Manager (Multi-Frontend Support) **Files:** - Create: `src/session/manager.ts` - Modify: `src/session/index.ts` - Test: `src/session/manager.test.ts` **Step 1: Write failing test** Create `src/session/manager.test.ts`: ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { SessionManager } from './manager.js'; import { SessionStore } from './store.js'; import { unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; describe('SessionManager', () => { const dbPath = join(tmpdir(), 'flynn-test-manager.db'); let store: SessionStore; let manager: SessionManager; beforeEach(() => { store = new SessionStore(dbPath); manager = new SessionManager(store); }); afterEach(() => { store.close(); if (existsSync(dbPath)) { unlinkSync(dbPath); } }); it('creates sessions for different frontends', () => { const telegramSession = manager.getSession('telegram', 'user-123'); const tuiSession = manager.getSession('tui', 'local'); expect(telegramSession.id).toBe('telegram:user-123'); expect(tuiSession.id).toBe('tui:local'); }); it('returns same session for same frontend and user', () => { const session1 = manager.getSession('telegram', 'user-123'); const session2 = manager.getSession('telegram', 'user-123'); expect(session1).toBe(session2); }); it('transfers session history between frontends', () => { const telegramSession = manager.getSession('telegram', 'user-123'); telegramSession.addMessage({ role: 'user', content: 'Hello from Telegram' }); telegramSession.addMessage({ role: 'assistant', content: 'Hi!' }); const tuiSession = manager.getSession('tui', 'local'); manager.transferSession('telegram', 'user-123', 'tui', 'local'); const tuiMessages = tuiSession.getHistory(); expect(tuiMessages).toHaveLength(2); expect(tuiMessages[0].content).toBe('Hello from Telegram'); }); it('lists active sessions', () => { manager.getSession('telegram', 'user-123'); manager.getSession('tui', 'local'); const sessions = manager.listSessions(); expect(sessions).toContain('telegram:user-123'); expect(sessions).toContain('tui:local'); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/session/manager.test.ts` **Step 3: Implement SessionManager** Create `src/session/manager.ts`: ```typescript import type { Message } from '../models/types.js'; import type { SessionStore } from './store.js'; export interface Session { id: string; addMessage(message: Message): void; getHistory(): Message[]; clear(): void; } export class ManagedSession implements Session { constructor( public readonly id: string, private store: SessionStore, private history: Message[] = [] ) {} addMessage(message: Message): void { this.history.push(message); this.store.addMessage(this.id, message); } getHistory(): Message[] { return [...this.history]; } clear(): void { this.history = []; this.store.clearSession(this.id); } setHistory(messages: Message[]): void { this.history = [...messages]; } } export class SessionManager { private sessions: Map = new Map(); constructor(private store: SessionStore) {} private makeSessionId(frontend: string, userId: string): string { return `${frontend}:${userId}`; } getSession(frontend: string, userId: string): ManagedSession { const id = this.makeSessionId(frontend, userId); let session = this.sessions.get(id); if (!session) { const history = this.store.getMessages(id); session = new ManagedSession(id, this.store, history); this.sessions.set(id, session); } return session; } transferSession( fromFrontend: string, fromUserId: string, toFrontend: string, toUserId: string ): void { const fromSession = this.getSession(fromFrontend, fromUserId); const toSession = this.getSession(toFrontend, toUserId); const history = fromSession.getHistory(); // Clear target and copy history toSession.clear(); for (const message of history) { toSession.addMessage(message); } } listSessions(): string[] { return Array.from(this.sessions.keys()); } closeSession(frontend: string, userId: string): void { const id = this.makeSessionId(frontend, userId); this.sessions.delete(id); } } ``` **Step 4: Update session index** Add to `src/session/index.ts`: ```typescript export { SessionManager, ManagedSession, type Session } from './manager.js'; ``` **Step 5: Run test to verify it passes** Run: `pnpm test:run src/session/manager.test.ts` **Step 6: Commit** ```bash git add src/session/manager.ts src/session/manager.test.ts src/session/index.ts git commit -m "feat: add SessionManager for multi-frontend session handling" ``` --- ## Task 3: Minimal TUI (Readline Mode) **Files:** - Create: `src/frontends/tui/minimal.ts` - Create: `src/frontends/tui/index.ts` - Test: `src/frontends/tui/minimal.test.ts` **Step 1: Write failing test** Create `src/frontends/tui/minimal.test.ts`: ```typescript import { describe, it, expect, vi } from 'vitest'; import { formatPrompt, parseCommand, type TuiCommand } from './minimal.js'; describe('formatPrompt', () => { it('formats default prompt', () => { const prompt = formatPrompt('default'); expect(prompt).toBe('flynn> '); }); it('formats thinking prompt', () => { const prompt = formatPrompt('thinking'); expect(prompt).toContain('...'); }); }); describe('parseCommand', () => { it('parses /quit command', () => { const result = parseCommand('/quit'); expect(result).toEqual({ type: 'quit' }); }); it('parses /reset command', () => { const result = parseCommand('/reset'); expect(result).toEqual({ type: 'reset' }); }); it('parses /transfer command with target', () => { const result = parseCommand('/transfer telegram'); expect(result).toEqual({ type: 'transfer', target: 'telegram' }); }); it('parses /fullscreen command', () => { const result = parseCommand('/fullscreen'); expect(result).toEqual({ type: 'fullscreen' }); }); it('parses regular message', () => { const result = parseCommand('Hello, Flynn!'); expect(result).toEqual({ type: 'message', content: 'Hello, Flynn!' }); }); it('returns null for empty input', () => { const result = parseCommand(''); expect(result).toBeNull(); }); }); ``` **Step 2: Run test to verify it fails** Run: `pnpm test:run src/frontends/tui/minimal.test.ts` **Step 3: Implement minimal TUI helpers** Create `src/frontends/tui/minimal.ts`: ```typescript import * as readline from 'node:readline'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient } from '../../models/types.js'; export type TuiCommand = | { type: 'quit' } | { type: 'reset' } | { type: 'transfer'; target: string } | { type: 'fullscreen' } | { type: 'status' } | { type: 'help' } | { type: 'message'; content: string }; export function formatPrompt(state: 'default' | 'thinking'): string { if (state === 'thinking') { return 'flynn... '; } return 'flynn> '; } export function parseCommand(input: string): TuiCommand | null { const trimmed = input.trim(); if (!trimmed) { return null; } if (trimmed === '/quit' || trimmed === '/exit') { return { type: 'quit' }; } if (trimmed === '/reset' || trimmed === '/clear') { return { type: 'reset' }; } if (trimmed.startsWith('/transfer ')) { const target = trimmed.slice('/transfer '.length).trim(); return { type: 'transfer', target }; } if (trimmed === '/fullscreen' || trimmed === '/fs') { return { type: 'fullscreen' }; } if (trimmed === '/status') { return { type: 'status' }; } if (trimmed === '/help' || trimmed === '/?') { return { type: 'help' }; } return { type: 'message', content: trimmed }; } export interface MinimalTuiConfig { session: ManagedSession; modelClient: ModelClient; systemPrompt: string; onFullscreen?: () => void; onTransfer?: (target: string) => void; } export class MinimalTui { private rl: readline.Interface | null = null; private running = false; constructor(private config: MinimalTuiConfig) {} async start(): Promise { this.running = true; this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log('Flynn TUI (minimal mode)'); console.log('Type /help for commands, /fullscreen for panel mode\n'); await this.promptLoop(); } private async promptLoop(): Promise { while (this.running && this.rl) { const input = await this.prompt(formatPrompt('default')); const command = parseCommand(input); if (!command) { continue; } await this.handleCommand(command); } } private prompt(promptText: string): Promise { return new Promise((resolve) => { this.rl?.question(promptText, resolve); }); } private async handleCommand(command: TuiCommand): Promise { switch (command.type) { case 'quit': this.stop(); break; case 'reset': this.config.session.clear(); console.log('Session cleared.\n'); break; case 'transfer': this.config.onTransfer?.(command.target); break; case 'fullscreen': this.config.onFullscreen?.(); break; case 'status': console.log(`Session: ${this.config.session.id}`); console.log(`Messages: ${this.config.session.getHistory().length}\n`); break; case 'help': this.printHelp(); break; case 'message': await this.handleMessage(command.content); break; } } private async handleMessage(content: string): Promise { this.config.session.addMessage({ role: 'user', content }); process.stdout.write('\n'); try { const response = await this.config.modelClient.chat({ messages: this.config.session.getHistory(), system: this.config.systemPrompt, }); console.log(response.content); console.log(); this.config.session.addMessage({ role: 'assistant', content: response.content }); } catch (error) { console.error('Error:', error instanceof Error ? error.message : error); console.log(); } } private printHelp(): void { console.log(` Commands: /help, /? Show this help /reset, /clear Clear conversation history /status Show session info /fullscreen, /fs Switch to fullscreen mode /transfer Transfer session to another frontend /quit, /exit Exit TUI `); } stop(): void { this.running = false; this.rl?.close(); this.rl = null; } } ``` **Step 4: Create TUI index** Create `src/frontends/tui/index.ts`: ```typescript export { MinimalTui, formatPrompt, parseCommand, type TuiCommand, type MinimalTuiConfig, } from './minimal.js'; ``` **Step 5: Run test to verify it passes** Run: `pnpm test:run src/frontends/tui/minimal.test.ts` **Step 6: Commit** ```bash git add src/frontends/tui/ git commit -m "feat: add minimal TUI with readline interface" ``` --- ## Task 4: TUI Entry Point **Files:** - Create: `src/tui.ts` - Modify: `package.json` **Step 1: Create TUI entry point** Create `src/tui.ts`: ```typescript import { loadConfig } from './config/index.js'; import { SessionStore, SessionManager } from './session/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from './models/index.js'; import { MinimalTui } from './frontends/tui/index.js'; import type { Config } from './config/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; import { existsSync, mkdirSync } from 'fs'; const CONFIG_PATH = process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml'); const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; function createModelRouter(config: Config): ModelRouter { const models = config.models; const defaultClient = new AnthropicClient({ model: models.default.model, }); let fastClient; let complexClient; let localClient; if (models.fast) { fastClient = new AnthropicClient({ model: models.fast.model }); } if (models.complex) { complexClient = new AnthropicClient({ model: models.complex.model }); } if (models.local) { if (models.local.provider === 'ollama') { localClient = new OllamaClient({ model: models.local.model, host: models.local.endpoint, }); } } const fallbackChain = []; for (const providerName of models.fallback_chain) { if (providerName === 'openai') { fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); } else if (providerName === 'local' && localClient) { fallbackChain.push(localClient); } } return new ModelRouter({ default: defaultClient, fast: fastClient, complex: complexClient, local: localClient, fallbackChain, }); } async function main() { console.log('Flynn TUI starting...'); 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); } const config = loadConfig(CONFIG_PATH); // Ensure data directory exists const dataDir = resolve(homedir(), '.local/share/flynn'); mkdirSync(dataDir, { recursive: true }); // Initialize components const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); const sessionManager = new SessionManager(sessionStore); const modelRouter = createModelRouter(config); // Get TUI session const session = sessionManager.getSession('tui', 'local'); // Create and start minimal TUI const tui = new MinimalTui({ session, modelClient: modelRouter, systemPrompt: SYSTEM_PROMPT, onTransfer: (target) => { if (target === 'telegram') { const telegramUserId = String(config.telegram.allowed_chat_ids[0]); sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); console.log(`Session transferred to Telegram (${telegramUserId})\n`); } else { console.log(`Unknown transfer target: ${target}\n`); } }, onFullscreen: () => { console.log('Fullscreen mode not yet implemented.\n'); }, }); // Handle shutdown process.on('SIGINT', () => { tui.stop(); sessionStore.close(); process.exit(0); }); await tui.start(); // Cleanup sessionStore.close(); } main().catch((error) => { console.error('Failed to start TUI:', error); process.exit(1); }); ``` **Step 2: Add TUI script to package.json** Add to scripts section of `package.json`: ```json "tui": "tsx src/tui.ts", "tui:dev": "tsx watch src/tui.ts" ``` **Step 3: Run build to verify** Run: `pnpm build` **Step 4: Commit** ```bash git add src/tui.ts package.json git commit -m "feat: add TUI entry point with minimal readline mode" ``` --- ## Task 5: Fullscreen TUI Components (Ink) **Files:** - Create: `src/frontends/tui/components/App.tsx` - Create: `src/frontends/tui/components/MessageList.tsx` - Create: `src/frontends/tui/components/InputBar.tsx` - Create: `src/frontends/tui/components/StatusBar.tsx` - Create: `src/frontends/tui/components/index.ts` **Step 1: Create StatusBar component** Create `src/frontends/tui/components/StatusBar.tsx`: ```tsx import React from 'react'; import { Box, Text } from 'ink'; export interface StatusBarProps { sessionId: string; messageCount: number; model: string; } export function StatusBar({ sessionId, messageCount, model }: StatusBarProps): React.ReactElement { return ( Flynn | Session: {sessionId} Messages: {messageCount} | Model: {model} ); } ``` **Step 2: Create MessageList component** Create `src/frontends/tui/components/MessageList.tsx`: ```tsx import React from 'react'; import { Box, Text } from 'ink'; import type { Message } from '../../../models/types.js'; export interface MessageListProps { messages: Message[]; maxHeight?: number; } export function MessageList({ messages, maxHeight = 20 }: MessageListProps): React.ReactElement { // Show only recent messages that fit const visibleMessages = messages.slice(-maxHeight); return ( {visibleMessages.length === 0 ? ( No messages yet. Start typing to chat with Flynn. ) : ( visibleMessages.map((message, index) => ( {message.role === 'user' ? 'You: ' : 'Flynn: '} {message.content} )) )} ); } ``` **Step 3: Create InputBar component** Create `src/frontends/tui/components/InputBar.tsx`: ```tsx import React from 'react'; import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; export interface InputBarProps { value: string; onChange: (value: string) => void; onSubmit: (value: string) => void; isLoading?: boolean; placeholder?: string; } export function InputBar({ value, onChange, onSubmit, isLoading = false, placeholder = 'Type a message...', }: InputBarProps): React.ReactElement { return ( {'> '} {isLoading ? ( Thinking... ) : ( )} ); } ``` **Step 4: Create main App component** Create `src/frontends/tui/components/App.tsx`: ```tsx import React, { useState, useCallback } from 'react'; import { Box, useApp, useInput } from 'ink'; import { StatusBar } from './StatusBar.js'; import { MessageList } from './MessageList.js'; import { InputBar } from './InputBar.js'; import type { Message, ModelClient } from '../../../models/types.js'; import type { ManagedSession } from '../../../session/index.js'; export interface AppProps { session: ManagedSession; modelClient: ModelClient; systemPrompt: string; model: string; onExit?: () => void; } export function App({ session, modelClient, systemPrompt, model, onExit, }: AppProps): React.ReactElement { const { exit } = useApp(); const [input, setInput] = useState(''); const [messages, setMessages] = useState(session.getHistory()); const [isLoading, setIsLoading] = useState(false); useInput((inputChar, key) => { if (key.escape) { onExit?.(); exit(); } }); const handleSubmit = useCallback(async (value: string) => { const trimmed = value.trim(); if (!trimmed || isLoading) return; // Handle commands if (trimmed === '/quit' || trimmed === '/exit') { onExit?.(); exit(); return; } if (trimmed === '/reset' || trimmed === '/clear') { session.clear(); setMessages([]); setInput(''); return; } // Regular message const userMessage: Message = { role: 'user', content: trimmed }; session.addMessage(userMessage); setMessages(prev => [...prev, userMessage]); setInput(''); setIsLoading(true); try { const response = await modelClient.chat({ messages: session.getHistory(), system: systemPrompt, }); const assistantMessage: Message = { role: 'assistant', content: response.content }; session.addMessage(assistantMessage); setMessages(prev => [...prev, assistantMessage]); } catch (error) { const errorMessage: Message = { role: 'assistant', content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }; setMessages(prev => [...prev, errorMessage]); } finally { setIsLoading(false); } }, [isLoading, session, modelClient, systemPrompt, exit, onExit]); return ( ); } ``` **Step 5: Create components index** Create `src/frontends/tui/components/index.ts`: ```typescript export { App, type AppProps } from './App.js'; export { StatusBar, type StatusBarProps } from './StatusBar.js'; export { MessageList, type MessageListProps } from './MessageList.js'; export { InputBar, type InputBarProps } from './InputBar.js'; ``` **Step 6: Run build to verify** Run: `pnpm build` **Step 7: Commit** ```bash git add src/frontends/tui/components/ git commit -m "feat: add Ink-based fullscreen TUI components" ``` --- ## Task 6: Fullscreen TUI Entry **Files:** - Create: `src/frontends/tui/fullscreen.ts` - Modify: `src/frontends/tui/index.ts` - Modify: `src/tui.ts` **Step 1: Create fullscreen TUI wrapper** Create `src/frontends/tui/fullscreen.ts`: ```typescript import React from 'react'; import { render } from 'ink'; import { App } from './components/index.js'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient } from '../../models/types.js'; export interface FullscreenTuiConfig { session: ManagedSession; modelClient: ModelClient; systemPrompt: string; model: string; onExit?: () => void; } export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { const { waitUntilExit } = render( React.createElement(App, { session: config.session, modelClient: config.modelClient, systemPrompt: config.systemPrompt, model: config.model, onExit: config.onExit, }) ); await waitUntilExit(); } ``` **Step 2: Update TUI index** Update `src/frontends/tui/index.ts`: ```typescript export { MinimalTui, formatPrompt, parseCommand, type TuiCommand, type MinimalTuiConfig, } from './minimal.js'; export { startFullscreenTui, type FullscreenTuiConfig, } from './fullscreen.js'; export { App, StatusBar, MessageList, InputBar } from './components/index.js'; ``` **Step 3: Update TUI entry point with mode switching** Update `src/tui.ts`: ```typescript import { loadConfig } from './config/index.js'; import { SessionStore, SessionManager } from './session/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from './models/index.js'; import { MinimalTui, startFullscreenTui } from './frontends/tui/index.js'; import type { Config } from './config/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; import { existsSync, mkdirSync } from 'fs'; const CONFIG_PATH = process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml'); const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; function createModelRouter(config: Config): ModelRouter { const models = config.models; const defaultClient = new AnthropicClient({ model: models.default.model, }); let fastClient; let complexClient; let localClient; if (models.fast) { fastClient = new AnthropicClient({ model: models.fast.model }); } if (models.complex) { complexClient = new AnthropicClient({ model: models.complex.model }); } if (models.local) { if (models.local.provider === 'ollama') { localClient = new OllamaClient({ model: models.local.model, host: models.local.endpoint, }); } } const fallbackChain = []; for (const providerName of models.fallback_chain) { if (providerName === 'openai') { fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); } else if (providerName === 'local' && localClient) { fallbackChain.push(localClient); } } return new ModelRouter({ default: defaultClient, fast: fastClient, complex: complexClient, local: localClient, fallbackChain, }); } async function main() { const args = process.argv.slice(2); const fullscreenMode = args.includes('--fullscreen') || args.includes('-f'); console.log('Flynn TUI starting...'); 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); } const config = loadConfig(CONFIG_PATH); // Ensure data directory exists const dataDir = resolve(homedir(), '.local/share/flynn'); mkdirSync(dataDir, { recursive: true }); // Initialize components const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); const sessionManager = new SessionManager(sessionStore); const modelRouter = createModelRouter(config); // Get TUI session const session = sessionManager.getSession('tui', 'local'); const cleanup = () => { sessionStore.close(); }; process.on('SIGINT', () => { cleanup(); process.exit(0); }); if (fullscreenMode) { // Start fullscreen Ink UI await startFullscreenTui({ session, modelClient: modelRouter, systemPrompt: SYSTEM_PROMPT, model: config.models.default.model, onExit: cleanup, }); } else { // Start minimal readline UI const tui = new MinimalTui({ session, modelClient: modelRouter, systemPrompt: SYSTEM_PROMPT, onTransfer: (target) => { if (target === 'telegram') { const telegramUserId = String(config.telegram.allowed_chat_ids[0]); sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); console.log(`Session transferred to Telegram (${telegramUserId})\n`); } else { console.log(`Unknown transfer target: ${target}\n`); } }, onFullscreen: async () => { tui.stop(); console.clear(); await startFullscreenTui({ session, modelClient: modelRouter, systemPrompt: SYSTEM_PROMPT, model: config.models.default.model, onExit: () => { // Return to minimal mode would require re-init // For now, just exit cleanup(); process.exit(0); }, }); }, }); await tui.start(); } cleanup(); } main().catch((error) => { console.error('Failed to start TUI:', error); process.exit(1); }); ``` **Step 4: Update package.json scripts** Update scripts in `package.json`: ```json "tui": "tsx src/tui.ts", "tui:fs": "tsx src/tui.ts --fullscreen" ``` **Step 5: Run build to verify** Run: `pnpm build` **Step 6: Commit** ```bash git add src/frontends/tui/fullscreen.ts src/frontends/tui/index.ts src/tui.ts package.json git commit -m "feat: add fullscreen TUI mode with Ink React components" ``` --- ## Task 7: Integrate SessionManager into Daemon **Files:** - Modify: `src/daemon/index.ts` - Modify: `src/backends/native/agent.ts` **Step 1: Update NativeAgent to use ManagedSession** Modify `src/backends/native/agent.ts`: ```typescript import type { ModelClient, Message } from '../../models/types.js'; import type { Session } from '../../session/index.js'; export interface NativeAgentConfig { modelClient: ModelClient; systemPrompt: string; session?: Session; } export class NativeAgent { private modelClient: ModelClient; private systemPrompt: string; private session?: Session; private inMemoryHistory: Message[] = []; constructor(config: NativeAgentConfig) { this.modelClient = config.modelClient; this.systemPrompt = config.systemPrompt; this.session = config.session; } private get history(): Message[] { return this.session?.getHistory() ?? this.inMemoryHistory; } async process(userMessage: string): Promise { const userMsg: Message = { role: 'user', content: userMessage }; if (this.session) { this.session.addMessage(userMsg); } else { this.inMemoryHistory.push(userMsg); } const response = await this.modelClient.chat({ messages: this.history, system: this.systemPrompt, }); const assistantMsg: Message = { role: 'assistant', content: response.content }; if (this.session) { this.session.addMessage(assistantMsg); } else { this.inMemoryHistory.push(assistantMsg); } return response.content; } reset(): void { if (this.session) { this.session.clear(); } else { this.inMemoryHistory = []; } } getHistory(): Message[] { return [...this.history]; } } ``` **Step 2: Update daemon to use SessionManager** Modify `src/daemon/index.ts` - update the relevant imports and sections: ```typescript import { Bot } from 'grammy'; import { Lifecycle } from './lifecycle.js'; import type { Config } from '../config/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from '../models/index.js'; import { NativeAgent } from '../backends/index.js'; import { createTelegramBot } from '../frontends/telegram/index.js'; import { SessionStore, SessionManager } from '../session/index.js'; import { HookEngine } from '../hooks/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; import { mkdirSync } from 'fs'; export interface DaemonContext { config: Config; lifecycle: Lifecycle; bot: Bot; agent: NativeAgent; sessionStore: SessionStore; sessionManager: SessionManager; hookEngine: HookEngine; modelRouter: ModelRouter; } // ... (keep SYSTEM_PROMPT and createModelRouter unchanged) export async function startDaemon(config: Config): Promise { const lifecycle = new Lifecycle(); // Ensure data directory exists const dataDir = resolve(homedir(), '.local/share/flynn'); mkdirSync(dataDir, { recursive: true }); // Initialize session store and manager const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); const sessionManager = new SessionManager(sessionStore); lifecycle.onShutdown(async () => { sessionStore.close(); console.log('Session store closed'); }); // Initialize hook engine const hookEngine = new HookEngine(config.hooks); // Initialize model router const modelRouter = createModelRouter(config); // Get Telegram session const telegramUserId = String(config.telegram.allowed_chat_ids[0]); const session = sessionManager.getSession('telegram', telegramUserId); // Initialize native agent with session const agent = new NativeAgent({ modelClient: modelRouter, systemPrompt: SYSTEM_PROMPT, session, }); // Initialize Telegram bot with hook engine const bot = createTelegramBot({ telegram: config.telegram, agent, hookEngine, }); // ... (keep signal handlers and bot start unchanged) console.log('Flynn daemon started'); return { config, lifecycle, bot, agent, sessionStore, sessionManager, hookEngine, modelRouter }; } export { Lifecycle } from './lifecycle.js'; ``` **Step 3: Update agent test** Update `src/backends/native/agent.test.ts` to work with new interface: ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NativeAgent } from './agent.js'; import type { ModelClient, ChatResponse } from '../../models/types.js'; describe('NativeAgent', () => { const createMockClient = (): ModelClient => ({ chat: vi.fn().mockResolvedValue({ content: 'Hello!', stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 5 }, } satisfies ChatResponse), }); it('processes messages and maintains history', async () => { const mockClient = createMockClient(); const agent = new NativeAgent({ modelClient: mockClient, systemPrompt: 'You are helpful.', }); const response = await agent.process('Hi'); expect(response).toBe('Hello!'); expect(mockClient.chat).toHaveBeenCalledWith({ messages: [{ role: 'user', content: 'Hi' }], system: 'You are helpful.', }); const history = agent.getHistory(); expect(history).toHaveLength(2); expect(history[0]).toEqual({ role: 'user', content: 'Hi' }); expect(history[1]).toEqual({ role: 'assistant', content: 'Hello!' }); }); it('resets conversation history', async () => { const mockClient = createMockClient(); const agent = new NativeAgent({ modelClient: mockClient, systemPrompt: 'You are helpful.', }); await agent.process('Hi'); agent.reset(); expect(agent.getHistory()).toHaveLength(0); }); it('uses session when provided', async () => { const mockClient = createMockClient(); const mockSession = { id: 'test-session', getHistory: vi.fn().mockReturnValue([]), addMessage: vi.fn(), clear: vi.fn(), }; const agent = new NativeAgent({ modelClient: mockClient, systemPrompt: 'You are helpful.', session: mockSession, }); await agent.process('Hi'); expect(mockSession.addMessage).toHaveBeenCalledTimes(2); expect(mockSession.addMessage).toHaveBeenNthCalledWith(1, { role: 'user', content: 'Hi' }); expect(mockSession.addMessage).toHaveBeenNthCalledWith(2, { role: 'assistant', content: 'Hello!' }); }); }); ``` **Step 4: Run all tests** Run: `pnpm test:run` **Step 5: Run build to verify** Run: `pnpm build` **Step 6: Commit** ```bash git add src/daemon/index.ts src/backends/native/agent.ts src/backends/native/agent.test.ts git commit -m "refactor: integrate SessionManager into daemon and agent" ``` --- ## Verification Checklist After completing all tasks, verify: 1. [ ] `pnpm build` succeeds with no errors 2. [ ] `pnpm test:run` passes all tests 3. [ ] `pnpm tui` starts minimal readline mode 4. [ ] `pnpm tui --fullscreen` or `pnpm tui:fs` starts Ink fullscreen mode 5. [ ] Messages persist between TUI sessions 6. [ ] `/transfer telegram` transfers session to Telegram 7. [ ] `/fullscreen` switches from minimal to fullscreen mode 8. [ ] Esc key exits fullscreen mode ## Manual Testing Steps 1. Start TUI in minimal mode: ```bash pnpm tui ``` 2. Send a few messages, verify responses 3. Type `/status` - verify session info shown 4. Type `/fullscreen` - verify switch to Ink UI 5. Press Esc to exit fullscreen 6. Start TUI again - verify messages persisted 7. Type `/transfer telegram` - verify session transferred 8. Start daemon (`pnpm dev`) and check Telegram for transferred messages 9. Test fullscreen mode directly: ```bash pnpm tui:fs ```