# Pi SDK Migration Plan ## Overview Replace the hand-rolled `packages/shared/src/llm/client.ts` HTTP client (which called the Pi/Inflection AI API at `api.pi.ai`) with the **Pi Coding Agent SDK** (`@earendil-works/pi-coding-agent`). This removes the dependency on the Pi/Inflection AI cloud service entirely and lets MosaicIQ use any LLM provider. Benefits: - Type-safe LLM interaction via `AgentSession` - Built-in streaming, retry, and compaction - Custom tools with direct access to our SQLite database - **Multi-provider support** (Anthropic, OpenAI, DeepSeek, Gemini, etc.) - Structured output with proper schemas - Session management (persisted or in-memory) > **Note:** "Pi" in "Pi Coding Agent SDK" is the name of the coding agent framework (the tool we use to build agents). It is NOT the same as the Pi/Inflection AI chatbot API (`api.pi.ai`). The SDK supports many LLM providers — we are removing the Inflection AI dependency entirely. The migration is structured as **7 phases**, each independently shippable. No phase should break existing functionality. --- ## Phase 1: Install SDK & Create Adapter Layer **Goal:** Install the package and build a thin adapter that wraps the SDK, keeping the existing `llm/` API surface working. ### Files to create/modify | Action | File | Description | |--------|------|-------------| | Install | `packages/shared/package.json` | Add `@earendil-works/pi-coding-agent` dependency | | Create | `packages/shared/src/llm/piSdk.ts` | SDK initialization, session factory, auth setup | | Modify | `packages/shared/src/llm/client.ts` | Replace raw `fetch` calls with SDK-backed implementation | ### `packages/shared/src/llm/piSdk.ts` — SDK singleton ```typescript import { AuthStorage, createAgentSession, ModelRegistry, SessionManager, SettingsManager, DefaultResourceLoader, } from "@earendil-works/pi-coding-agent"; // Lazy-initialized SDK state let authStorage: AuthStorage; let modelRegistry: ModelRegistry; export function getAuthStorage(): AuthStorage { if (!authStorage) { authStorage = AuthStorage.create(); } return authStorage; } export function getModelRegistry(): ModelRegistry { if (!modelRegistry) { modelRegistry = ModelRegistry.create(getAuthStorage()); } return modelRegistry; } export async function createSession(options?: { systemPrompt?: string; customTools?: any[]; onEvent?: (event: any) => void; }) { const session = await createAgentSession({ sessionManager: SessionManager.inMemory(), authStorage: getAuthStorage(), modelRegistry: getModelRegistry(), settingsManager: SettingsManager.inMemory({ compaction: { enabled: false }, // We manage context ourselves retry: { enabled: true, maxRetries: 3 }, }), // No built-in tools for research agents — only custom tools tools: [], customTools: options?.customTools ?? [], }); if (options?.onEvent) { session.subscribe(options.onEvent); } return session; } export function isConfigured(): boolean { // Check if any supported provider has an API key configured return !!( process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.GOOGLE_API_KEY ); } ``` ### `packages/shared/src/llm/client.ts` — Updated to use SDK ```typescript // Keep the same exported API surface (streamResponse, complete, etc.) // but delegate to SDK sessions internally export async function complete( prompt: string, options: CompletionOptions = {} ): Promise { const session = await createSession({ systemPrompt: options.systemPrompt, }); let result = ""; session.subscribe((event) => { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { result += event.assistantMessageEvent.delta; } }); await session.prompt(prompt); return result; } export async function streamResponse( prompt: string, options: StreamOptions & CompletionOptions = {} ): Promise { const session = await createSession(); let fullResponse = ""; session.subscribe((event) => { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { const delta = event.assistantMessageEvent.delta; fullResponse += delta; options.onProgress?.(delta); } }); await session.prompt(prompt); return fullResponse; } ``` ### Acceptance criteria - [ ] `pnpm install` succeeds with new SDK dependency - [ ] Existing `agent.chat`, `agent.start`, `validation.run` RPC methods still work - [ ] `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` are picked up from env - [ ] No references to `PI_API_KEY` or `api.pi.ai` remain - [ ] Streaming still works end-to-end (RPC → event emitter → renderer) --- ## Phase 2: Custom Tools for Database Access **Goal:** Build Pi SDK custom tools that give the LLM direct access to MosaicIQ's SQLite data. This replaces the "build context → embed in prompt" pattern with tool-based access. ### Files to create | Action | File | Description | |--------|------|-------------| | Create | `packages/shared/src/tools/index.ts` | Tool barrel export | | Create | `packages/shared/src/tools/companyTools.ts` | Company, filings, earnings tools | | Create | `packages/shared/src/tools/modelTools.ts` | Financial model read/write tools | | Create | `packages/shared/src/tools/memoTools.ts` | Memo read/write tools | | Create | `packages/shared/src/tools/validationTools.ts` | Validation status tools | ### `packages/shared/src/tools/companyTools.ts` — Example ```typescript import { Type } from "typebox"; import { defineTool } from "@earendil-works/pi-coding-agent"; import type { Db } from "../db/database.js"; import { getCompany, listFilings, getEarningsSchedule, } from "../db/queries.js"; export function createCompanyTools(db: Db) { const getCompanyInfo = defineTool({ name: "get_company_info", label: "Get Company Info", description: "Get company details including name, ticker, sector, and price", parameters: Type.Object({ companyId: Type.String({ description: "Company ID or ticker" }), }), execute: async (_, params) => { const company = getCompany(db, params.companyId); if (!company) { return { content: [{ type: "text", text: `Company "${params.companyId}" not found.` }], isError: true, details: {}, }; } return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }], details: {}, }; }, }); const getFilings = defineTool({ name: "get_filings", label: "Get SEC Filings", description: "List SEC filings for a company (10-K, 10-Q, 8-K, etc.)", parameters: Type.Object({ companyId: Type.String({ description: "Company ID" }), limit: Type.Optional(Type.Number({ description: "Max filings to return", default: 20 })), }), execute: async (_, params) => { const filings = listFilings(db, params.companyId, params.limit ?? 20); return { content: [{ type: "text", text: JSON.stringify(filings, null, 2) }], details: {}, }; }, }); const getEarnings = defineTool({ name: "get_earnings", label: "Get Earnings Schedule", description: "Get upcoming and past earnings dates and results", parameters: Type.Object({ companyId: Type.String({ description: "Company ID" }), }), execute: async (_, params) => { const schedule = getEarningsSchedule(db, params.companyId); return { content: [{ type: "text", text: JSON.stringify(schedule, null, 2) }], details: {}, }; }, }); return [getCompanyInfo, getFilings, getEarnings]; } ``` ### `packages/shared/src/tools/modelTools.ts` ```typescript export function createModelTools(db: Db) { const getModelData = defineTool({ name: "get_financial_model", label: "Get Financial Model", description: "Get the financial model for a company (income, balance, operating tabs)", parameters: Type.Object({ companyId: Type.String(), tab: Type.Union([Type.Literal("income"), Type.Literal("balance"), Type.Literal("operating")]), }), execute: async (_, params) => { const model = getModel(db, params.companyId, params.tab); return { content: [{ type: "text", text: JSON.stringify(model, null, 2) }], details: {}, }; }, }); const updateModelCell = defineTool({ name: "update_model_cell", label: "Update Model Cell", description: "Update a single cell in the financial model", parameters: Type.Object({ companyId: Type.String(), tab: Type.String(), row: Type.Number(), col: Type.Number(), value: Type.String(), }), execute: async (_, params) => { const result = updateModelCell(db, params.companyId, params.tab, params.row, params.col, params.value); return { content: [{ type: "text", text: `Updated cell [${params.row}, ${params.col}] = ${params.value}` }], details: result, }; }, }); return [getModelData, updateModelCell]; } ``` ### `packages/shared/src/tools/memoTools.ts` ```typescript export function createMemoTools(db: Db) { const getMemo = defineTool({ name: "get_memo", label: "Get Investment Memo", description: "Get the full investment memo with all sections, annotations, and review status", parameters: Type.Object({ companyId: Type.String(), }), execute: async (_, params) => { const memo = getMemo(db, params.companyId); return { content: [{ type: "text", text: JSON.stringify(memo, null, 2) }], details: {}, }; }, }); const updateMemoSection = defineTool({ name: "update_memo_section", label: "Update Memo Section", description: "Update a specific section of the investment memo", parameters: Type.Object({ companyId: Type.String(), sectionId: Type.String(), content: Type.String({ description: "New section content (markdown)" }), }), execute: async (_, params) => { const section = updateMemoSection(db, params.companyId, params.sectionId, { content: params.content }); return { content: [{ type: "text", text: `Updated section "${params.sectionId}"` }], details: { section }, }; }, }); return [getMemo, updateMemoSection]; } ``` ### `packages/shared/src/tools/index.ts` ```typescript import type { Db } from "../db/database.js"; import { createCompanyTools } from "./companyTools.js"; import { createModelTools } from "./modelTools.js"; import { createMemoTools } from "./memoTools.js"; import { createValidationTools } from "./validationTools.js"; export function createAllTools(db: Db) { return [ ...createCompanyTools(db), ...createModelTools(db), ...createMemoTools(db), ...createValidationTools(db), ]; } export { createCompanyTools, createModelTools, createMemoTools, createValidationTools }; ``` ### Acceptance criteria - [ ] All tools have TypeBox schemas (parameters validated by SDK) - [ ] Each tool has a clear description for the LLM - [ ] Tools can access the DB (test with a simple session.prompt()) - [ ] Error cases return `isError: true` --- ## Phase 3: Migrate Agent Runner to SDK Sessions **Goal:** Rewrite `runner.ts` and `validationAgents.ts` to use SDK sessions with custom tools instead of the "build context → embed in prompt" pattern. ### Files to modify | Action | File | Description | |--------|------|-------------| | Modify | `packages/shared/src/agents/runner.ts` | Use `createSession()` with custom tools | | Modify | `packages/shared/src/agents/validationAgents.ts` | Use SDK sessions | | Modify | `packages/shared/src/rpc/agentRpc.ts` | Pass streaming events through | ### New `runner.ts` approach **Before (current):** 1. Build huge context object from DB 2. Interpolate context into a giant prompt string 3. Send prompt to LLM 4. Parse JSON from response **After (SDK):** 1. Create SDK session with company-specific custom tools 2. Send a focused prompt (the agent's instructions + user request) 3. LLM calls tools as needed to get data 4. LLM returns structured response ```typescript // New runner.ts (simplified) export async function executeAgent( db: Db, agentId: string, companyId: string, options: AgentRunOptions = {} ): Promise { const tools = createAllTools(db); const metadata = getAgentMetadata(agentId); const systemPrompt = getAgentSystemPrompt(agentId); // Just the role, not the data const session = await createSession({ systemPrompt, customTools: tools, onEvent: (event) => { // Forward SDK events to our EventEmitter if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { emitAgentStreaming(runId, agentId, companyId, event.assistantMessageEvent.delta); } if (event.type === "tool_execution_start") { emitAgentProgress(runId, agentId, companyId, 50, `Running tool: ${event.toolName}`); } }, }); // Focused prompt — the LLM will use tools to gather data const prompt = `Analyze company ${companyId} using your available tools. ${options.userMessage ?? ""}`; await session.prompt(prompt); // Get the final assistant message const messages = session.agent.state.messages; const lastAssistant = messages.filter(m => m.role === "assistant").pop(); // ... store results, emit events, etc. } ``` ### Key insight: Agent prompts change The current `prompts.ts` embeds all data in the prompt. After migration, prompts become **instructions only** — the LLM fetches data via tools: ```typescript // BEFORE (current): Giant prompt with all data baked in const prompt = `You are the SEC Filings Agent for ${ctx.company.name}. Available Filings: ${ctx.filings.map(f => `- ${f.formType}: ${f.title}`).join("\n")} ...hundreds of lines of embedded data... `; // AFTER: Focused system prompt, tools provide data const systemPrompt = `You are the SEC Filings Agent. Use the get_filings and get_company_info tools to gather data. Extract segment revenue, identify risk factors, and note accounting changes. Cite specific sections when making claims. Respond with structured JSON.`; // The LLM calls tools as needed during generation ``` ### Acceptance criteria - [ ] `agent.start` RPC works end-to-end with SDK - [ ] `agent.chat` RPC works with SDK (streaming response) - [ ] Tool calls appear in agent progress events - [ ] Validation agents (sv, qa, rt) still return `ValidationResult` - [ ] Pipeline executor still works --- ## Phase 4: Excel/File Custom Tools **Goal:** Add custom tools for reading and writing Excel, CSV, and PDF files. The Pi SDK's built-in `read` tool only handles text and images. ### Files to create | Action | File | Description | |--------|------|-------------| | Create | `packages/shared/src/tools/fileTools.ts` | Excel read/write, CSV, PDF tools | | Modify | `packages/shared/src/tools/index.ts` | Export new tools | ### `packages/shared/src/tools/fileTools.ts` ```typescript import ExcelJS from "exceljs"; import { Type } from "typebox"; import { defineTool } from "@earendil-works/pi-coding-agent"; export function createFileTools() { const readExcel = defineTool({ name: "read_excel", label: "Read Excel File", description: "Read an Excel (.xlsx) file and return sheet data as JSON. Supports sheet selection and row ranges.", parameters: Type.Object({ path: Type.String({ description: "Path to .xlsx file" }), sheetName: Type.Optional(Type.String({ description: "Sheet name (default: first sheet)" })), maxRows: Type.Optional(Type.Number({ description: "Max rows to return (default: 100)" })), }), execute: async (_, params) => { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(params.path); const sheet = params.sheetName ? workbook.getWorksheet(params.sheetName) : workbook.worksheets[0]; if (!sheet) { return { content: [{ type: "text", text: `Sheet not found: ${params.sheetName ?? "(first sheet)"}` }], isError: true, details: {}, }; } const rows: unknown[] = []; const headers: string[] = []; const limit = params.maxRows ?? 100; sheet.eachRow((row, rowNumber) => { if (rowNumber === 1) { row.eachCell((cell) => headers.push(String(cell.value ?? ""))); } else if (rowNumber <= limit + 1) { const values: Record = {}; row.eachCell((cell, colNumber) => { values[headers[colNumber - 1] ?? `col_${colNumber}`] = cell.value; }); rows.push(values); } }); return { content: [{ type: "text", text: JSON.stringify({ sheetName: sheet.name, headers, rowCount: rows.length, rows }, null, 2), }], details: {}, }; }, }); const writeExcel = defineTool({ name: "write_excel", label: "Write Excel File", description: "Write structured data to a new Excel (.xlsx) file", parameters: Type.Object({ path: Type.String({ description: "Output file path" }), sheetName: Type.String({ description: "Sheet name", default: "Sheet1" }), data: Type.Array(Type.Record(Type.String(), Type.Unknown()), { description: "Array of row objects", }), }), execute: async (_, params) => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet(params.sheetName); if (params.data.length > 0) { const headers = Object.keys(params.data[0]); sheet.addRow(headers); for (const row of params.data) { sheet.addRow(headers.map(h => row[h] ?? "")); } } await workbook.xlsx.writeFile(params.path); return { content: [{ type: "text", text: `Wrote ${params.data.length} rows to ${params.path}` }], details: {}, }; }, }); const readCsv = defineTool({ name: "read_csv", label: "Read CSV File", description: "Parse a CSV file and return rows as JSON", parameters: Type.Object({ path: Type.String({ description: "Path to CSV file" }), delimiter: Type.Optional(Type.String({ description: "Delimiter (default: comma)", default: "," })), maxRows: Type.Optional(Type.Number({ description: "Max rows (default: 500)" })), }), execute: async (_, params) => { const fs = await import("fs/promises"); const content = await fs.readFile(params.path, "utf-8"); const delimiter = params.delimiter ?? ","; const lines = content.trim().split("\n"); const headers = lines[0].split(delimiter).map(h => h.trim().replace(/^"|"$/g, "")); const maxRows = params.maxRows ?? 500; const rows = lines.slice(1, maxRows + 1).map(line => { const values = line.split(delimiter).map(v => v.trim().replace(/^"|"$/g, "")); const obj: Record = {}; headers.forEach((h, i) => { obj[h] = values[i] ?? ""; }); return obj; }); return { content: [{ type: "text", text: JSON.stringify({ headers, rowCount: rows.length, rows }, null, 2) }], details: {}, }; }, }); return [readExcel, writeExcel, readCsv]; } ``` ### Acceptance criteria - [ ] `read_excel` reads `.xlsx` files and returns JSON - [ ] `write_excel` creates `.xlsx` files from structured data - [ ] `read_csv` parses CSV files - [ ] Tools are available in agent sessions - [ ] Export pipeline can be triggered via agent (export agent can write Excel) --- ## Phase 5: Multi-Model Support & Model Configuration **Goal:** Expose the SDK's multi-model capabilities through the existing RPC layer. Let users switch between providers/models. ### Files to modify | Action | File | Description | |--------|------|-------------| | Modify | `packages/shared/src/llm/piSdk.ts` | Expose `ModelRegistry`, model switching | | Modify | `packages/shared/src/rpc/modelRpc.ts` | Add model listing/switching RPC methods | | Modify | `packages/contracts/src/rpc.ts` | Add new RPC methods for model management | | Modify | `packages/shared/src/rpc/settingsRpc.ts` | Persist model preferences | ### New RPC methods ```typescript // Add to contracts/rpc.ts "model.list": undefined; "model.set": { provider: string; modelId: string }; "model.cycle": undefined; ``` ### Settings integration ```typescript // Store model preference in settings interface ServerSettings { // ...existing... llm: { provider: string; modelId: string; thinkingLevel: "off" | "minimal" | "low" | "medium" | "high"; }; } ``` ### Acceptance criteria - [ ] `model.list` returns available models with valid API keys - [ ] `model.set` switches the active model for all agents - [ ] Model preference persists across restarts - [ ] Thinking level is configurable - [ ] Works with Anthropic, OpenAI, and other providers --- ## Phase 6: Improved Streaming & Event Bridge **Goal:** Connect SDK's event system to the existing EventEmitter → IPC → React pipeline properly. Add abort support. ### Files to modify | Action | File | Description | |--------|------|-------------| | Modify | `packages/shared/src/agents/eventEmitter.ts` | Add abort event types | | Modify | `packages/shared/src/agents/runner.ts` | Forward SDK events to our EventEmitter | | Modify | `apps/desktop/src/main.ts` | Handle new event types | | Modify | `apps/web/src/hooks/useServerEvents.ts` | Handle tool_execution events | | Modify | `packages/contracts/src/rpc.ts` | Add tool execution event types | ### New event types to bridge ```typescript // SDK events → MosaicIQ events "tool_execution_start" → "agent.progress" (with tool name) "tool_execution_update" → "agent.streaming" (tool output) "tool_execution_end" → "agent.progress" (tool complete) "message_update" → "agent.streaming" (text delta) "agent_start" → "agent.started" "agent_end" → "agent.completed" ``` ### Abort support ```typescript // In runner.ts const controller = new AbortController(); // Store for cancellation activeSessions.set(runId, { session, controller }); // In agent.pause RPC handler const active = activeSessions.get(runId); if (active) { await active.session.abort(); } ``` ### Acceptance criteria - [ ] Tool calls appear in real-time in the UI - [ ] Text streaming works without character loss - [ ] Agent can be aborted mid-run - [ ] Error events are surfaced to the UI - [ ] Queue (steer/follow-up) works for chat --- ## Phase 7: System Prompts & Agent Specialization **Goal:** Refactor agent prompts from "data-embedded" to "instruction-only" now that tools provide data. Add proper system prompt management. ### Files to modify | Action | File | Description | |--------|------|-------------| | Modify | `packages/shared/src/llm/prompts.ts` | Rewrite all agent prompts as instructions | | Modify | `packages/shared/src/llm/context.ts` | Simplify (tools handle data now) | | Create | `packages/shared/src/llm/systemPrompts.ts` | Centralized system prompt management | ### Prompt refactoring example ```typescript // BEFORE: Data embedded (current) const AGENT_PROMPTS = { sf: (ctx) => `You are the SEC Filings Agent for ${ctx.company.name} (${ctx.company.ticker}). Available Filings: ${ctx.filings.map(f => `- ${f.formType}: ${f.title} (${f.filedDate})`).join("\n")} ... 50+ lines of data ...`, // AFTER: Instruction-only with tool access const SYSTEM_PROMPTS = { sf: `You are the SEC Filings Agent for MosaicIQ equity research. Your role: Analyze SEC filings and extract structured financial information. TOOLS AVAILABLE: - get_company_info: Get company details - get_filings: List SEC filings - get_financial_model: Get financial model data WORKFLOW: 1. Use get_company_info to identify the company 2. Use get_filings to retrieve recent filings 3. Analyze the filings for: - Segment revenue data from 10-K - Key risk factors - Changes in accounting policies 4. Cite specific sections when making claims OUTPUT: Respond with structured JSON containing: - extractedData: Key findings - sources: Filing references with sections - assumptions: Any assumptions made - confidence: "high" | "medium" | "low" RULES: - Always cite sources - Flag uncertainty - Never fabricate filing content`, }; ``` ### Acceptance criteria - [ ] All 15 agent prompts rewritten as instruction-only - [ ] Agents successfully use tools to fetch data instead of relying on embedded context - [ ] Responses maintain same quality and structure - [ ] `context.ts` simplified (no more giant context builders) --- ## Migration Strategy ### Order of execution 1. **Phase 1** → Non-breaking. SDK installed alongside existing client. 2. **Phase 2** → Additive. New tools, nothing removed. 3. **Phase 3** → **Breaking change point.** Runner switches to SDK. - Before merging: run full manual test of all agent RPCs 4. **Phase 4** → Additive. File tools enhance export capabilities. 5. **Phase 5** → Additive. New RPC methods, existing ones unchanged. 6. **Phase 6** → Enhancement. Better streaming, abort support. 7. **Phase 7** → Refactor. Prompts rewritten after everything works. ### What to delete after migration After Phase 3 is verified: - **Delete `client.ts` entirely** — remove `PI_API_URL`, `PI_API_KEY` references, raw `fetch` calls - Remove `streamStructuredResponse` / `completeStructured` (the SDK handles structured output better via tool responses) - Simplify `context.ts` (no more `buildAgentContext` with all data) - Remove `PI_API_KEY` from `agent.md`, `CLAUDE.md`, and all documentation - Update error messages in `runner.ts` and `agentRpc.ts` (currently say "Set PI_API_KEY") After Phase 7: - Remove `AgentContext` type from `prompts.ts` (no longer needed) - Remove `buildAgentContext`, `buildChatContext`, `buildMinimalContext` from `context.ts` ### Files to delete (eventually) | File | When | Why | |------|------|-----| | `packages/shared/src/llm/client.ts` | Phase 3 complete | Replaced by `piSdk.ts` | | `packages/shared/src/llm/context.ts` | Phase 7 complete | Tools replace context building | ### Risk mitigation 1. **Keep old client as fallback**: During Phase 1-2, keep the old `client.ts` working. Add a feature flag or env var to switch between old and new. 2. **Test with one agent first**: Migrate `agent.chat` first (simplest), verify end-to-end, then migrate the rest. 3. **Type safety**: The SDK is fully typed. Every tool, event, and session method is typed. Use this. 4. **Electron compatibility**: Test that `@earendil-works/pi-coding-agent` works in Electron's main process (Node.js). It should — it's a Node.js package. --- ## Dependency Changes ### `packages/shared/package.json` ```diff "dependencies": { "@mosaiciq/contracts": "workspace:*", + "@earendil-works/pi-coding-agent": "^latest", "better-sqlite3": "^12.10.0", "exceljs": "^4.4.0", "pptxgenjs": "^4.0.1" } ``` Note: The SDK brings in its own dependencies (typebox, etc.). These should not conflict with existing deps. ### Environment variables ```bash # Supported providers (SDK picks up automatically) ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY=sk-... DEEPSEEK_API_KEY=... GOOGLE_API_KEY=... # Or configure via settings file (Phase 5) ``` > **Removed:** `PI_API_KEY` and the `api.pi.ai` endpoint are no longer used. --- ## Estimated Effort | Phase | Description | Effort | |-------|-------------|--------| | 1 | SDK install + adapter layer | 1-2 hours | | 2 | Custom DB tools | 2-3 hours | | 3 | Runner migration (breaking) | 3-4 hours | | 4 | File tools (Excel, CSV) | 1-2 hours | | 5 | Multi-model support | 2-3 hours | | 6 | Streaming & event bridge | 2-3 hours | | 7 | Prompt refactoring | 2-3 hours | | **Total** | | **13-20 hours** | Phase 3 is the critical path. Everything before it is additive; everything after it is polish.