1433 lines
37 KiB
Markdown
1433 lines
37 KiB
Markdown
# Flynn Phase 2 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 model routing with fallback chain, hook engine for sensitive operations, session persistence, and local LLM support via Ollama.
|
|
|
|
**Architecture:** Extend the existing daemon with a ModelRouter that selects between providers (Anthropic, OpenAI, Gemini, Ollama), a HookEngine that intercepts tool calls and requests Telegram confirmation for sensitive operations, and SQLite-based session persistence.
|
|
|
|
**Tech Stack:** TypeScript, better-sqlite3, ollama (npm package), openai (npm package)
|
|
|
|
---
|
|
|
|
## Task 1: Add New Dependencies
|
|
|
|
**Files:**
|
|
- Modify: `package.json`
|
|
|
|
**Step 1: Add dependencies**
|
|
|
|
Add to package.json dependencies:
|
|
```json
|
|
"better-sqlite3": "^11.0.0",
|
|
"ollama": "^0.5.0",
|
|
"openai": "^4.0.0"
|
|
```
|
|
|
|
Add to devDependencies:
|
|
```json
|
|
"@types/better-sqlite3": "^7.6.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 phase 2 (sqlite, ollama, openai)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: OpenAI Client (Fallback Provider)
|
|
|
|
**Files:**
|
|
- Create: `src/models/openai.ts`
|
|
- Modify: `src/models/index.ts`
|
|
- Test: `src/models/openai.test.ts`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `src/models/openai.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { OpenAIClient } from './openai.js';
|
|
|
|
vi.mock('openai', () => ({
|
|
default: vi.fn().mockImplementation(() => ({
|
|
chat: {
|
|
completions: {
|
|
create: vi.fn().mockResolvedValue({
|
|
choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
|
}),
|
|
},
|
|
},
|
|
})),
|
|
}));
|
|
|
|
describe('OpenAIClient', () => {
|
|
it('sends messages and returns response', async () => {
|
|
const client = new OpenAIClient({
|
|
apiKey: 'test-key',
|
|
model: 'gpt-4o',
|
|
});
|
|
|
|
const response = await client.chat({
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
});
|
|
|
|
expect(response.content).toBe('Hello from GPT!');
|
|
expect(response.stopReason).toBe('stop');
|
|
expect(response.usage.inputTokens).toBe(10);
|
|
expect(response.usage.outputTokens).toBe(5);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/models/openai.test.ts`
|
|
|
|
**Step 3: Implement OpenAI client**
|
|
|
|
Create `src/models/openai.ts`:
|
|
|
|
```typescript
|
|
import OpenAI from 'openai';
|
|
import type { ChatRequest, ChatResponse, ModelClient } from './types.js';
|
|
|
|
export interface OpenAIClientConfig {
|
|
apiKey?: string;
|
|
model: string;
|
|
maxTokens?: number;
|
|
baseURL?: string;
|
|
}
|
|
|
|
export class OpenAIClient implements ModelClient {
|
|
private client: OpenAI;
|
|
private model: string;
|
|
private defaultMaxTokens: number;
|
|
|
|
constructor(config: OpenAIClientConfig) {
|
|
this.client = new OpenAI({
|
|
apiKey: config.apiKey,
|
|
baseURL: config.baseURL,
|
|
});
|
|
this.model = config.model;
|
|
this.defaultMaxTokens = config.maxTokens ?? 4096;
|
|
}
|
|
|
|
async chat(request: ChatRequest): Promise<ChatResponse> {
|
|
const messages: OpenAI.ChatCompletionMessageParam[] = [];
|
|
|
|
if (request.system) {
|
|
messages.push({ role: 'system', content: request.system });
|
|
}
|
|
|
|
for (const msg of request.messages) {
|
|
messages.push({ role: msg.role, content: msg.content });
|
|
}
|
|
|
|
const response = await this.client.chat.completions.create({
|
|
model: this.model,
|
|
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
|
|
messages,
|
|
});
|
|
|
|
const choice = response.choices[0];
|
|
const content = choice?.message?.content ?? '';
|
|
|
|
return {
|
|
content,
|
|
stopReason: choice?.finish_reason ?? 'stop',
|
|
usage: {
|
|
inputTokens: response.usage?.prompt_tokens ?? 0,
|
|
outputTokens: response.usage?.completion_tokens ?? 0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Update models index**
|
|
|
|
Add to `src/models/index.ts`:
|
|
|
|
```typescript
|
|
export { OpenAIClient, type OpenAIClientConfig } from './openai.js';
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/models/openai.test.ts`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/models/openai.ts src/models/openai.test.ts src/models/index.ts
|
|
git commit -m "feat: add OpenAI client for fallback support"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Ollama Client (Local LLM)
|
|
|
|
**Files:**
|
|
- Create: `src/models/local/ollama.ts`
|
|
- Create: `src/models/local/index.ts`
|
|
- Modify: `src/models/index.ts`
|
|
- Test: `src/models/local/ollama.test.ts`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `src/models/local/ollama.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { OllamaClient } from './ollama.js';
|
|
|
|
vi.mock('ollama', () => ({
|
|
Ollama: vi.fn().mockImplementation(() => ({
|
|
chat: vi.fn().mockResolvedValue({
|
|
message: { content: 'Hello from Ollama!' },
|
|
done_reason: 'stop',
|
|
prompt_eval_count: 10,
|
|
eval_count: 5,
|
|
}),
|
|
})),
|
|
}));
|
|
|
|
describe('OllamaClient', () => {
|
|
it('sends messages and returns response', async () => {
|
|
const client = new OllamaClient({
|
|
model: 'llama3.2',
|
|
});
|
|
|
|
const response = await client.chat({
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
});
|
|
|
|
expect(response.content).toBe('Hello from Ollama!');
|
|
expect(response.stopReason).toBe('stop');
|
|
expect(response.usage.inputTokens).toBe(10);
|
|
expect(response.usage.outputTokens).toBe(5);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/models/local/ollama.test.ts`
|
|
|
|
**Step 3: Implement Ollama client**
|
|
|
|
Create `src/models/local/ollama.ts`:
|
|
|
|
```typescript
|
|
import { Ollama } from 'ollama';
|
|
import type { ChatRequest, ChatResponse, ModelClient } from '../types.js';
|
|
|
|
export interface OllamaClientConfig {
|
|
host?: string;
|
|
model: string;
|
|
}
|
|
|
|
export class OllamaClient implements ModelClient {
|
|
private client: Ollama;
|
|
private model: string;
|
|
|
|
constructor(config: OllamaClientConfig) {
|
|
this.client = new Ollama({
|
|
host: config.host ?? 'http://localhost:11434',
|
|
});
|
|
this.model = config.model;
|
|
}
|
|
|
|
async chat(request: ChatRequest): Promise<ChatResponse> {
|
|
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
|
|
|
|
if (request.system) {
|
|
messages.push({ role: 'system', content: request.system });
|
|
}
|
|
|
|
for (const msg of request.messages) {
|
|
messages.push({ role: msg.role, content: msg.content });
|
|
}
|
|
|
|
const response = await this.client.chat({
|
|
model: this.model,
|
|
messages,
|
|
});
|
|
|
|
return {
|
|
content: response.message.content,
|
|
stopReason: response.done_reason ?? 'stop',
|
|
usage: {
|
|
inputTokens: response.prompt_eval_count ?? 0,
|
|
outputTokens: response.eval_count ?? 0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Create local index**
|
|
|
|
Create `src/models/local/index.ts`:
|
|
|
|
```typescript
|
|
export { OllamaClient, type OllamaClientConfig } from './ollama.js';
|
|
```
|
|
|
|
**Step 5: Update models index**
|
|
|
|
Add to `src/models/index.ts`:
|
|
|
|
```typescript
|
|
export { OllamaClient, type OllamaClientConfig } from './local/index.js';
|
|
```
|
|
|
|
**Step 6: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/models/local/ollama.test.ts`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/models/local/ src/models/index.ts
|
|
git commit -m "feat: add Ollama client for local LLM support"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Model Router
|
|
|
|
**Files:**
|
|
- Create: `src/models/router.ts`
|
|
- Modify: `src/models/index.ts`
|
|
- Test: `src/models/router.test.ts`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `src/models/router.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ModelRouter } from './router.js';
|
|
import type { ModelClient, ChatResponse } from './types.js';
|
|
|
|
describe('ModelRouter', () => {
|
|
const createMockClient = (name: string, shouldFail = false): ModelClient => ({
|
|
chat: vi.fn().mockImplementation(async () => {
|
|
if (shouldFail) {
|
|
throw new Error(`${name} failed`);
|
|
}
|
|
return {
|
|
content: `Response from ${name}`,
|
|
stopReason: 'end_turn',
|
|
usage: { inputTokens: 10, outputTokens: 5 },
|
|
} satisfies ChatResponse;
|
|
}),
|
|
});
|
|
|
|
it('uses default client when available', async () => {
|
|
const defaultClient = createMockClient('default');
|
|
const router = new ModelRouter({
|
|
default: defaultClient,
|
|
fallbackChain: [],
|
|
});
|
|
|
|
const response = await router.chat({ messages: [{ role: 'user', content: 'Hi' }] });
|
|
|
|
expect(response.content).toBe('Response from default');
|
|
expect(defaultClient.chat).toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to next provider on failure', async () => {
|
|
const failingClient = createMockClient('primary', true);
|
|
const fallbackClient = createMockClient('fallback');
|
|
|
|
const router = new ModelRouter({
|
|
default: failingClient,
|
|
fallbackChain: [fallbackClient],
|
|
});
|
|
|
|
const response = await router.chat({ messages: [{ role: 'user', content: 'Hi' }] });
|
|
|
|
expect(response.content).toBe('Response from fallback');
|
|
expect(failingClient.chat).toHaveBeenCalled();
|
|
expect(fallbackClient.chat).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws when all providers fail', async () => {
|
|
const failing1 = createMockClient('primary', true);
|
|
const failing2 = createMockClient('fallback', true);
|
|
|
|
const router = new ModelRouter({
|
|
default: failing1,
|
|
fallbackChain: [failing2],
|
|
});
|
|
|
|
await expect(router.chat({ messages: [{ role: 'user', content: 'Hi' }] }))
|
|
.rejects.toThrow('All model providers failed');
|
|
});
|
|
|
|
it('uses tier-specific client when specified', async () => {
|
|
const defaultClient = createMockClient('default');
|
|
const fastClient = createMockClient('fast');
|
|
|
|
const router = new ModelRouter({
|
|
default: defaultClient,
|
|
fast: fastClient,
|
|
fallbackChain: [],
|
|
});
|
|
|
|
const response = await router.chat(
|
|
{ messages: [{ role: 'user', content: 'Hi' }] },
|
|
'fast'
|
|
);
|
|
|
|
expect(response.content).toBe('Response from fast');
|
|
expect(fastClient.chat).toHaveBeenCalled();
|
|
expect(defaultClient.chat).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/models/router.test.ts`
|
|
|
|
**Step 3: Implement model router**
|
|
|
|
Create `src/models/router.ts`:
|
|
|
|
```typescript
|
|
import type { ChatRequest, ChatResponse, ModelClient } from './types.js';
|
|
|
|
export type ModelTier = 'fast' | 'default' | 'complex' | 'local';
|
|
|
|
export interface ModelRouterConfig {
|
|
default: ModelClient;
|
|
fast?: ModelClient;
|
|
complex?: ModelClient;
|
|
local?: ModelClient;
|
|
fallbackChain: ModelClient[];
|
|
}
|
|
|
|
export class ModelRouter implements ModelClient {
|
|
private clients: Map<ModelTier, ModelClient>;
|
|
private defaultClient: ModelClient;
|
|
private fallbackChain: ModelClient[];
|
|
|
|
constructor(config: ModelRouterConfig) {
|
|
this.clients = new Map();
|
|
this.defaultClient = config.default;
|
|
this.fallbackChain = config.fallbackChain;
|
|
|
|
this.clients.set('default', config.default);
|
|
if (config.fast) this.clients.set('fast', config.fast);
|
|
if (config.complex) this.clients.set('complex', config.complex);
|
|
if (config.local) this.clients.set('local', config.local);
|
|
}
|
|
|
|
async chat(request: ChatRequest, tier?: ModelTier): Promise<ChatResponse> {
|
|
const primaryClient = tier ? this.clients.get(tier) ?? this.defaultClient : this.defaultClient;
|
|
const errors: Error[] = [];
|
|
|
|
// Try primary client
|
|
try {
|
|
return await primaryClient.chat(request);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
console.warn(`Primary model failed: ${errors[0].message}`);
|
|
}
|
|
|
|
// Try fallback chain
|
|
for (const fallbackClient of this.fallbackChain) {
|
|
try {
|
|
console.log('Trying fallback model...');
|
|
return await fallbackClient.chat(request);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
console.warn(`Fallback model failed: ${errors[errors.length - 1].message}`);
|
|
}
|
|
}
|
|
|
|
throw new Error(`All model providers failed: ${errors.map(e => e.message).join(', ')}`);
|
|
}
|
|
|
|
getClient(tier: ModelTier): ModelClient | undefined {
|
|
return this.clients.get(tier);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Update models index**
|
|
|
|
Add to `src/models/index.ts`:
|
|
|
|
```typescript
|
|
export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js';
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/models/router.test.ts`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/models/router.ts src/models/router.test.ts src/models/index.ts
|
|
git commit -m "feat: add model router with fallback chain support"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Session Persistence (SQLite)
|
|
|
|
**Files:**
|
|
- Create: `src/session/store.ts`
|
|
- Create: `src/session/index.ts`
|
|
- Test: `src/session/store.test.ts`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `src/session/store.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { SessionStore } from './store.js';
|
|
import { unlinkSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
describe('SessionStore', () => {
|
|
const dbPath = join(tmpdir(), 'flynn-test-sessions.db');
|
|
let store: SessionStore;
|
|
|
|
beforeEach(() => {
|
|
store = new SessionStore(dbPath);
|
|
});
|
|
|
|
afterEach(() => {
|
|
store.close();
|
|
if (existsSync(dbPath)) {
|
|
unlinkSync(dbPath);
|
|
}
|
|
});
|
|
|
|
it('saves and retrieves messages', () => {
|
|
const sessionId = 'test-session';
|
|
|
|
store.addMessage(sessionId, { role: 'user', content: 'Hello' });
|
|
store.addMessage(sessionId, { role: 'assistant', content: 'Hi there!' });
|
|
|
|
const messages = store.getMessages(sessionId);
|
|
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[0].role).toBe('user');
|
|
expect(messages[0].content).toBe('Hello');
|
|
expect(messages[1].role).toBe('assistant');
|
|
expect(messages[1].content).toBe('Hi there!');
|
|
});
|
|
|
|
it('clears session messages', () => {
|
|
const sessionId = 'test-session';
|
|
|
|
store.addMessage(sessionId, { role: 'user', content: 'Hello' });
|
|
store.clearSession(sessionId);
|
|
|
|
const messages = store.getMessages(sessionId);
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it('handles multiple sessions independently', () => {
|
|
store.addMessage('session-1', { role: 'user', content: 'Session 1' });
|
|
store.addMessage('session-2', { role: 'user', content: 'Session 2' });
|
|
|
|
expect(store.getMessages('session-1')).toHaveLength(1);
|
|
expect(store.getMessages('session-2')).toHaveLength(1);
|
|
expect(store.getMessages('session-1')[0].content).toBe('Session 1');
|
|
});
|
|
|
|
it('lists all sessions', () => {
|
|
store.addMessage('session-a', { role: 'user', content: 'A' });
|
|
store.addMessage('session-b', { role: 'user', content: 'B' });
|
|
|
|
const sessions = store.listSessions();
|
|
|
|
expect(sessions).toContain('session-a');
|
|
expect(sessions).toContain('session-b');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/session/store.test.ts`
|
|
|
|
**Step 3: Implement session store**
|
|
|
|
Create `src/session/store.ts`:
|
|
|
|
```typescript
|
|
import Database from 'better-sqlite3';
|
|
import type { Message } from '../models/types.js';
|
|
|
|
export class SessionStore {
|
|
private db: Database.Database;
|
|
|
|
constructor(dbPath: string) {
|
|
this.db = new Database(dbPath);
|
|
this.init();
|
|
}
|
|
|
|
private init(): void {
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
`);
|
|
}
|
|
|
|
addMessage(sessionId: string, message: Message): void {
|
|
const stmt = this.db.prepare(
|
|
'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)'
|
|
);
|
|
stmt.run(sessionId, message.role, message.content);
|
|
}
|
|
|
|
getMessages(sessionId: string): Message[] {
|
|
const stmt = this.db.prepare(
|
|
'SELECT role, content FROM messages WHERE session_id = ? ORDER BY id ASC'
|
|
);
|
|
const rows = stmt.all(sessionId) as Array<{ role: string; content: string }>;
|
|
return rows.map(row => ({
|
|
role: row.role as 'user' | 'assistant',
|
|
content: row.content,
|
|
}));
|
|
}
|
|
|
|
clearSession(sessionId: string): void {
|
|
const stmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
|
|
stmt.run(sessionId);
|
|
}
|
|
|
|
listSessions(): string[] {
|
|
const stmt = this.db.prepare('SELECT DISTINCT session_id FROM messages');
|
|
const rows = stmt.all() as Array<{ session_id: string }>;
|
|
return rows.map(row => row.session_id);
|
|
}
|
|
|
|
close(): void {
|
|
this.db.close();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Create session index**
|
|
|
|
Create `src/session/index.ts`:
|
|
|
|
```typescript
|
|
export { SessionStore } from './store.js';
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/session/store.test.ts`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/session/
|
|
git commit -m "feat: add SQLite session persistence"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Hook Engine
|
|
|
|
**Files:**
|
|
- Create: `src/hooks/types.ts`
|
|
- Create: `src/hooks/engine.ts`
|
|
- Create: `src/hooks/index.ts`
|
|
- Test: `src/hooks/engine.test.ts`
|
|
|
|
**Step 1: Create hook types**
|
|
|
|
Create `src/hooks/types.ts`:
|
|
|
|
```typescript
|
|
export type HookAction = 'confirm' | 'log' | 'silent';
|
|
|
|
export interface HookResult {
|
|
approved: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface PendingConfirmation {
|
|
id: string;
|
|
tool: string;
|
|
args: Record<string, unknown>;
|
|
resolve: (result: HookResult) => void;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface HookConfig {
|
|
confirm: string[];
|
|
log: string[];
|
|
silent: string[];
|
|
}
|
|
```
|
|
|
|
**Step 2: Write failing test**
|
|
|
|
Create `src/hooks/engine.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { HookEngine } from './engine.js';
|
|
|
|
describe('HookEngine', () => {
|
|
it('returns silent action for non-matching tools', async () => {
|
|
const engine = new HookEngine({
|
|
confirm: ['shell.*'],
|
|
log: ['web.*'],
|
|
silent: [],
|
|
});
|
|
|
|
const action = engine.getAction('unknown.tool');
|
|
expect(action).toBe('silent');
|
|
});
|
|
|
|
it('returns confirm action for matching confirm patterns', () => {
|
|
const engine = new HookEngine({
|
|
confirm: ['shell.*', 'file.write'],
|
|
log: [],
|
|
silent: [],
|
|
});
|
|
|
|
expect(engine.getAction('shell.exec')).toBe('confirm');
|
|
expect(engine.getAction('shell.run')).toBe('confirm');
|
|
expect(engine.getAction('file.write')).toBe('confirm');
|
|
expect(engine.getAction('file.read')).toBe('silent');
|
|
});
|
|
|
|
it('returns log action for matching log patterns', () => {
|
|
const engine = new HookEngine({
|
|
confirm: [],
|
|
log: ['web.*'],
|
|
silent: [],
|
|
});
|
|
|
|
expect(engine.getAction('web.fetch')).toBe('log');
|
|
expect(engine.getAction('web.search')).toBe('log');
|
|
});
|
|
|
|
it('queues confirmation and resolves when approved', async () => {
|
|
const engine = new HookEngine({
|
|
confirm: ['shell.*'],
|
|
log: [],
|
|
silent: [],
|
|
});
|
|
|
|
const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'ls' });
|
|
|
|
const pending = engine.getPendingConfirmations();
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0].tool).toBe('shell.exec');
|
|
|
|
engine.resolveConfirmation(pending[0].id, { approved: true });
|
|
|
|
const result = await confirmPromise;
|
|
expect(result.approved).toBe(true);
|
|
});
|
|
|
|
it('resolves with denied when rejected', async () => {
|
|
const engine = new HookEngine({
|
|
confirm: ['shell.*'],
|
|
log: [],
|
|
silent: [],
|
|
});
|
|
|
|
const confirmPromise = engine.requestConfirmation('shell.exec', { cmd: 'rm -rf' });
|
|
|
|
const pending = engine.getPendingConfirmations();
|
|
engine.resolveConfirmation(pending[0].id, { approved: false, reason: 'Too dangerous' });
|
|
|
|
const result = await confirmPromise;
|
|
expect(result.approved).toBe(false);
|
|
expect(result.reason).toBe('Too dangerous');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/hooks/engine.test.ts`
|
|
|
|
**Step 4: Implement hook engine**
|
|
|
|
Create `src/hooks/engine.ts`:
|
|
|
|
```typescript
|
|
import { randomUUID } from 'crypto';
|
|
import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js';
|
|
|
|
export class HookEngine {
|
|
private confirmPatterns: RegExp[];
|
|
private logPatterns: RegExp[];
|
|
private pendingConfirmations: Map<string, PendingConfirmation> = new Map();
|
|
|
|
constructor(config: HookConfig) {
|
|
this.confirmPatterns = config.confirm.map(p => this.patternToRegex(p));
|
|
this.logPatterns = config.log.map(p => this.patternToRegex(p));
|
|
}
|
|
|
|
private patternToRegex(pattern: string): RegExp {
|
|
// Convert glob-like patterns to regex
|
|
// shell.* -> ^shell\..*$
|
|
// file.write -> ^file\.write$
|
|
const escaped = pattern
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
.replace(/\*/g, '.*');
|
|
return new RegExp(`^${escaped}$`);
|
|
}
|
|
|
|
getAction(tool: string): HookAction {
|
|
if (this.confirmPatterns.some(p => p.test(tool))) {
|
|
return 'confirm';
|
|
}
|
|
if (this.logPatterns.some(p => p.test(tool))) {
|
|
return 'log';
|
|
}
|
|
return 'silent';
|
|
}
|
|
|
|
async requestConfirmation(tool: string, args: Record<string, unknown>): Promise<HookResult> {
|
|
const id = randomUUID();
|
|
|
|
return new Promise((resolve) => {
|
|
const pending: PendingConfirmation = {
|
|
id,
|
|
tool,
|
|
args,
|
|
resolve,
|
|
createdAt: new Date(),
|
|
};
|
|
this.pendingConfirmations.set(id, pending);
|
|
});
|
|
}
|
|
|
|
resolveConfirmation(id: string, result: HookResult): boolean {
|
|
const pending = this.pendingConfirmations.get(id);
|
|
if (!pending) {
|
|
return false;
|
|
}
|
|
|
|
pending.resolve(result);
|
|
this.pendingConfirmations.delete(id);
|
|
return true;
|
|
}
|
|
|
|
getPendingConfirmations(): PendingConfirmation[] {
|
|
return Array.from(this.pendingConfirmations.values());
|
|
}
|
|
|
|
clearExpiredConfirmations(maxAgeMs: number = 5 * 60 * 1000): number {
|
|
const now = Date.now();
|
|
let cleared = 0;
|
|
|
|
for (const [id, pending] of this.pendingConfirmations) {
|
|
if (now - pending.createdAt.getTime() > maxAgeMs) {
|
|
pending.resolve({ approved: false, reason: 'Confirmation timed out' });
|
|
this.pendingConfirmations.delete(id);
|
|
cleared++;
|
|
}
|
|
}
|
|
|
|
return cleared;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Create hooks index**
|
|
|
|
Create `src/hooks/index.ts`:
|
|
|
|
```typescript
|
|
export { HookEngine } from './engine.js';
|
|
export type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js';
|
|
```
|
|
|
|
**Step 6: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/hooks/engine.test.ts`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/hooks/
|
|
git commit -m "feat: add hook engine for sensitive operation confirmation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Telegram Hook Confirmation UI
|
|
|
|
**Files:**
|
|
- Create: `src/frontends/telegram/confirmations.ts`
|
|
- Modify: `src/frontends/telegram/bot.ts`
|
|
- Modify: `src/frontends/telegram/index.ts`
|
|
- Test: `src/frontends/telegram/confirmations.test.ts`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `src/frontends/telegram/confirmations.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { formatConfirmationMessage, parseConfirmationCallback } from './confirmations.js';
|
|
|
|
describe('formatConfirmationMessage', () => {
|
|
it('formats tool and args into readable message', () => {
|
|
const message = formatConfirmationMessage('shell.exec', { cmd: 'ls -la' });
|
|
|
|
expect(message).toContain('shell.exec');
|
|
expect(message).toContain('ls -la');
|
|
});
|
|
});
|
|
|
|
describe('parseConfirmationCallback', () => {
|
|
it('parses approve callback data', () => {
|
|
const result = parseConfirmationCallback('confirm:abc123:approve');
|
|
|
|
expect(result).toEqual({
|
|
id: 'abc123',
|
|
approved: true,
|
|
});
|
|
});
|
|
|
|
it('parses deny callback data', () => {
|
|
const result = parseConfirmationCallback('confirm:abc123:deny');
|
|
|
|
expect(result).toEqual({
|
|
id: 'abc123',
|
|
approved: false,
|
|
});
|
|
});
|
|
|
|
it('returns null for invalid callback data', () => {
|
|
expect(parseConfirmationCallback('invalid')).toBeNull();
|
|
expect(parseConfirmationCallback('other:data')).toBeNull();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pnpm test:run src/frontends/telegram/confirmations.test.ts`
|
|
|
|
**Step 3: Implement confirmations module**
|
|
|
|
Create `src/frontends/telegram/confirmations.ts`:
|
|
|
|
```typescript
|
|
import { InlineKeyboard } from 'grammy';
|
|
|
|
export function formatConfirmationMessage(tool: string, args: Record<string, unknown>): string {
|
|
const argsStr = Object.entries(args)
|
|
.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
|
|
.join('\n');
|
|
|
|
return `🔐 **Confirmation Required**
|
|
|
|
Tool: \`${tool}\`
|
|
Arguments:
|
|
${argsStr || ' (none)'}
|
|
|
|
Approve this action?`;
|
|
}
|
|
|
|
export function createConfirmationKeyboard(confirmationId: string): InlineKeyboard {
|
|
return new InlineKeyboard()
|
|
.text('✅ Approve', `confirm:${confirmationId}:approve`)
|
|
.text('❌ Deny', `confirm:${confirmationId}:deny`);
|
|
}
|
|
|
|
export interface ConfirmationCallbackData {
|
|
id: string;
|
|
approved: boolean;
|
|
}
|
|
|
|
export function parseConfirmationCallback(data: string): ConfirmationCallbackData | null {
|
|
const parts = data.split(':');
|
|
if (parts.length !== 3 || parts[0] !== 'confirm') {
|
|
return null;
|
|
}
|
|
|
|
const [, id, action] = parts;
|
|
if (action !== 'approve' && action !== 'deny') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
approved: action === 'approve',
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pnpm test:run src/frontends/telegram/confirmations.test.ts`
|
|
|
|
**Step 5: Update telegram index**
|
|
|
|
Add to `src/frontends/telegram/index.ts`:
|
|
|
|
```typescript
|
|
export {
|
|
formatConfirmationMessage,
|
|
createConfirmationKeyboard,
|
|
parseConfirmationCallback,
|
|
type ConfirmationCallbackData,
|
|
} from './confirmations.js';
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/frontends/telegram/confirmations.ts src/frontends/telegram/confirmations.test.ts src/frontends/telegram/index.ts
|
|
git commit -m "feat: add Telegram confirmation UI components"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Integrate Components into Daemon
|
|
|
|
**Files:**
|
|
- Modify: `src/daemon/index.ts`
|
|
- Modify: `src/frontends/telegram/bot.ts`
|
|
- Modify: `src/backends/native/agent.ts`
|
|
|
|
**Step 1: Update native agent to use session store**
|
|
|
|
Modify `src/backends/native/agent.ts`:
|
|
|
|
```typescript
|
|
import type { ModelClient, Message } from '../../models/types.js';
|
|
import type { SessionStore } from '../../session/index.js';
|
|
|
|
export interface NativeAgentConfig {
|
|
modelClient: ModelClient;
|
|
systemPrompt: string;
|
|
sessionStore?: SessionStore;
|
|
sessionId?: string;
|
|
}
|
|
|
|
export class NativeAgent {
|
|
private modelClient: ModelClient;
|
|
private systemPrompt: string;
|
|
private sessionStore?: SessionStore;
|
|
private sessionId: string;
|
|
private history: Message[] = [];
|
|
|
|
constructor(config: NativeAgentConfig) {
|
|
this.modelClient = config.modelClient;
|
|
this.systemPrompt = config.systemPrompt;
|
|
this.sessionStore = config.sessionStore;
|
|
this.sessionId = config.sessionId ?? 'default';
|
|
|
|
// Load existing history from store
|
|
if (this.sessionStore) {
|
|
this.history = this.sessionStore.getMessages(this.sessionId);
|
|
}
|
|
}
|
|
|
|
async process(userMessage: string): Promise<string> {
|
|
const userMsg: Message = { role: 'user', content: userMessage };
|
|
this.history.push(userMsg);
|
|
|
|
if (this.sessionStore) {
|
|
this.sessionStore.addMessage(this.sessionId, userMsg);
|
|
}
|
|
|
|
const response = await this.modelClient.chat({
|
|
messages: [...this.history],
|
|
system: this.systemPrompt,
|
|
});
|
|
|
|
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
|
this.history.push(assistantMsg);
|
|
|
|
if (this.sessionStore) {
|
|
this.sessionStore.addMessage(this.sessionId, assistantMsg);
|
|
}
|
|
|
|
return response.content;
|
|
}
|
|
|
|
reset(): void {
|
|
this.history = [];
|
|
if (this.sessionStore) {
|
|
this.sessionStore.clearSession(this.sessionId);
|
|
}
|
|
}
|
|
|
|
getHistory(): Message[] {
|
|
return [...this.history];
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Update daemon to use model router and session store**
|
|
|
|
Modify `src/daemon/index.ts`:
|
|
|
|
```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 } 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;
|
|
hookEngine: HookEngine;
|
|
modelRouter: ModelRouter;
|
|
}
|
|
|
|
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;
|
|
|
|
// Create default client (required)
|
|
const defaultClient = new AnthropicClient({
|
|
model: models.default.model,
|
|
});
|
|
|
|
// Create optional tier clients
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Build fallback chain
|
|
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,
|
|
});
|
|
}
|
|
|
|
export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|
const lifecycle = new Lifecycle();
|
|
|
|
// Ensure data directory exists
|
|
const dataDir = resolve(homedir(), '.local/share/flynn');
|
|
mkdirSync(dataDir, { recursive: true });
|
|
|
|
// Initialize session store
|
|
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
|
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);
|
|
|
|
// Initialize native agent with session persistence
|
|
const agent = new NativeAgent({
|
|
modelClient: modelRouter,
|
|
systemPrompt: SYSTEM_PROMPT,
|
|
sessionStore,
|
|
sessionId: `telegram-${config.telegram.allowed_chat_ids[0]}`,
|
|
});
|
|
|
|
// Initialize Telegram bot with hook engine
|
|
const bot = createTelegramBot({
|
|
telegram: config.telegram,
|
|
agent,
|
|
hookEngine,
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
|
|
bot.start({
|
|
onStart: (botInfo) => {
|
|
console.log(`Telegram bot started: @${botInfo.username}`);
|
|
},
|
|
});
|
|
|
|
console.log('Flynn daemon started');
|
|
|
|
return { config, lifecycle, bot, agent, sessionStore, hookEngine, modelRouter };
|
|
}
|
|
|
|
export { Lifecycle } from './lifecycle.js';
|
|
```
|
|
|
|
**Step 3: Update telegram bot to handle confirmations**
|
|
|
|
Modify `src/frontends/telegram/bot.ts` to add hook engine and callback query handler:
|
|
|
|
```typescript
|
|
import { Bot } from 'grammy';
|
|
import type { NativeAgent } from '../../backends/index.js';
|
|
import type { TelegramConfig } from '../../config/index.js';
|
|
import type { HookEngine } from '../../hooks/index.js';
|
|
import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';
|
|
import {
|
|
formatConfirmationMessage,
|
|
createConfirmationKeyboard,
|
|
parseConfirmationCallback,
|
|
} from './confirmations.js';
|
|
|
|
export interface TelegramBotConfig {
|
|
telegram: TelegramConfig;
|
|
agent: NativeAgent;
|
|
hookEngine?: HookEngine;
|
|
}
|
|
|
|
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;
|
|
const hookEngine = config.hookEngine;
|
|
|
|
// 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();
|
|
});
|
|
|
|
// Handle confirmation callbacks
|
|
bot.on('callback_query:data', async (ctx) => {
|
|
const data = ctx.callbackQuery.data;
|
|
const parsed = parseConfirmationCallback(data);
|
|
|
|
if (!parsed || !hookEngine) {
|
|
await ctx.answerCallbackQuery({ text: 'Invalid action' });
|
|
return;
|
|
}
|
|
|
|
const resolved = hookEngine.resolveConfirmation(parsed.id, {
|
|
approved: parsed.approved,
|
|
reason: parsed.approved ? undefined : 'Denied by user',
|
|
});
|
|
|
|
if (resolved) {
|
|
await ctx.answerCallbackQuery({
|
|
text: parsed.approved ? '✅ Approved' : '❌ Denied',
|
|
});
|
|
await ctx.editMessageText(
|
|
ctx.callbackQuery.message?.text + `\n\n${parsed.approved ? '✅ Approved' : '❌ Denied'}`,
|
|
{ parse_mode: 'Markdown' }
|
|
);
|
|
} else {
|
|
await ctx.answerCallbackQuery({ text: 'Confirmation expired or not found' });
|
|
}
|
|
});
|
|
|
|
// 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) => {
|
|
const pending = hookEngine?.getPendingConfirmations() ?? [];
|
|
const statusMsg = `Flynn is running.\nPending confirmations: ${pending.length}`;
|
|
await ctx.reply(statusMsg);
|
|
});
|
|
|
|
// Message handler
|
|
bot.on('message:text', async (ctx) => {
|
|
const text = ctx.message.text;
|
|
|
|
await ctx.replyWithChatAction('typing');
|
|
|
|
try {
|
|
const response = await handleMessage(text);
|
|
|
|
if (response.length <= 4096) {
|
|
await ctx.reply(response, { parse_mode: 'Markdown' });
|
|
} else {
|
|
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;
|
|
}
|
|
|
|
let splitIndex = remaining.lastIndexOf('\n', maxLength);
|
|
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
|
splitIndex = remaining.lastIndexOf(' ', maxLength);
|
|
}
|
|
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
|
splitIndex = maxLength;
|
|
}
|
|
|
|
chunks.push(remaining.slice(0, splitIndex));
|
|
remaining = remaining.slice(splitIndex).trimStart();
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
```
|
|
|
|
**Step 4: Verify build**
|
|
|
|
Run: `pnpm build`
|
|
|
|
**Step 5: Run all tests**
|
|
|
|
Run: `pnpm test:run`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/daemon/index.ts src/frontends/telegram/bot.ts src/backends/native/agent.ts
|
|
git commit -m "feat: integrate model router, session persistence, and hook engine"
|
|
```
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
After completing all tasks, verify:
|
|
|
|
1. [ ] `pnpm build` succeeds with no errors
|
|
2. [ ] `pnpm test:run` passes all tests
|
|
3. [ ] Model router tries fallback on primary failure
|
|
4. [ ] Session messages persist across restarts
|
|
5. [ ] Hook engine classifies tools correctly
|
|
6. [ ] Telegram shows confirmation buttons for sensitive operations
|
|
7. [ ] Ollama integration works when Ollama is running
|
|
|
|
## Manual Testing Steps
|
|
|
|
1. Ensure Ollama is running with a model: `ollama run llama3.2`
|
|
2. Update config to include local model:
|
|
```yaml
|
|
models:
|
|
local:
|
|
provider: ollama
|
|
model: llama3.2
|
|
default:
|
|
provider: anthropic
|
|
model: claude-sonnet-4-20250514
|
|
fallback_chain: [openai, local]
|
|
```
|
|
3. Start Flynn: `pnpm dev`
|
|
4. Send a message - verify response
|
|
5. Stop Flynn (Ctrl+C), start again
|
|
6. Send `/status` - verify session persists
|
|
7. Unset ANTHROPIC_API_KEY temporarily - verify fallback to OpenAI/Ollama
|
|
|
|
|
|
|