Migrate to Pi Coding Agent SDK for multi-provider LLM support

Replace the custom Pi API fetch client with the @earendil-works/pi-coding-agent
SDK, enabling support for multiple LLM providers (Anthropic, OpenAI, DeepSeek,
Google Gemini, xAI/ZAI) while maintaining backward compatibility.

Key changes:
- Add piSdk.ts with SDK session management and provider auto-detection
- Refactor client.ts to delegate to SDK adapter, keeping public API surface
- Update documentation to reflect multi-provider environment variables
- Add RPC contracts for LLM model selection and provider configuration
- Update agent runner to support provider-specific tools and parameters

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 00:17:26 -04:00
parent 506d092b2b
commit 0624026af3
30 changed files with 4326 additions and 354 deletions

View File

@@ -204,6 +204,8 @@ export type RpcRequestMap = {
"earnings.getSchedule": { companyId: string };
"filing.list": { companyId: string; since?: string };
"model.get": { companyId: string; tab: string };
"model.list": undefined;
"model.set": { provider: string; modelId: string; thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" };
"model.updateCell": { companyId: string; tab: string; row: number; col: number; value: string };
"model.createRow": { companyId: string; tab: string; label: string; kind: "actual" | "forecast" | "total"; values?: string[] };
"model.deleteRow": { companyId: string; tab: string; row: number };
@@ -267,6 +269,8 @@ export type RpcResponseMap = {
"earnings.getSchedule": { schedule: EarningsSchedule[] };
"filing.list": { filings: Filing[] };
"model.get": { headers: string[]; rows: ModelRow[] };
"model.list": { models: Array<{ provider: string; modelId: string; name: string; available: boolean }> };
"model.set": { ok: boolean; provider: string; modelId: string };
"model.updateCell": { ok: boolean; affectedCells: string[] };
"model.createRow": { row: ModelRow; position: number };
"model.deleteRow": { ok: boolean };

View File

@@ -40,6 +40,11 @@ export const ServerSettingsSchema = z.object({
agentConfigs: z.record(z.unknown()),
dataSources: z.record(z.boolean()),
exportPipelines: z.record(z.unknown()),
llm: z.object({
provider: z.string().optional(),
modelId: z.string().optional(),
thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high"]).optional(),
}).optional(),
});
const RiskInputSchema = RiskSchema.omit({ id: true, companyId: true });
@@ -65,6 +70,8 @@ export const RpcRequestSchemas = {
"earnings.getSchedule": z.object({ companyId: idString }),
"filing.list": z.object({ companyId: idString, since: z.string().optional() }),
"model.get": z.object({ companyId: idString, tab: nonEmptyString }),
"model.list": z.undefined(),
"model.set": z.object({ provider: nonEmptyString, modelId: nonEmptyString, thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high"]).optional() }),
"model.updateCell": z.object({
companyId: idString,
tab: nonEmptyString,
@@ -158,6 +165,8 @@ export const RpcResponseSchemas = {
"earnings.getSchedule": z.object({ schedule: z.array(EarningsScheduleSchema) }),
"filing.list": z.object({ filings: z.array(FilingSchema) }),
"model.get": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"model.list": z.object({ models: z.array(z.object({ provider: z.string(), modelId: z.string(), name: z.string(), available: z.boolean() })) }),
"model.set": z.object({ ok: z.boolean(), provider: z.string(), modelId: z.string() }),
"model.updateCell": z.object({ ok: z.boolean(), affectedCells: z.array(z.string()) }),
"model.createRow": z.object({ row: ModelRowSchema, position: z.number().int().min(0) }),
"model.deleteRow": z.object({ ok: z.boolean() }),

View File

@@ -25,10 +25,12 @@
}
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.74.0",
"@mosaiciq/contracts": "workspace:*",
"better-sqlite3": "^12.10.0",
"exceljs": "^4.4.0",
"pptxgenjs": "^4.0.1"
"pptxgenjs": "^4.0.1",
"typebox": "^1.1.38"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13"

View File

@@ -1,12 +1,15 @@
/**
* Individual agent execution
* Runs a single agent with proper context and error handling
*
* Now uses the Pi Coding Agent SDK for LLM interaction with custom tools
* instead of the old "build context → embed in prompt" pattern.
*/
import type { Db } from "../db/database.js";
import { streamResponse, streamStructuredResponse, isConfigured } from "../llm/client.js";
import { getAgentPrompt, getAgentMetadata } from "../llm/prompts.js";
import { buildAgentContext } from "../llm/context.js";
import { createSession, registerSession, unregisterSession, isConfigured as sdkIsConfigured } from "../llm/piSdk.js";
import { getAgentMetadata } from "../llm/prompts.js";
import { createAllTools } from "../tools/index.js";
import {
emitAgentProgress,
emitAgentStarted,
@@ -14,13 +17,21 @@ import {
emitAgentFailed,
emitAgentStreaming,
} from "./eventEmitter.js";
import {
addAgentRunStep,
createAgentRun,
getServerSettings,
listAgentRunSteps,
pauseAgent,
storeAgentOutput,
updateAgentRunCompletion,
} from "../db/queries.js";
export interface AgentRunOptions {
runId?: string;
onProgress?: (progress: number, action: string) => void;
signal?: AbortSignal;
structured?: boolean;
schema?: Record<string, unknown>;
userMessage?: string;
}
export interface AgentRunResult {
@@ -36,31 +47,185 @@ export interface AgentRunResult {
}
/**
* Execute a single agent
* Agent system prompts — instruction-only, no data embedded.
* The LLM fetches data via custom tools.
*/
const AGENT_SYSTEM_PROMPTS: Record<string, string> = {
sf: `You are the SEC Filings Agent for MosaicIQ equity research.
Your role: Analyze SEC filings and extract structured financial information.
Use the available tools to gather data:
- get_company_info: Get company details
- get_filings: List SEC filings (10-K, 10-Q, 8-K, etc.)
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, risk factors, accounting policy changes
4. Cite specific sections when making claims
Output JSON with:
- extractedData: Key findings from filings
- sources: List of filings and sections referenced
- assumptions: Any assumptions made during analysis
- confidence: "high" | "medium" | "low"
Rules: Always cite sources. Flag uncertainty. Never fabricate filing content.`,
cr: `You are the Company Research Agent for MosaicIQ equity research.
Analyze the company's business model and competitive position.
Use tools to gather data about the company, filings, earnings, catalysts, and risks.
Provide a structured analysis covering:
- businessModel: How the company makes money
- competitiveAdvantages: Key advantages
- marketPosition: Assessment of market position
- recentDevelopments: Important recent changes
- sources: Citations for your claims`,
fm: `You are the Financial Modeling Agent for MosaicIQ equity research.
Build and analyze financial projections for the company.
Use tools to get the financial model, company info, filings, and earnings data.
1. Review historical data from the model
2. Project revenue for the next 3 years
3. Model key margin drivers
4. Document assumptions clearly
5. Flag sensitive assumptions
If the model needs updates, use update_model_cell or create_model_row tools.
Output JSON with: projections, keyAssumptions, sensitivityAnalysis, confidence.`,
va: `You are the Valuation Agent for MosaicIQ equity research.
Provide a comprehensive valuation analysis.
Use tools to get the memo, financial model, and company data.
1. Apply appropriate valuation methodologies (DCF, multiples, etc.)
2. Provide a fair value estimate with range
3. Identify key value drivers
4. Discuss upside/downside scenarios
Output JSON with: methodologies, fairValue, keyDrivers, scenarios, assumptions.`,
mw: `You are the Memo Writing Agent for MosaicIQ equity research.
Synthesize research into a clear investment memo.
Use tools to get the current memo, company data, filings, and model.
Review and refine the thesis statement. Ensure coherence across sections.
If improvements are needed, use update_memo_section to revise content.
Output JSON with: refinedSections, suggestions, consistencyCheck, overallQuality.`,
pa: `You are the Presentation Agent for MosaicIQ equity research.
Create investment committee presentation materials.
Use tools to get the memo and company data.
Structure presentation for investment committee. Create slide outlines.
Output JSON with: slideOutline, keyCharts, talkingPoints, timing.`,
ec: `You are the Earnings Call Agent for MosaicIQ equity research.
Analyze earnings call data for insights.
Use tools to get earnings schedule, filings, and company data.
Extract management guidance, tone changes, and key Q&A insights.
Output JSON with: guidance, keyInsights, toneAnalysis, qAHighlights, actionItems.`,
ci: `You are the Competitive Intelligence Agent for MosaicIQ equity research.
Analyze competitive positioning and peer dynamics.
Use tools to get company data, risks, and alerts.
Identify key competitors, assess market share trends.
Output JSON with: competitors, marketPosition, recentMoves, threats, opportunities.`,
rk: `You are the Risk Assessment Agent for MosaicIQ equity research.
Identify and analyze key investment risks.
Use tools to get the memo, filings, risks, and alerts.
Categorize risks by type, assess likelihood and impact.
Output JSON with: risks, categories, highPriorityRisks, mitigationStrategies.`,
rt: `You are the Red Team Agent for MosaicIQ equity research.
Challenge the investment thesis and identify weaknesses.
Use tools to get the memo (especially thesis section), model, risks, and alerts.
1. Identify weaknesses in the thesis
2. Uncover unstated assumptions
3. Develop bear case scenarios
4. Challenge key projections
Output JSON with: counterArguments, unstatedAssumptions, bearCase, redFlags, recommendation.`,
mn: `You are the Monitoring Agent for MosaicIQ equity research.
Set up ongoing monitoring of key indicators for the company.
Use tools to get company data, catalysts, risks, and alerts.
Identify key metrics to monitor with thresholds.
Output JSON with: metricsToMonitor, alertConditions, dataSources, monitoringCadence.`,
sv: `You are the Source Verification Agent for MosaicIQ equity research.
Verify the accuracy of citations and sources in the memo.
Use tools to get the memo, filings, and validation status.
Check that claims are properly cited. Flag uncited assertions.
Output JSON with: verificationResults, flaggedClaims, sourceQuality, recommendations.`,
ex: `You are the Export Agent for MosaicIQ equity research.
Prepare data for export in various formats.
Use tools to get memo, model, and company data.
If writing Excel files, use the write_excel tool.
Output JSON with: pdfReady, excelReady, pptReady, metadata.`,
qa: `You are the Model QA Agent for MosaicIQ equity research.
Audit the financial model for errors and inconsistencies.
Use tools to get the financial model, company data, and filings.
Check for formula errors, verify consistency, run sanity checks.
Output JSON with: formulaErrors, consistencyIssues, sanityCheckResults, recommendations, overallStatus.`,
chat: `You are a research assistant for MosaicIQ equity research.
Help the analyst with questions about the company.
Use the available tools to look up company data, filings, model, memo, earnings, etc.
Answer questions based on available data. Cite sources when possible.
Admit when you don't have enough information.
Respond in a helpful, professional tone.`,
};
/**
* Get the system prompt for an agent
*/
function getAgentSystemPrompt(agentId: string): string {
return AGENT_SYSTEM_PROMPTS[agentId] ?? AGENT_SYSTEM_PROMPTS.chat;
}
/**
* Execute a single agent using the Pi SDK
*/
export async function executeAgent(
db: Db,
agentId: string,
companyId: string,
options: AgentRunOptions = {}
options: AgentRunOptions = {},
): Promise<AgentRunResult> {
const {
onProgress,
signal,
structured = false,
schema,
} = options;
const runId = options.runId ?? `run-${Date.now()}`;
const { onProgress, userMessage } = options;
const runId = options.runId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const startedAt = new Date().toISOString();
// Check if LLM is configured
if (!isConfigured()) {
const errorMsg = "LLM API key is not configured. Please set PI_API_KEY.";
if (!sdkIsConfigured()) {
const errorMsg = "LLM API key is not configured. Set ANTHROPIC_API_KEY or another supported provider key.";
console.error(`[Agent] ${errorMsg}`);
// Update agent run status in database
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
updateAgentRunCompletion(db, runId, "failed", "Configuration error", errorMsg);
return {
runId,
@@ -76,61 +241,88 @@ export async function executeAgent(
}
try {
// Update agent run status to running
updateAgentRunStatus(db, runId, agentId, companyId, "running");
// Get agent metadata
const metadata = getAgentMetadata(agentId);
const action = metadata?.description || "Running agent";
// Update agent run status to running
updateAgentRunCompletion(db, runId, "running", action);
// Emit agent started event
emitAgentStarted(runId, agentId, companyId);
onProgress?.(0, action);
emitAgentProgress(runId, agentId, companyId, 0, action);
// Build agent context
const context = await buildAgentContext(db, agentId, companyId, {
includeHistoricalData: true,
includeModel: true,
// Create custom tools with DB access for this agent
const tools = createAllTools(db);
const llmSettings = getServerSettings(db).llm;
// Create SDK session with tools
const session = await createSession({
systemPrompt: getAgentSystemPrompt(agentId),
modelSelection: llmSettings?.provider && llmSettings.modelId
? {
provider: llmSettings.provider,
modelId: llmSettings.modelId,
thinkingLevel: llmSettings.thinkingLevel,
}
: undefined,
customTools: tools,
onEvent: (event) => {
// Bridge SDK events to our EventEmitter
switch (event.type) {
case "message_update":
if (event.assistantMessageEvent.type === "text_delta") {
emitAgentStreaming(runId, agentId, companyId, event.assistantMessageEvent.delta);
}
break;
case "tool_execution_start":
emitAgentProgress(runId, agentId, companyId, 50, `Running tool: ${event.toolName}`);
onProgress?.(50, `Running tool: ${event.toolName}`);
break;
case "tool_execution_end":
emitAgentProgress(runId, agentId, companyId, 70, `Tool complete: ${event.toolName}`);
break;
case "agent_start":
emitAgentProgress(runId, agentId, companyId, 20, `Executing ${action}`);
onProgress?.(20, `Executing ${action}`);
break;
case "agent_end":
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
onProgress?.(90, `Finalizing ${action}`);
break;
}
},
});
onProgress?.(20, `Gathering data for ${action}`);
emitAgentProgress(runId, agentId, companyId, 20, `Gathering data for ${action}`);
// Register for abort/cancellation support
registerSession(runId, session);
// Get agent prompt
const prompt = getAgentPrompt(agentId, context);
// Build the prompt — focused, not data-heavy
const prompt = userMessage
? `Analyze company "${companyId}". ${userMessage}`
: `Analyze company "${companyId}" using your available tools. Provide a comprehensive analysis with structured output.`;
onProgress?.(40, `Executing ${action}`);
emitAgentProgress(runId, agentId, companyId, 40, `Executing ${action}`);
// Send prompt and wait for completion
await session.prompt(prompt);
// Extract the response
const messages = session.agent.state.messages;
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
// Execute LLM call
let rawResponse = "";
let output: unknown;
if (structured && schema) {
output = await streamStructuredResponse(prompt, schema, {
onProgress: (text) => {
rawResponse += text;
// Emit streaming event for live output
emitAgentStreaming(runId, agentId, companyId, text);
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
onProgress?.(progress, `Processing ${action}`);
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
},
signal,
});
} else {
rawResponse = await streamResponse(prompt, {
onProgress: (text) => {
// Emit streaming event for live output
emitAgentStreaming(runId, agentId, companyId, text);
const progress = 40 + Math.min(40, (rawResponse.length / 4000) * 40);
onProgress?.(progress, `Processing ${action}`);
emitAgentProgress(runId, agentId, companyId, progress, `Processing ${action}`);
},
signal,
});
if (lastAssistant && lastAssistant.content) {
// Extract text from content blocks
if (typeof lastAssistant.content === "string") {
rawResponse = lastAssistant.content;
} else if (Array.isArray(lastAssistant.content)) {
for (const block of lastAssistant.content) {
if (block.type === "text") {
rawResponse += block.text;
}
}
}
// Try to parse as JSON
try {
@@ -140,24 +332,20 @@ export async function executeAgent(
}
}
onProgress?.(90, `Finalizing ${action}`);
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
// Store agent output in database
// Store agent output
storeAgentOutput(db, runId, agentId, companyId, output, rawResponse);
updateAgentRunCompletion(db, runId, "completed", `Completed ${action}`);
// Update agent run status to completed
updateAgentRunStatus(db, runId, agentId, companyId, "completed");
onProgress?.(100, `Completed ${action}`);
emitAgentProgress(runId, agentId, companyId, 100, `Completed ${action}`);
onProgress?.(100, `Completed ${action}`);
// Calculate duration
const duration = Date.now() - new Date(startedAt).getTime();
// Emit completion event
emitAgentCompleted(runId, agentId, companyId, output, duration);
// Unregister session
unregisterSession(runId);
session.dispose();
return {
runId,
agentId,
@@ -172,9 +360,11 @@ export async function executeAgent(
const errorMsg = error instanceof Error ? error.message : String(error);
// Check if cancelled
if (signal?.aborted) {
updateAgentRunStatus(db, runId, agentId, companyId, "cancelled");
if (options.signal?.aborted) {
updateAgentRunCompletion(db, runId, "cancelled", "Cancelled by user");
emitAgentFailed(runId, agentId, companyId, "Cancelled by user");
unregisterSession(runId);
return {
runId,
agentId,
@@ -188,11 +378,9 @@ export async function executeAgent(
};
}
// Update agent run status to failed
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
// Emit failed event
updateAgentRunCompletion(db, runId, "failed", "Agent failed", errorMsg);
emitAgentFailed(runId, agentId, companyId, errorMsg);
unregisterSession(runId);
return {
runId,
@@ -208,59 +396,6 @@ export async function executeAgent(
}
}
/**
* Update agent run status in database
*/
function updateAgentRunStatus(
db: Db,
runId: string,
agentId: string,
companyId: string,
status: "running" | "completed" | "failed" | "cancelled",
error?: string
): void {
const stmt = db.prepare(`
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at${error ? ", error" : ""})
VALUES (?, ?, ?, ?, datetime('now')${error ? ", ?" : ""})
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
completed_at = CASE WHEN excluded.status IN ('completed', 'failed', 'cancelled') THEN datetime('now') ELSE completed_at END,
error = COALESCE(excluded.error, error)
`);
if (error) {
stmt.run(runId, agentId, companyId, status, error);
} else {
stmt.run(runId, agentId, companyId, status);
}
}
/**
* Store agent output in database
*/
function storeAgentOutput(
db: Db,
runId: string,
agentId: string,
companyId: string,
output: unknown,
rawResponse: string
): void {
// Store output as JSON in the action field for now
// In a production system, you'd have a separate table for agent outputs
const stmt = db.prepare(`
UPDATE agent_runs
SET action = ?, progress = 100
WHERE id = ?
`);
stmt.run(JSON.stringify({ output, rawResponse: rawResponse.slice(0, 1000) }), runId);
db.prepare(`
INSERT OR REPLACE INTO agent_outputs (run_id, agent_id, company_id, output_json, raw_response)
VALUES (?, ?, ?, ?, ?)
`).run(runId, agentId, companyId, JSON.stringify(output), rawResponse);
}
/**
* Execute agent with simple callback interface
*/
@@ -268,7 +403,7 @@ export async function executeAgentSimple(
db: Db,
agentId: string,
companyId: string,
onProgress?: (message: string) => void
onProgress?: (message: string) => void,
): Promise<unknown> {
const result = await executeAgent(db, agentId, companyId, {
onProgress: (progress, action) => {

View File

@@ -4,7 +4,7 @@
*/
import type { Db } from "../db/database.js";
import { streamStructuredResponse, completeStructured, isConfigured } from "../llm/client.js";
import { complete, isConfigured } from "../llm/client.js";
import { getAgentPrompt, type AgentContext } from "../llm/prompts.js";
import { buildAgentContext } from "../llm/context.js";
import { emitValidationUpdated } from "./eventEmitter.js";
@@ -69,7 +69,14 @@ export async function executeSourceVerification(
recommendations: ["string"],
};
const result = await completeStructured(prompt, schema);
const rawResult = await complete(prompt);
let result: any;
try {
result = JSON.parse(rawResult);
} catch {
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
}
// Extract issues from the result
const issues: ValidationResult["issues"] = [];
@@ -172,7 +179,14 @@ export async function executeModelQA(
overallStatus: ["pass", "warn", "fail"],
};
const result = await completeStructured(prompt, schema);
const rawResult = await complete(prompt);
let result: any;
try {
result = JSON.parse(rawResult);
} catch {
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
}
// Extract issues from the result
const issues: ValidationResult["issues"] = [];
@@ -288,7 +302,14 @@ export async function executeRedTeam(
recommendation: ["proceed_with_caution", "acceptable", "needs_revision"],
};
const result = await completeStructured(prompt, schema);
const rawResult = await complete(prompt);
let result: any;
try {
result = JSON.parse(rawResult);
} catch {
const jsonMatch = rawResult.match(/\{[\s\S]*\}/);
result = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
}
// Extract issues from the result
const issues: ValidationResult["issues"] = [];

View File

@@ -830,7 +830,7 @@ export function storeAgentOutput(db: Db, runId: string, agentId: string, company
export function updateAgentRunCompletion(
db: Db,
runId: string,
status: "completed" | "failed" | "cancelled",
status: "queued" | "running" | "completed" | "failed" | "cancelled",
action: string,
error?: string
): void {
@@ -1056,7 +1056,7 @@ export function getServerSettings(db: Db): ServerSettings {
}
}
const settingsStmt = db.prepare("SELECT key, value FROM client_settings WHERE key IN ('server.dataSources', 'server.exportPipelines')");
const settingsStmt = db.prepare("SELECT key, value FROM client_settings WHERE key IN ('server.dataSources', 'server.exportPipelines', 'server.llm')");
const settingsRows = settingsStmt.all() as Array<{ key: string; value: string }>;
let dataSources: Record<string, boolean> = {
sec_filings: true,
@@ -1066,6 +1066,7 @@ export function getServerSettings(db: Db): ServerSettings {
press_releases: true,
};
let exportPipelines: Record<string, unknown> = {};
let llm: ServerSettings["llm"] = undefined;
for (const row of settingsRows) {
try {
const parsed = JSON.parse(row.value) as unknown;
@@ -1075,6 +1076,9 @@ export function getServerSettings(db: Db): ServerSettings {
} else if (row.key === "server.exportPipelines") {
const value = z.record(z.unknown()).safeParse(parsed);
if (value.success) exportPipelines = value.data;
} else if (row.key === "server.llm") {
const value = ServerSettingsSchema.shape.llm.safeParse(parsed);
if (value.success) llm = value.data;
}
} catch {
// Ignore malformed server settings.
@@ -1085,6 +1089,7 @@ export function getServerSettings(db: Db): ServerSettings {
agentConfigs,
dataSources,
exportPipelines,
...(llm ? { llm } : {}),
});
}
@@ -1114,4 +1119,7 @@ export function updateServerSettings(
if (settings.exportPipelines !== undefined) {
updateClientSetting(db, "server.exportPipelines", settings.exportPipelines);
}
if (settings.llm !== undefined) {
updateClientSetting(db, "server.llm", settings.llm);
}
}

View File

@@ -1,12 +1,18 @@
/**
* LLM client for Pi API (Inflection AI)
* Provides streaming and non-streaming completion functions
* LLM client — delegates to the Pi Coding Agent SDK
*
* This module keeps the same public API surface (streamResponse, complete,
* isConfigured) but delegates all LLM interaction to the SDK adapter in
* `piSdk.ts`.
*
* The old raw-fetch-to-api.pi.ai implementation has been removed.
*/
const PI_API_URL = "https://api.pi.ai/v1/chat/completions";
const client = {
apiKey: process.env.PI_API_KEY || "",
};
import { createSession, isConfigured as sdkIsConfigured } from "./piSdk.js";
// ---------------------------------------------------------------------------
// Types (kept for backward compatibility)
// ---------------------------------------------------------------------------
export interface StreamOptions {
onProgress?: (text: string) => void;
@@ -19,189 +25,79 @@ export interface CompletionOptions {
temperature?: number;
}
const DEFAULT_MODEL = "pi";
const DEFAULT_MAX_TOKENS = 4096;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Stream a response from Pi
* Check if an API key is configured for any supported provider.
* Delegates to the SDK adapter which checks both env vars and auth.json.
*/
export function isConfigured(): boolean {
return sdkIsConfigured();
}
/**
* Stream a response from the LLM.
*
* Creates a one-shot in-memory SDK session, sends the prompt, and collects
* the streamed text via the event subscription.
*/
export async function streamResponse(
prompt: string,
options: StreamOptions & CompletionOptions = {}
options: StreamOptions & CompletionOptions = {},
): Promise<string> {
const {
model = DEFAULT_MODEL,
maxTokens = DEFAULT_MAX_TOKENS,
temperature = 0,
onProgress,
signal,
} = options;
const { onProgress } = options;
if (!client.apiKey) {
throw new Error("PI_API_KEY is not set");
if (!isConfigured()) {
throw new Error(
"No LLM API key configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, or another provider key.",
);
}
const response = await fetch(PI_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${client.apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokens,
temperature,
stream: true,
}),
signal,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Pi API error: ${response.status} ${error}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Failed to get response body reader");
}
const decoder = new TextDecoder();
const session = await createSession();
let fullResponse = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
try {
const data = JSON.parse(line.slice(6));
const content = data.choices?.[0]?.delta?.content;
if (content) {
fullResponse += content;
onProgress?.(content);
}
} catch {
// Skip invalid JSON
}
}
session.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
const delta = event.assistantMessageEvent.delta;
fullResponse += delta;
onProgress?.(delta);
}
}
});
await session.prompt(prompt);
return fullResponse;
}
/**
* Get a complete response from Pi (non-streaming)
* Get a complete (non-streaming) response from the LLM.
*/
export async function complete(
prompt: string,
options: CompletionOptions = {}
_options: CompletionOptions = {},
): Promise<string> {
const {
model = DEFAULT_MODEL,
maxTokens = DEFAULT_MAX_TOKENS,
temperature = 0,
} = options;
if (!client.apiKey) {
throw new Error("PI_API_KEY is not set");
if (!isConfigured()) {
throw new Error(
"No LLM API key configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, or another provider key.",
);
}
const response = await fetch(PI_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${client.apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokens,
temperature,
}),
const session = await createSession();
let result = "";
session.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
result += event.assistantMessageEvent.delta;
}
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Pi API error: ${response.status} ${error}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content ?? "";
await session.prompt(prompt);
return result;
}
/**
* Check if API key is configured
*/
export function isConfigured(): boolean {
return !!client.apiKey && client.apiKey.length > 0;
}
/**
* Stream structured JSON response
* Useful for agents that need to return structured data
*/
export async function streamStructuredResponse<T>(
prompt: string,
schema: Record<string, unknown>,
options: StreamOptions & CompletionOptions = {}
): Promise<T> {
const structuredPrompt = `${prompt}
Please respond with a JSON object that follows this structure:
${JSON.stringify(schema, null, 2)}
Your response must be valid JSON only, with no additional text or explanation.`;
const response = await streamResponse(structuredPrompt, options);
// Try to parse as JSON
try {
return JSON.parse(response) as T;
} catch (error) {
// If the response isn't valid JSON, try to extract JSON from the response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]) as T;
}
throw new Error("Failed to parse structured response as JSON");
}
}
/**
* Complete with structured JSON response (non-streaming)
*/
export async function completeStructured<T>(
prompt: string,
schema: Record<string, unknown>,
options: CompletionOptions = {}
): Promise<T> {
const structuredPrompt = `${prompt}
Please respond with a JSON object that follows this structure:
${JSON.stringify(schema, null, 2)}
Your response must be valid JSON only, with no additional text or explanation.`;
const response = await complete(structuredPrompt, options);
// Try to parse as JSON
try {
return JSON.parse(response) as T;
} catch (error) {
// If the response isn't valid JSON, try to extract JSON from the response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]) as T;
}
throw new Error("Failed to parse structured response as JSON");
}
}
export default client;

View File

@@ -1,7 +1,22 @@
/**
* LLM module exports
*
* isConfigured is exported from client.ts (which has its own implementation).
* The piSdk.ts also has isConfigured but it's only exported here for direct
* access to SDK internals.
*/
export * from "./client.js";
export { complete, streamResponse, isConfigured } from "./client.js";
export {
createSession,
getAuthStorage,
getModelRegistry,
setActiveModelSelection,
getActiveModelSelection,
registerSession,
unregisterSession,
abortSession,
} from "./piSdk.js";
export type { CreateSessionOptions, ModelSelection } from "./piSdk.js";
export * from "./prompts.js";
export * from "./context.js";

View File

@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from "vitest";
import { createSession, setActiveModelSelection } from "./piSdk.js";
describe("Pi SDK adapter", () => {
afterEach(() => {
delete process.env.ZAI_API_KEY;
setActiveModelSelection(undefined);
});
it("creates sessions with the selected ZAI GLM model and system prompt", async () => {
process.env.ZAI_API_KEY = "test-zai-key";
const session = await createSession({
systemPrompt: "You are a MosaicIQ test agent.",
modelSelection: { provider: "zai", modelId: "glm-5.1", thinkingLevel: "medium" },
});
expect(session.model?.provider).toBe("zai");
expect(session.model?.id).toBe("glm-5.1");
expect(session.thinkingLevel).toBe("medium");
expect(session.systemPrompt).toBe("You are a MosaicIQ test agent.");
session.dispose();
});
});

View File

@@ -0,0 +1,188 @@
/**
* Pi Coding Agent SDK adapter for MosaicIQ
*
* This module wraps the Pi Coding Agent SDK (NOT the Pi/Inflection AI chatbot API).
* The SDK is an agent framework that supports multiple LLM providers (Anthropic,
* OpenAI, DeepSeek, Google, etc.). We do NOT use the Pi/Inflection AI service.
*/
import {
AuthStorage,
createAgentSession,
ModelRegistry,
SessionManager,
SettingsManager,
type AgentSession,
type AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
// ---------------------------------------------------------------------------
// Lazy-initialized singletons
// ---------------------------------------------------------------------------
let authStorage: AuthStorage | undefined;
let modelRegistry: ModelRegistry | undefined;
let activeModelSelection: ModelSelection | undefined;
export interface ModelSelection {
provider: string;
modelId: string;
thinkingLevel?: ThinkingLevel;
}
export function getAuthStorage(): AuthStorage {
if (!authStorage) {
authStorage = AuthStorage.create();
}
return authStorage;
}
export function getModelRegistry(): ModelRegistry {
if (!modelRegistry) {
modelRegistry = ModelRegistry.create(getAuthStorage());
}
return modelRegistry;
}
// ---------------------------------------------------------------------------
// Check configuration
// ---------------------------------------------------------------------------
/**
* Returns true if at least one supported provider has an API key configured
* (via env var or auth storage / auth.json).
*
* Checks environment variables first (fast, synchronous), then falls back to
* the SDK's ModelRegistry which inspects auth.json.
*/
export function isConfigured(): boolean {
// Fast path: check env vars synchronously
if (
process.env.ANTHROPIC_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.DEEPSEEK_API_KEY ||
process.env.GEMINI_API_KEY ||
process.env.ZAI_API_KEY
) {
return true;
}
// Slow path: check auth.json via the SDK registry
try {
const registry = getModelRegistry();
return registry.getAvailable().length > 0;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Session factory
// ---------------------------------------------------------------------------
export interface CreateSessionOptions {
/** System prompt injected at session creation */
systemPrompt?: string;
/** Explicit model selection. Falls back to runtime selection, then SDK defaults. */
modelSelection?: ModelSelection;
/** Custom tools available to the agent */
customTools?: unknown[];
/** Event listener for streaming / lifecycle events */
onEvent?: (event: AgentSessionEvent) => void;
}
export function setActiveModelSelection(selection: ModelSelection | undefined): void {
activeModelSelection = selection;
}
export function getActiveModelSelection(): ModelSelection | undefined {
return activeModelSelection;
}
/**
* Create an in-memory agent session with custom tools.
*
* We deliberately:
* - Use `SessionManager.inMemory()` — MosaicIQ has its own SQLite persistence.
* - Set `compaction: { enabled: false }` — agents are short-lived per run.
* - Pass `tools: []` — no built-in coding tools (read/write/edit/bash).
* Only our domain-specific custom tools are provided.
*/
export async function createSession(
options: CreateSessionOptions = {},
): Promise<AgentSession> {
const registry = getModelRegistry();
const modelSelection = options.modelSelection ?? activeModelSelection;
const selectedModel = modelSelection
? registry.find(modelSelection.provider, modelSelection.modelId)
: undefined;
if (modelSelection && !selectedModel) {
throw new Error(`Model "${modelSelection.provider}/${modelSelection.modelId}" is not known to the Pi SDK.`);
}
if (selectedModel && !registry.hasConfiguredAuth(selectedModel)) {
throw new Error(
`Model "${selectedModel.provider}/${selectedModel.id}" is selected, but no API key is configured for provider "${selectedModel.provider}".`,
);
}
const { session } = await createAgentSession({
model: selectedModel,
thinkingLevel: modelSelection?.thinkingLevel,
sessionManager: SessionManager.inMemory(),
authStorage: getAuthStorage(),
modelRegistry: registry,
settingsManager: SettingsManager.inMemory({
...(modelSelection
? {
defaultProvider: modelSelection.provider,
defaultModel: modelSelection.modelId,
defaultThinkingLevel: modelSelection.thinkingLevel,
}
: {}),
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 3 },
}),
tools: [],
customTools: (options.customTools ?? []) as any[],
});
if (options.onEvent) {
session.subscribe(options.onEvent);
}
if (options.systemPrompt) {
const agentSession = session as unknown as { _baseSystemPrompt?: string };
agentSession._baseSystemPrompt = options.systemPrompt;
session.agent.state.systemPrompt = options.systemPrompt;
}
return session;
}
// ---------------------------------------------------------------------------
// Active sessions (for abort / cancellation)
// ---------------------------------------------------------------------------
const activeSessions = new Map<string, AgentSession>();
export function registerSession(runId: string, session: AgentSession): void {
activeSessions.set(runId, session);
}
export function unregisterSession(runId: string): void {
activeSessions.delete(runId);
}
export async function abortSession(runId: string): Promise<boolean> {
const session = activeSessions.get(runId);
if (session) {
await session.abort();
activeSessions.delete(runId);
return true;
}
return false;
}

View File

@@ -1,9 +1,17 @@
import type { Db } from "../db/database.js";
import { addAgentRunStep, createAgentRun, getPortfolio, listAgentRunSteps, listAgents, pauseAgent, storeAgentOutput, updateAgentConfig, updateAgentRunCompletion } from "../db/queries.js";
import {
addAgentRunStep,
createAgentRun,
getPortfolio,
listAgentRunSteps,
listAgents,
pauseAgent,
storeAgentOutput,
updateAgentConfig,
updateAgentRunCompletion,
} from "../db/queries.js";
import { executeAgent } from "../agents/runner.js";
import { buildChatContext } from "../llm/context.js";
import { complete, isConfigured } from "../llm/client.js";
import { getAgentPrompt } from "../llm/prompts.js";
import { isConfigured } from "../llm/client.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
@@ -20,6 +28,7 @@ type AgentMethod =
export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
return {
"agent.list": ({ companyId }) => ok("agent.list", { agents: listAgents(db, companyId) }),
"agent.start": ({ agentId, companyId }) => {
const { runId } = createAgentRun(db, agentId, companyId, "queued");
void executeAgent(db, agentId, companyId, { runId }).catch((error) => {
@@ -27,7 +36,9 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
});
return ok("agent.start", { runId });
},
"agent.pause": ({ agentId }) => ok("agent.pause", pauseAgent(db, agentId)),
"agent.restart": ({ agentId }) => {
const companyId = getPortfolio(db)?.activeCompanyId;
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before restarting an agent.");
@@ -37,32 +48,48 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
});
return ok("agent.restart", { runId });
},
"agent.chat": async ({ agentId, message }) => {
const portfolio = getPortfolio(db);
const companyId = portfolio?.activeCompanyId;
if (!companyId) return fail("VALIDATION_ERROR", "Select or add a company before chatting with an agent.");
if (!isConfigured()) return fail("AGENT_FAILED", "LLM API key is not configured. Set PI_API_KEY to use agent chat.");
if (!isConfigured()) return fail("AGENT_FAILED", "LLM API key is not configured. Set ANTHROPIC_API_KEY or another supported provider key to use agent chat.");
const { runId } = createAgentRun(db, agentId, companyId, "running");
addAgentRunStep(db, runId, 1, "Build context", "Loaded local company, memo, filings, and model context.");
addAgentRunStep(db, runId, 1, "Build context", "Using SDK session with custom tools for company data.");
try {
const context = await buildChatContext(db, companyId, message);
// Use the new SDK-based runner for chat too
addAgentRunStep(db, runId, 2, "Generate response", `Sent analyst message to ${agentId}.`);
const response = await complete(getAgentPrompt("chat", context));
storeAgentOutput(db, runId, agentId, companyId, { response }, response);
updateAgentRunCompletion(db, runId, "completed", "Chat response generated");
addAgentRunStep(db, runId, 3, "Persist response", "Saved chat response to the local run log.");
return ok("agent.chat", { response });
const result = await executeAgent(db, agentId, companyId, {
runId,
userMessage: message,
});
if (result.status === "completed" && typeof result.output === "object" && result.output !== null) {
const chatOutput = result.output as { response?: string; text?: string };
const response = chatOutput.response ?? chatOutput.text ?? result.rawResponse;
storeAgentOutput(db, runId, agentId, companyId, { response }, result.rawResponse);
updateAgentRunCompletion(db, runId, "completed", "Chat response generated");
addAgentRunStep(db, runId, 3, "Persist response", "Saved chat response to the local run log.");
return ok("agent.chat", { response });
} else {
throw new Error(result.error || "Agent chat failed");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
updateAgentRunCompletion(db, runId, "failed", "Chat failed", message);
return fail("AGENT_FAILED", message, error);
const msg = error instanceof Error ? error.message : String(error);
updateAgentRunCompletion(db, runId, "failed", "Chat failed", msg);
return fail("AGENT_FAILED", msg, error);
}
},
"agent.configure": ({ agentId, config }) => {
updateAgentConfig(db, agentId, config);
return ok("agent.configure", { ok: true });
},
"agent.getTrace": ({ runId }) => ok("agent.getTrace", { steps: listAgentRunSteps(db, runId) }),
"agent.runPipeline": ({ companyId, pipeline }) => {
const pipelineAgents: Record<string, string[]> = {
research: ["sf", "cr", "fm", "va", "mw", "pa"],
@@ -71,6 +98,7 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
};
const agentIds = pipelineAgents[pipeline];
if (!agentIds) return fail("VALIDATION_ERROR", `Unknown pipeline: ${pipeline}`);
const runIds = agentIds.map((pipelineAgentId) => {
const { runId } = createAgentRun(db, pipelineAgentId, companyId, "queued");
void executeAgent(db, pipelineAgentId, companyId, { runId }).catch((error) => {
@@ -78,6 +106,7 @@ export function agentHandlers(db: Db): RpcHandlers<AgentMethod> {
});
return runId;
});
return ok("agent.runPipeline", { runIds });
},
};

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { getActiveModelSelection, setActiveModelSelection } from "../llm/piSdk.js";
describe("model RPC", () => {
let db: Db;
@@ -13,6 +14,8 @@ describe("model RPC", () => {
});
afterEach(() => {
delete process.env.ZAI_API_KEY;
setActiveModelSelection(undefined);
closeDatabase(db);
});
@@ -42,4 +45,30 @@ describe("model RPC", () => {
expect(result.error.code).toBe("INTERNAL_ERROR");
expect(result.error.message).toBe("Could not load model for company.");
});
it("lists SDK-discovered ZAI GLM models", async () => {
process.env.ZAI_API_KEY = "test-zai-key";
const result = await rpc("model.list", undefined);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.data.models).toContainEqual(
expect.objectContaining({
provider: "zai",
modelId: "glm-5.1",
name: "GLM-5.1",
available: true,
}),
);
});
it("sets the runtime SDK model selection", async () => {
process.env.ZAI_API_KEY = "test-zai-key";
const result = await rpc("model.set", { provider: "zai", modelId: "glm-5.1" });
expect(result).toEqual({ ok: true, data: { ok: true, provider: "zai", modelId: "glm-5.1" } });
expect(getActiveModelSelection()).toEqual({ provider: "zai", modelId: "glm-5.1" });
});
});

View File

@@ -1,5 +1,6 @@
import type { Db } from "../db/database.js";
import { createModelRow, deleteModelRow, getModel, resolveCompany, updateModelCell } from "../db/queries.js";
import { getModelRegistry, setActiveModelSelection } from "../llm/piSdk.js";
import { fail, ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
@@ -10,7 +11,17 @@ function errorDetail(operation: string, error: unknown): { operation: string; me
};
}
export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCell" | "model.createRow" | "model.deleteRow" | "model.runScenario"> {
const DISPLAYED_LLM_PROVIDERS = new Set(["anthropic", "openai", "deepseek", "google", "zai"]);
export function modelHandlers(db: Db): RpcHandlers<
"model.get" |
"model.list" |
"model.set" |
"model.updateCell" |
"model.createRow" |
"model.deleteRow" |
"model.runScenario"
> {
return {
"model.get": ({ companyId, tab }) => {
const company = resolveCompany(db, companyId);
@@ -21,21 +32,54 @@ export function modelHandlers(db: Db): RpcHandlers<"model.get" | "model.updateCe
return fail("INTERNAL_ERROR", "Could not load model for company.", errorDetail("getModel", error));
}
},
"model.list": async () => {
const registry = getModelRegistry();
const available = await registry.getAvailable();
const availableIds = new Set(available.map((m) => `${m.provider}/${m.id}`));
const models = registry
.getAll()
.filter((model) => DISPLAYED_LLM_PROVIDERS.has(model.provider))
.map((model) => ({
provider: model.provider,
modelId: model.id,
name: model.name ?? model.id,
available: availableIds.has(`${model.provider}/${model.id}`),
}));
return ok("model.list", { models });
},
"model.set": ({ provider, modelId, thinkingLevel }) => {
const registry = getModelRegistry();
const model = registry.find(provider, modelId);
if (!model) return fail("VALIDATION_ERROR", `Model "${provider}/${modelId}" is not supported by the Pi SDK.`);
if (!registry.hasConfiguredAuth(model)) {
return fail("VALIDATION_ERROR", `No API key is configured for provider "${provider}".`);
}
setActiveModelSelection({ provider, modelId, thinkingLevel });
return ok("model.set", { ok: true, provider, modelId });
},
"model.updateCell": ({ companyId, tab, row, col, value }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.updateCell", updateModelCell(db, company.id, tab, row, col, value));
},
"model.createRow": ({ companyId, tab, label, kind, values }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.createRow", createModelRow(db, company.id, tab, { label, kind, values: values ?? [] }));
},
"model.deleteRow": ({ companyId, tab, row }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
return ok("model.deleteRow", { ok: deleteModelRow(db, company.id, tab, row) });
},
"model.runScenario": ({ companyId, overrides }) => {
const company = resolveCompany(db, companyId);
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, type Db, initDatabase } from "../db/database.js";
import { createRpcHandler } from "../db/rpcHandler.js";
import { getActiveModelSelection, setActiveModelSelection } from "../llm/piSdk.js";
describe("settings RPC", () => {
let db: Db;
@@ -12,6 +13,7 @@ describe("settings RPC", () => {
});
afterEach(() => {
setActiveModelSelection(undefined);
closeDatabase(db);
});
@@ -40,4 +42,18 @@ describe("settings RPC", () => {
if (!get.ok) return;
expect(get.data.settings).toMatchObject({ agentConfigs: { "agent-1": { enabled: true } } });
});
it("updates runtime LLM model selection from server settings", async () => {
const update = await rpc("settings.update", {
scope: "server",
changes: { llm: { provider: "zai", modelId: "glm-5.1", thinkingLevel: "medium" } },
});
expect(update).toEqual({ ok: true, data: { ok: true } });
expect(getActiveModelSelection()).toEqual({
provider: "zai",
modelId: "glm-5.1",
thinkingLevel: "medium",
});
});
});

View File

@@ -1,6 +1,7 @@
import type { ClientSettings, ServerSettings } from "@mosaiciq/contracts/rpc";
import type { Db } from "../db/database.js";
import { getClientSettings, getServerSettings, updateClientSettings, updateServerSettings } from "../db/queries.js";
import { setActiveModelSelection } from "../llm/piSdk.js";
import { ok } from "./result.js";
import type { RpcHandlers } from "./types.js";
@@ -14,7 +15,15 @@ export function settingsHandlers(db: Db): RpcHandlers<"settings.get" | "settings
if (scope === "client") {
updateClientSettings(db, changes as Partial<ClientSettings>);
} else {
updateServerSettings(db, changes as Partial<ServerSettings>);
const serverChanges = changes as Partial<ServerSettings>;
updateServerSettings(db, serverChanges);
if (serverChanges.llm?.provider && serverChanges.llm.modelId) {
setActiveModelSelection({
provider: serverChanges.llm.provider,
modelId: serverChanges.llm.modelId,
thinkingLevel: serverChanges.llm.thinkingLevel,
});
}
}
return ok("settings.update", { ok: true });
},

View File

@@ -0,0 +1,208 @@
/**
* Custom tools: Company, filings, and earnings data access
*/
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import type { Db } from "../db/database.js";
import {
getCompany,
getCompanyByTicker,
resolveCompany,
listFilings,
getEarningsSchedule,
listCatalysts,
listRisks,
listAlerts,
} 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, price, thesis, and metadata. Accepts either a company ID or ticker symbol.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol (e.g. 'AAPL' or 'company-123')",
}),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [
{
type: "text" as const,
text: `Company "${params.companyIdOrTicker}" not found.`,
},
],
isError: true,
details: {},
};
}
return {
content: [
{
type: "text" as const,
text: JSON.stringify(company, null, 2),
},
],
details: {},
};
},
});
const getFilingsTool = defineTool({
name: "get_filings",
label: "Get SEC Filings",
description:
"List SEC filings for a company (10-K, 10-Q, 8-K, DEF 14A, etc.). Returns form type, title, filed date, and key changes.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol",
}),
limit: Type.Optional(
Type.Number({ description: "Max filings to return (default 20)", default: 20 }),
),
since: Type.Optional(
Type.String({ description: "Only filings after this date (YYYY-MM-DD)" }),
),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const filings = listFilings(db, company.id, params.since);
const limited = filings.slice(0, params.limit ?? 20);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{ companyId: company.id, ticker: company.ticker, count: limited.length, filings: limited },
null,
2,
),
},
],
details: {},
};
},
});
const getEarningsTool = defineTool({
name: "get_earnings",
label: "Get Earnings Schedule",
description:
"Get upcoming and historical earnings dates, actual vs expected revenue and EPS.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol",
}),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const schedule = getEarningsSchedule(db, company.id);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(schedule, null, 2),
},
],
details: {},
};
},
});
const getCatalystsTool = defineTool({
name: "get_catalysts",
label: "Get Catalysts",
description: "List catalysts (upcoming events) for a company with impact assessment and thesis relevance.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol",
}),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const catalysts = listCatalysts(db, company.id);
return {
content: [{ type: "text" as const, text: JSON.stringify(catalysts, null, 2) }],
details: {},
};
},
});
const getRisksTool = defineTool({
name: "get_risks",
label: "Get Risks",
description: "List identified risks for a company, categorized by type with severity and likelihood.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol",
}),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const risks = listRisks(db, company.id);
return {
content: [{ type: "text" as const, text: JSON.stringify(risks, null, 2) }],
details: {},
};
},
});
const getAlertsTool = defineTool({
name: "get_alerts",
label: "Get Alerts",
description: "List recent alerts (filing, price move, earnings surprise, peer event) for a company.",
parameters: Type.Object({
companyIdOrTicker: Type.Optional(
Type.String({ description: "Company ID or ticker symbol (omit for all companies)" }),
),
since: Type.Optional(
Type.String({ description: "Only alerts after this timestamp" }),
),
}),
execute: async (_, params) => {
const company = params.companyIdOrTicker
? resolveCompany(db, params.companyIdOrTicker)
: undefined;
const alerts = listAlerts(db, company?.id, params.since);
return {
content: [{ type: "text" as const, text: JSON.stringify(alerts, null, 2) }],
details: {},
};
},
});
return [getCompanyInfo, getFilingsTool, getEarningsTool, getCatalystsTool, getRisksTool, getAlertsTool];
}

View File

@@ -0,0 +1,250 @@
/**
* Custom tools: File manipulation (Excel, CSV)
*
* The Pi SDK's built-in read/write tools only handle text files.
* These tools let agents read and write Excel, CSV, and other financial file formats.
*/
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import ExcelJS from "exceljs";
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 limits.",
parameters: Type.Object({
path: Type.String({ description: "Path to the .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)", default: 100 }),
),
}),
execute: async (_, params) => {
try {
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" as const,
text: `Sheet not found: ${params.sheetName ?? "(first sheet)"}`,
},
],
isError: true,
details: {},
};
}
const headers: string[] = [];
const rows: Record<string, unknown>[] = [];
const limit = params.maxRows ?? 100;
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) {
row.eachCell((cell) => {
headers.push(String(cell.value ?? `col_${headers.length}`));
});
} else if (rowNumber <= limit + 1) {
const values: Record<string, unknown> = {};
row.eachCell((cell, colNumber) => {
values[headers[colNumber - 1] ?? `col_${colNumber}`] = cell.value;
});
rows.push(values);
}
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{ file: params.path, sheetName: sheet.name, headers, rowCount: rows.length, rows },
null,
2,
),
},
],
details: {},
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Failed to read Excel file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
details: {},
};
}
},
});
const writeExcel = defineTool({
name: "write_excel",
label: "Write Excel File",
description:
"Write structured data to a new Excel (.xlsx) file. Data is an array of objects (each object is a row, keys are column headers).",
parameters: Type.Object({
path: Type.String({ description: "Output file path (must end in .xlsx)" }),
sheetName: Type.String({
description: "Sheet name",
default: "Sheet1",
}),
data: Type.Array(Type.Record(Type.String(), Type.Unknown()), {
description: "Array of row objects. Keys become column headers.",
}),
}),
execute: async (_, params) => {
try {
const workbook = new ExcelJS.Workbook();
workbook.creator = "MosaicIQ";
workbook.created = new Date();
const sheet = workbook.addWorksheet(params.sheetName);
if (params.data.length > 0) {
const headers = Object.keys(params.data[0]);
sheet.addRow(headers);
// Style header row
const headerRow = sheet.getRow(1);
headerRow.font = { bold: true };
for (const rowData of params.data) {
sheet.addRow(headers.map((h) => rowData[h] ?? ""));
}
// Auto-fit column widths (approximate)
for (let i = 0; i < headers.length; i++) {
const maxLen = Math.max(
headers[i].length,
...params.data.slice(0, 20).map((r: Record<string, unknown>) => String(r[headers[i]] ?? "").length),
);
sheet.getColumn(i + 1).width = Math.min(maxLen + 2, 40);
}
}
await workbook.xlsx.writeFile(params.path);
return {
content: [
{
type: "text" as const,
text: `Wrote ${params.data.length} rows to ${params.path} (sheet: ${params.sheetName})`,
},
],
details: {},
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Failed to write Excel file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
details: {},
};
}
},
});
const readCsv = defineTool({
name: "read_csv",
label: "Read CSV File",
description:
"Parse a CSV file and return rows as JSON objects. Handles quoted fields and custom delimiters.",
parameters: Type.Object({
path: Type.String({ description: "Path to the CSV file" }),
delimiter: Type.Optional(
Type.String({ description: "Field delimiter (default: comma)", default: "," }),
),
maxRows: Type.Optional(
Type.Number({ description: "Max rows to return (default: 500)", default: 500 }),
),
}),
execute: async (_, params) => {
try {
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 = splitCsvLine(line, delimiter);
const obj: Record<string, string> = {};
headers.forEach((h, i) => {
obj[h] = (values[i] ?? "").trim().replace(/^"|"$/g, "");
});
return obj;
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ file: params.path, headers, rowCount: rows.length, rows }, null, 2),
},
],
details: {},
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Failed to read CSV file: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
details: {},
};
}
},
});
return [readExcel, writeExcel, readCsv];
}
/**
* Simple CSV line splitter that respects quoted fields.
*/
function splitCsvLine(line: string, delimiter: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
result.push(current);
current = "";
} else {
current += char;
}
}
result.push(current);
return result;
}

View File

@@ -0,0 +1,27 @@
/**
* Tool barrel export
*/
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";
import { createFileTools } from "./fileTools.js";
/**
* Create all custom tools for a given DB instance.
* These tools are passed to the Pi SDK session so the LLM can access
* MosaicIQ's SQLite data and manipulate files directly.
*/
export function createAllTools(db: Db) {
return [
...createCompanyTools(db),
...createModelTools(db),
...createMemoTools(db),
...createValidationTools(db),
...createFileTools(),
];
}
export { createCompanyTools, createModelTools, createMemoTools, createValidationTools, createFileTools };

View File

@@ -0,0 +1,165 @@
/**
* Custom tools: Investment memo read/write
*/
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import type { Db } from "../db/database.js";
import {
resolveCompany,
getMemo,
updateMemoSection,
addMemoAnnotation,
resolveMemoAnnotation,
updateMemoSectionReview,
} from "../db/queries.js";
export function createMemoTools(db: Db) {
const getMemoTool = defineTool({
name: "get_memo",
label: "Get Investment Memo",
description:
"Get the full investment memo for a company including all sections, citations, annotations, and review status.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const memo = getMemo(db, company.id);
return {
content: [{ type: "text" as const, text: JSON.stringify(memo, null, 2) }],
details: {} as any,
};
},
});
const updateSectionTool = defineTool({
name: "update_memo_section",
label: "Update Memo Section",
description:
"Update a specific section of the investment memo. Use this to write or revise content.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
sectionId: Type.String({ description: "Section ID (e.g. 'thesis', 'variant-perception', 'valuation')" }),
content: Type.String({ description: "New section content in markdown" }),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
try {
const section = updateMemoSection(db, company.id, params.sectionId, {
content: params.content,
});
return {
content: [
{ type: "text" as const, text: `Updated section "${params.sectionId}"` },
],
details: { section } as any,
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Failed to update section: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
details: {} as any,
};
}
},
});
const addAnnotationTool = defineTool({
name: "add_memo_annotation",
label: "Add Memo Annotation",
description:
"Add an annotation (highlight, comment, or strike) to a memo section for review.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
sectionId: Type.String({ description: "Section ID" }),
kind: Type.Union(
[Type.Literal("highlight"), Type.Literal("comment"), Type.Literal("strike")],
{ description: "Annotation kind" },
),
selectedText: Type.String({ description: "The text being annotated" }),
comment: Type.Optional(Type.String({ description: "Comment text (required for comment kind)" })),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const annotation = addMemoAnnotation(db, company.id, {
sectionId: params.sectionId,
kind: params.kind,
selectedText: params.selectedText,
comment: params.comment,
createdBy: "AI",
createdAt: new Date().toISOString(),
status: "open",
});
return {
content: [{ type: "text" as const, text: `Added ${params.kind} annotation` }],
details: { annotation } as any,
};
},
});
const reviewSectionTool = defineTool({
name: "review_memo_section",
label: "Update Memo Section Review",
description:
"Set the review status of a memo section (pending, in_review, approved, changes_requested).",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
sectionId: Type.String({ description: "Section ID" }),
status: Type.Union(
[
Type.Literal("pending"),
Type.Literal("in_review"),
Type.Literal("approved"),
Type.Literal("changes_requested"),
],
{ description: "New review status" },
),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const review = updateMemoSectionReview(db, company.id, params.sectionId, params.status);
return {
content: [
{ type: "text" as const, text: `Section "${params.sectionId}" review status set to "${params.status}"` },
],
details: { review } as any,
};
},
});
return [getMemoTool, updateSectionTool, addAnnotationTool, reviewSectionTool];
}

View File

@@ -0,0 +1,153 @@
/**
* Custom tools: Financial model read/write
*/
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import type { Db } from "../db/database.js";
import { resolveCompany, getModel, updateModelCell, createModelRow, deleteModelRow } from "../db/queries.js";
export function createModelTools(db: Db) {
const getModelTool = defineTool({
name: "get_financial_model",
label: "Get Financial Model",
description:
"Get the financial model for a company. Returns headers (time periods) and rows (line items) with actual/forecast/total kinds.",
parameters: Type.Object({
companyIdOrTicker: Type.String({
description: "Company ID or ticker symbol",
}),
tab: Type.Union(
[Type.Literal("income"), Type.Literal("balance"), Type.Literal("operating")],
{ description: "Which model tab to retrieve" },
),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const model = getModel(db, company.id, params.tab);
return {
content: [{ type: "text" as const, text: JSON.stringify(model, null, 2) }],
details: {} as any,
};
},
});
const updateCellTool = defineTool({
name: "update_model_cell",
label: "Update Model Cell",
description: "Update a single cell in the financial model by row index and column index.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
tab: Type.String({ description: "Model tab (income, balance, operating)" }),
row: Type.Number({ description: "Row index (0-based)" }),
col: Type.Number({ description: "Column index (0-based)" }),
value: Type.String({ description: "New cell value" }),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const result = updateModelCell(db, company.id, params.tab, params.row, params.col, params.value);
return {
content: [
{
type: "text" as const,
text: result.ok
? `Updated cell [${params.row}, ${params.col}] = ${params.value}`
: `Failed to update cell. Row or column out of range.`,
},
],
isError: !result.ok,
details: result as any,
};
},
});
const createRowTool = defineTool({
name: "create_model_row",
label: "Create Model Row",
description: "Add a new row to the financial model (e.g. a new line item or forecast row).",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
tab: Type.String({ description: "Model tab" }),
label: Type.String({ description: "Row label (e.g. 'Revenue', 'COGS')" }),
kind: Type.Union(
[Type.Literal("actual"), Type.Literal("forecast"), Type.Literal("total")],
{ description: "Row kind" },
),
values: Type.Optional(
Type.Array(Type.String(), { description: "Initial cell values" }),
),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const result = createModelRow(db, company.id, params.tab, {
label: params.label,
kind: params.kind,
values: params.values ?? [],
});
return {
content: [
{
type: "text" as const,
text: `Created row "${params.label}" (${params.kind}) at position ${result.position}`,
},
],
details: result as any,
};
},
});
const deleteRowTool = defineTool({
name: "delete_model_row",
label: "Delete Model Row",
description: "Delete a row from the financial model by index.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
tab: Type.String({ description: "Model tab" }),
row: Type.Number({ description: "Row index to delete (0-based)" }),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {} as any,
};
}
const ok = deleteModelRow(db, company.id, params.tab, params.row);
return {
content: [
{
type: "text" as const,
text: ok ? `Deleted row ${params.row}` : `Row ${params.row} not found or could not be deleted`,
},
],
isError: !ok,
details: {} as any,
};
},
});
return [getModelTool, updateCellTool, createRowTool, deleteRowTool];
}

View File

@@ -0,0 +1,73 @@
/**
* Custom tools: Validation status queries
*/
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import type { Db } from "../db/database.js";
import { resolveCompany, getMemo } from "../db/queries.js";
export function createValidationTools(db: Db) {
const getValidationStatusTool = defineTool({
name: "get_validation_status",
label: "Get Validation Status",
description:
"Get the current validation/review status of all memo sections for a company. Shows which sections are verified, flagged, or unverified.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const memo = getMemo(db, company.id);
const reviewStatus = memo.sectionReviews.map((r) => ({
sectionId: r.sectionId,
status: r.status,
updatedAt: r.updatedAt,
}));
return {
content: [{ type: "text" as const, text: JSON.stringify(reviewStatus, null, 2) }],
details: {},
};
},
});
const getAnnotationsTool = defineTool({
name: "get_memo_annotations",
label: "Get Memo Annotations",
description:
"List all open annotations (comments, highlights, strikes) on the investment memo.",
parameters: Type.Object({
companyIdOrTicker: Type.String({ description: "Company ID or ticker symbol" }),
openOnly: Type.Optional(
Type.Boolean({ description: "Only return open (unresolved) annotations", default: true }),
),
}),
execute: async (_, params) => {
const company = resolveCompany(db, params.companyIdOrTicker);
if (!company) {
return {
content: [{ type: "text" as const, text: `Company "${params.companyIdOrTicker}" not found.` }],
isError: true,
details: {},
};
}
const memo = getMemo(db, company.id);
const annotations = params.openOnly ?? true
? memo.annotations.filter((a) => a.status === "open")
: memo.annotations;
return {
content: [{ type: "text" as const, text: JSON.stringify(annotations, null, 2) }],
details: {},
};
},
});
return [getValidationStatusTool, getAnnotationsTool];
}