Implement RPC contract validation baseline

This commit is contained in:
2026-05-14 15:41:51 -04:00
parent 379c07b50c
commit df367756d0
60 changed files with 10704 additions and 47 deletions

View File

@@ -6,6 +6,10 @@
"./rpc": {
"types": "./src/rpc.ts",
"import": "./src/rpc.ts"
},
"./rpcSchemas": {
"types": "./src/rpcSchemas.ts",
"import": "./src/rpcSchemas.ts"
}
}
}

View File

@@ -85,6 +85,15 @@ export const MemoSectionReviewSchema = z.object({
});
export type MemoSectionReview = z.infer<typeof MemoSectionReviewSchema>;
export const MemoSchema = z.object({
status: z.enum(["draft", "review", "final"]),
sections: z.array(MemoSectionSchema),
citations: z.array(MemoCitationSchema),
annotations: z.array(MemoAnnotationSchema),
sectionReviews: z.array(MemoSectionReviewSchema)
});
export type Memo = z.infer<typeof MemoSchema>;
export const CatalystSchema = z.object({
id: z.string(),
date: z.string(),
@@ -174,19 +183,9 @@ export const SnapshotSchema = z.object({
});
export type Snapshot = z.infer<typeof SnapshotSchema>;
export type ClientSettings = {
theme: "light" | "dark" | "system";
density: "comfortable" | "compact" | "dense";
sidebarWidth: number;
navCollapsed: Record<string, boolean>;
keybindings: Record<string, string>;
};
export type ClientSettings = z.infer<typeof import("./rpcSchemas.js").ClientSettingsSchema>;
export type ServerSettings = {
agentConfigs: Record<string, unknown>;
dataSources: Record<string, boolean>;
exportPipelines: Record<string, unknown>;
};
export type ServerSettings = z.infer<typeof import("./rpcSchemas.js").ServerSettingsSchema>;
export type RpcRequestMap = {
"portfolio.get": undefined;
@@ -239,11 +238,13 @@ export type RpcRequestMap = {
"agent.configure": { agentId: string; config: Record<string, unknown> };
"agent.getTrace": { agentId: string; runId: string };
"agent.runPipeline": { companyId: string; pipeline: string };
"validation.run": { companyId: string; agentType?: "sv" | "qa" | "rt" | "all" };
"validation.getStatus": { companyId: string; sectionId?: string };
"export.list": { companyId?: string };
"export.create": { type: string; companyId: string; options?: Record<string, unknown> };
"export.create": { type: "pdf" | "excel" | "ppt"; companyId: string; options?: Record<string, unknown> };
"export.download": { exportId: string };
"settings.get": { scope: "client" | "server" };
"settings.update": { scope: "client" | "server"; changes: Record<string, unknown> };
"settings.update": import("./rpcSchemas.js").SettingsUpdatePayload;
};
export type RpcResponseMap = {
@@ -289,6 +290,12 @@ export type RpcResponseMap = {
"agent.configure": { ok: boolean };
"agent.getTrace": { steps: Array<{ step: number; label: string; detail: string }> };
"agent.runPipeline": { runIds: string[] };
"validation.run": {
sourceVerification?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
modelQA?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
redTeam?: { passed: boolean; confidence: string; issues: Array<{ severity: string; message: string; suggestion?: string }>; notes: string; timestamp: string };
};
"validation.getStatus": { validationState: "verified" | "flagged" | "unverified" | "failed"; lastValidated?: string };
"export.list": { exports: ExportRecord[] };
"export.create": { exportId: string };
"export.download": { data: ArrayBuffer };
@@ -311,3 +318,114 @@ export type RpcResult<T extends RpcMethod> =
export type RpcClient = {
call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]): Promise<RpcResult<T>>;
};
// ============== SSE Events ==============
export type ServerEventType =
| "agent.progress"
| "agent.completed"
| "agent.failed"
| "agent.started"
| "agent.streaming"
| "validation.updated"
| "memo.updated"
| "model.updated";
export type AgentProgressEvent = {
type: "agent.progress";
data: {
runId: string;
agentId: string;
companyId: string;
progress: number;
action: string;
timestamp: string;
};
};
export type AgentCompletedEvent = {
type: "agent.completed";
data: {
runId: string;
agentId: string;
companyId: string;
output: unknown;
duration: number;
timestamp: string;
};
};
export type AgentFailedEvent = {
type: "agent.failed";
data: {
runId: string;
agentId: string;
companyId: string;
error: string;
timestamp: string;
};
};
export type AgentStartedEvent = {
type: "agent.started";
data: {
runId: string;
agentId: string;
companyId: string;
pipeline?: string;
timestamp: string;
};
};
export type AgentStreamingEvent = {
type: "agent.streaming";
data: {
runId: string;
agentId: string;
companyId: string;
chunk: string;
timestamp: string;
};
};
export type ValidationUpdatedEvent = {
type: "validation.updated";
data: {
companyId: string;
sectionId?: string;
validationState: "verified" | "flagged" | "unverified" | "failed";
agentId: string;
notes?: string;
timestamp: string;
};
};
export type MemoUpdatedEvent = {
type: "memo.updated";
data: {
companyId: string;
sectionId: string;
content: string;
updatedAt: string;
};
};
export type ModelUpdatedEvent = {
type: "model.updated";
data: {
companyId: string;
tab: string;
cell?: { row: number; col: number };
timestamp: string;
};
};
export type ServerEvent =
| AgentProgressEvent
| AgentCompletedEvent
| AgentFailedEvent
| AgentStartedEvent
| AgentStreamingEvent
| ValidationUpdatedEvent
| MemoUpdatedEvent
| ModelUpdatedEvent;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { RpcRequestSchemas, RpcResponseSchemas, parseRpcRequest } from "./rpcSchemas.js";
describe("RPC schemas", () => {
it("defines request and response schemas for every method", () => {
expect(Object.keys(RpcRequestSchemas).sort()).toEqual(Object.keys(RpcResponseSchemas).sort());
});
it("accepts valid payloads and trims bounded strings", () => {
expect(parseRpcRequest("portfolio.addHolding", { ticker: " cost " })).toEqual({ ticker: "cost" });
expect(parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: 0, col: 1, value: "42" })).toEqual({
companyId: "cost",
tab: "base",
row: 0,
col: 1,
value: "42",
});
});
it("rejects invalid payloads", () => {
expect(() => parseRpcRequest("portfolio.addHolding", { ticker: "" })).toThrow();
expect(() => parseRpcRequest("model.updateCell", { companyId: "cost", tab: "base", row: -1, col: 0, value: "42" })).toThrow();
});
it("rejects scope-mismatched settings updates", () => {
expect(() => parseRpcRequest("settings.update", { scope: "client", changes: { sidebarWidth: 40 } })).toThrow();
expect(() => parseRpcRequest("settings.update", { scope: "server", changes: { dataSources: { sec: "yes" } } })).toThrow();
});
it("catches malformed handler output", () => {
expect(RpcResponseSchemas["memo.updateSection"].safeParse({ section: {}, status: "draft", savedAt: "now" }).success).toBe(false);
});
});

View File

@@ -0,0 +1,270 @@
import { z } from "zod";
import {
AgentSchema,
AlertSchema,
CatalystSchema,
CompanySchema,
EarningsScheduleSchema,
ExportRecordSchema,
FilingSchema,
HoldingSchema,
MemoAnnotationSchema,
MemoCitationSchema,
MemoSectionReviewSchema,
MemoSectionSchema,
ModelRowSchema,
RiskSchema,
WorkspaceSectionSchema,
type ClientSettings,
type RpcMethod,
type RpcRequestMap,
type RpcResponseMap,
type ServerSettings,
} from "./rpc.js";
const nonEmptyString = z.string().trim().min(1);
const idString = nonEmptyString;
const tickerString = z.string().trim().min(1).max(16);
const nonNegativeIndex = z.number().int().min(0);
const unknownRecord = z.record(z.unknown());
export const ClientSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]),
density: z.enum(["comfortable", "compact", "dense"]),
sidebarWidth: z.number().int().min(160).max(520),
navCollapsed: z.record(z.boolean()),
keybindings: z.record(z.string()),
});
export const ServerSettingsSchema = z.object({
agentConfigs: z.record(z.unknown()),
dataSources: z.record(z.boolean()),
exportPipelines: z.record(z.unknown()),
});
const RiskInputSchema = RiskSchema.omit({ id: true, companyId: true });
const SettingsUpdateSchema = z.discriminatedUnion("scope", [
z.object({ scope: z.literal("client"), changes: ClientSettingsSchema.partial() }),
z.object({ scope: z.literal("server"), changes: ServerSettingsSchema.partial() }),
]);
export const RpcRequestSchemas = {
"portfolio.get": z.undefined(),
"portfolio.addHolding": z.object({ ticker: tickerString }),
"portfolio.removeHolding": z.object({ ticker: tickerString }),
"company.get": z.object({ companyId: idString }),
"company.search": z.object({ query: nonEmptyString }),
"company.setActive": z.object({ companyId: idString }),
"workspace.getSection": z.object({ companyId: idString, section: nonEmptyString }),
"workspace.listSources": z.object({ companyId: idString }),
"catalyst.list": z.object({ companyId: idString }),
"alert.list": z.object({ companyId: idString.optional(), since: z.string().optional() }),
"risk.list": z.object({ companyId: idString }),
"risk.add": z.object({ companyId: idString, risk: RiskInputSchema }),
"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.updateCell": z.object({
companyId: idString,
tab: nonEmptyString,
row: nonNegativeIndex,
col: nonNegativeIndex,
value: z.string(),
}),
"model.runScenario": z.object({
companyId: idString,
scenario: nonEmptyString,
overrides: z.record(z.string()),
}),
"memo.get": z.object({ companyId: idString }),
"memo.updateSection": z.object({
companyId: idString,
sectionId: idString,
title: nonEmptyString.optional(),
content: nonEmptyString,
}),
"memo.addAnnotation": z.object({
companyId: idString,
sectionId: idString,
kind: z.enum(["highlight", "comment", "strike"]),
selectedText: nonEmptyString,
comment: nonEmptyString.optional(),
}),
"memo.resolveAnnotation": z.object({ companyId: idString, annotationId: idString }),
"memo.updateSectionReview": z.object({
companyId: idString,
sectionId: idString,
status: z.enum(["pending", "in_review", "approved", "changes_requested"]),
}),
"memo.acceptEdit": z.object({ companyId: idString, editId: idString }),
"memo.rejectEdit": z.object({ companyId: idString, editId: idString, reason: z.string().optional() }),
"agent.list": z.object({ companyId: idString.optional() }),
"agent.start": z.object({ agentId: idString, companyId: idString }),
"agent.pause": z.object({ agentId: idString }),
"agent.restart": z.object({ agentId: idString }),
"agent.chat": z.object({ agentId: idString, message: nonEmptyString }),
"agent.configure": z.object({ agentId: idString, config: unknownRecord }),
"agent.getTrace": z.object({ agentId: idString, runId: idString }),
"agent.runPipeline": z.object({
companyId: idString,
pipeline: z.enum(["research", "competitive", "cross-cutting"]),
}),
"validation.run": z.object({ companyId: idString, agentType: z.enum(["sv", "qa", "rt", "all"]).optional() }),
"validation.getStatus": z.object({ companyId: idString, sectionId: idString.optional() }),
"export.list": z.object({ companyId: idString.optional() }),
"export.create": z.object({ type: z.enum(["pdf", "excel", "ppt"]), companyId: idString, options: unknownRecord.optional() }),
"export.download": z.object({ exportId: idString }),
"settings.get": z.object({ scope: z.enum(["client", "server"]) }),
"settings.update": SettingsUpdateSchema,
} satisfies Record<RpcMethod, z.ZodTypeAny>;
const SettingsResponseSchema = z.union([ClientSettingsSchema, ServerSettingsSchema]);
const ValidationIssueSchema = z.object({
severity: z.string(),
message: z.string(),
suggestion: z.string().optional(),
});
const ValidationResultSchema = z.object({
passed: z.boolean(),
confidence: z.string(),
issues: z.array(ValidationIssueSchema),
notes: z.string(),
timestamp: z.string(),
});
export const RpcResponseSchemas = {
"portfolio.get": z.object({ id: z.string(), name: z.string(), holdings: z.array(HoldingSchema), activeCompanyId: z.string() }),
"portfolio.addHolding": z.object({ holding: HoldingSchema }),
"portfolio.removeHolding": z.object({ ok: z.boolean() }),
"company.get": z.object({ company: CompanySchema }),
"company.search": z.object({ results: z.array(z.object({ ticker: z.string(), name: z.string(), sector: z.string() })) }),
"company.setActive": z.object({ ok: z.boolean() }),
"workspace.getSection": z.object({ content: WorkspaceSectionSchema, validationState: z.string() }),
"workspace.listSources": z.object({ sources: z.array(z.object({ type: z.string(), title: z.string(), metadata: z.string() })) }),
"catalyst.list": z.object({ catalysts: z.array(CatalystSchema) }),
"alert.list": z.object({ alerts: z.array(AlertSchema) }),
"risk.list": z.object({ risks: z.array(RiskSchema) }),
"risk.add": z.object({ risk: RiskSchema }),
"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.updateCell": z.object({ ok: z.boolean(), affectedCells: z.array(z.string()) }),
"model.runScenario": z.object({ headers: z.array(z.string()), rows: z.array(ModelRowSchema) }),
"memo.get": z.object({
status: z.enum(["draft", "review", "final"]),
sections: z.array(MemoSectionSchema),
citations: z.array(MemoCitationSchema),
annotations: z.array(MemoAnnotationSchema),
sectionReviews: z.array(MemoSectionReviewSchema),
}),
"memo.updateSection": z.object({
section: MemoSectionSchema,
status: z.enum(["draft", "review", "final"]),
savedAt: z.string(),
}),
"memo.addAnnotation": z.object({ annotation: MemoAnnotationSchema }),
"memo.resolveAnnotation": z.object({ annotation: MemoAnnotationSchema }),
"memo.updateSectionReview": z.object({ review: MemoSectionReviewSchema }),
"memo.acceptEdit": z.object({ ok: z.boolean() }),
"memo.rejectEdit": z.object({ ok: z.boolean() }),
"agent.list": z.object({ agents: z.array(AgentSchema) }),
"agent.start": z.object({ runId: z.string() }),
"agent.pause": z.object({ ok: z.boolean() }),
"agent.restart": z.object({ runId: z.string() }),
"agent.chat": z.object({ response: z.string() }),
"agent.configure": z.object({ ok: z.boolean() }),
"agent.getTrace": z.object({ steps: z.array(z.object({ step: z.number(), label: z.string(), detail: z.string() })) }),
"agent.runPipeline": z.object({ runIds: z.array(z.string()) }),
"validation.run": z.object({
sourceVerification: ValidationResultSchema.optional(),
modelQA: ValidationResultSchema.optional(),
redTeam: ValidationResultSchema.optional(),
}),
"validation.getStatus": z.object({
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
lastValidated: z.string().optional(),
}),
"export.list": z.object({ exports: z.array(ExportRecordSchema) }),
"export.create": z.object({ exportId: z.string() }),
"export.download": z.object({ data: z.instanceof(ArrayBuffer) }),
"settings.get": z.object({ settings: SettingsResponseSchema }),
"settings.update": z.object({ ok: z.boolean() }),
} satisfies Record<RpcMethod, z.ZodTypeAny>;
export const AgentProgressEventSchema = z.object({
type: z.literal("agent.progress"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), progress: z.number(), action: z.string(), timestamp: z.string() }),
});
export const AgentCompletedEventSchema = z.object({
type: z.literal("agent.completed"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), output: z.unknown(), duration: z.number(), timestamp: z.string() }),
});
export const AgentFailedEventSchema = z.object({
type: z.literal("agent.failed"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), error: z.string(), timestamp: z.string() }),
});
export const AgentStartedEventSchema = z.object({
type: z.literal("agent.started"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), pipeline: z.string().optional(), timestamp: z.string() }),
});
export const AgentStreamingEventSchema = z.object({
type: z.literal("agent.streaming"),
data: z.object({ runId: z.string(), agentId: z.string(), companyId: z.string(), chunk: z.string(), timestamp: z.string() }),
});
export const ValidationUpdatedEventSchema = z.object({
type: z.literal("validation.updated"),
data: z.object({
companyId: z.string(),
sectionId: z.string().optional(),
validationState: z.enum(["verified", "flagged", "unverified", "failed"]),
agentId: z.string(),
notes: z.string().optional(),
timestamp: z.string(),
}),
});
export const MemoUpdatedEventSchema = z.object({
type: z.literal("memo.updated"),
data: z.object({ companyId: z.string(), sectionId: z.string(), content: z.string(), updatedAt: z.string() }),
});
export const ModelUpdatedEventSchema = z.object({
type: z.literal("model.updated"),
data: z.object({ companyId: z.string(), tab: z.string(), cell: z.object({ row: z.number(), col: z.number() }).optional(), timestamp: z.string() }),
});
export const ServerEventSchema = z.discriminatedUnion("type", [
AgentProgressEventSchema,
AgentCompletedEventSchema,
AgentFailedEventSchema,
AgentStartedEventSchema,
AgentStreamingEventSchema,
ValidationUpdatedEventSchema,
MemoUpdatedEventSchema,
ModelUpdatedEventSchema,
]);
export const ServerEventTypeSchema = z.enum([
"agent.progress",
"agent.completed",
"agent.failed",
"agent.started",
"agent.streaming",
"validation.updated",
"memo.updated",
"model.updated",
]);
export function isRpcMethod(method: unknown): method is RpcMethod {
return typeof method === "string" && method in RpcRequestSchemas;
}
export function parseRpcRequest<T extends RpcMethod>(method: T, payload: unknown): RpcRequestMap[T] {
return RpcRequestSchemas[method].parse(payload) as RpcRequestMap[T];
}
export function parseRpcResponse<T extends RpcMethod>(method: T, data: unknown): RpcResponseMap[T] {
return RpcResponseSchemas[method].parse(data) as RpcResponseMap[T];
}
export type SettingsUpdatePayload =
| { scope: "client"; changes: Partial<ClientSettings> }
| { scope: "server"; changes: Partial<ServerSettings> };

View File

@@ -14,6 +14,35 @@
"./mockRpc": {
"types": "./src/mockRpc.ts",
"import": "./src/mockRpc.ts"
},
"./db": {
"types": "./src/db/index.ts",
"import": "./src/db/index.ts"
},
"./data": {
"types": "./src/data/index.ts",
"import": "./src/data/index.ts"
},
"./llm": {
"types": "./src/llm/index.ts",
"import": "./src/llm/index.ts"
},
"./agents": {
"types": "./src/agents/index.ts",
"import": "./src/agents/index.ts"
},
"./export": {
"types": "./src/export/index.ts",
"import": "./src/export/index.ts"
}
},
"dependencies": {
"@mosaiciq/contracts": "workspace:*",
"better-sqlite3": "^12.10.0",
"exceljs": "^4.4.0",
"pptxgenjs": "^4.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13"
}
}

View File

@@ -0,0 +1,200 @@
/**
* Event emitter for server-sent events
* Emits real-time events to the renderer process via IPC
*/
type EventCallback = (event: unknown) => void;
class EventEmitter {
private listeners: Map<string, Set<EventCallback>> = new Map();
/**
* Subscribe to events
*/
on(eventType: string, callback: EventCallback): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(callback);
// Return unsubscribe function
return () => {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.listeners.delete(eventType);
}
}
};
}
/**
* Emit an event to all subscribers
*/
emit(eventType: string, data: unknown): void {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
for (const callback of callbacks) {
try {
callback(data);
} catch (error) {
console.error(`[EventEmitter] Error in callback for ${eventType}:`, error);
}
}
}
}
/**
* Remove all listeners for an event type
*/
removeAllListeners(eventType?: string): void {
if (eventType) {
this.listeners.delete(eventType);
} else {
this.listeners.clear();
}
}
/**
* Get the number of listeners for an event type
*/
listenerCount(eventType: string): number {
return this.listeners.get(eventType)?.size ?? 0;
}
}
// Global singleton instance
export const eventEmitter = new EventEmitter();
/**
* Helper function to emit agent progress events
*/
export function emitAgentProgress(
runId: string,
agentId: string,
companyId: string,
progress: number,
action: string
): void {
eventEmitter.emit("agent.progress", {
type: "agent.progress",
data: {
runId,
agentId,
companyId,
progress,
action,
timestamp: new Date().toISOString(),
},
});
}
/**
* Helper function to emit agent started events
*/
export function emitAgentStarted(
runId: string,
agentId: string,
companyId: string,
pipeline?: string
): void {
eventEmitter.emit("agent.started", {
type: "agent.started",
data: {
runId,
agentId,
companyId,
pipeline,
timestamp: new Date().toISOString(),
},
});
}
/**
* Helper function to emit agent completed events
*/
export function emitAgentCompleted(
runId: string,
agentId: string,
companyId: string,
output: unknown,
duration: number
): void {
eventEmitter.emit("agent.completed", {
type: "agent.completed",
data: {
runId,
agentId,
companyId,
output,
duration,
timestamp: new Date().toISOString(),
},
});
}
/**
* Helper function to emit agent failed events
*/
export function emitAgentFailed(
runId: string,
agentId: string,
companyId: string,
error: string
): void {
eventEmitter.emit("agent.failed", {
type: "agent.failed",
data: {
runId,
agentId,
companyId,
error,
timestamp: new Date().toISOString(),
},
});
}
/**
* Helper function to emit agent streaming events (for live output)
*/
export function emitAgentStreaming(
runId: string,
agentId: string,
companyId: string,
chunk: string
): void {
eventEmitter.emit("agent.streaming", {
type: "agent.streaming",
data: {
runId,
agentId,
companyId,
chunk,
timestamp: new Date().toISOString(),
},
});
}
/**
* Helper function to emit validation updated events
*/
export function emitValidationUpdated(
companyId: string,
sectionId: string | undefined,
validationState: "verified" | "flagged" | "unverified" | "failed",
agentId: string,
notes?: string
): void {
eventEmitter.emit("validation.updated", {
type: "validation.updated",
data: {
companyId,
sectionId,
validationState,
agentId,
notes,
timestamp: new Date().toISOString(),
},
});
}

View File

@@ -0,0 +1,336 @@
/**
* Agent pipeline executor
* Orchestrates multi-agent workflows with dependency management
*/
import type { Db } from "../db/database.js";
import { executeAgent, type AgentRunResult } from "./runner.js";
import { getAgentsByPipeline } from "../llm/prompts.js";
export interface PipelineExecutionOptions {
onProgress?: (agentId: string, progress: number, action: string) => void;
onAgentComplete?: (result: AgentRunResult) => void;
signal?: AbortSignal;
continueOnError?: boolean;
}
export interface DependencyGraph {
nodes: string[];
edges: Array<[string, string]>;
}
// Pipeline definitions
const PIPELINE_GRAPHS: Record<string, DependencyGraph> = {
research: {
nodes: ["sf", "cr", "fm", "va", "mw", "pa"],
edges: [
["sf", "cr"],
["cr", "fm"],
["fm", "va"],
["va", "mw"],
["mw", "pa"],
],
},
competitive: {
nodes: ["ec", "ci", "rk", "rt"],
edges: [
["ec", "ci"],
["ci", "rk"],
["rk", "rt"],
],
},
"cross-cutting": {
nodes: ["mn", "sv", "ex", "qa"],
edges: [
["sv", "qa"],
["qa", "ex"],
],
},
};
/**
* Execute a pipeline of agents
*/
export async function executePipeline(
db: Db,
pipeline: string,
companyId: string,
options: PipelineExecutionOptions = {}
): Promise<AgentRunResult[]> {
const {
onProgress,
onAgentComplete,
signal,
continueOnError = false,
} = options;
const graph = PIPELINE_GRAPHS[pipeline];
if (!graph) {
throw new Error(`Unknown pipeline: ${pipeline}`);
}
const results: AgentRunResult[] = [];
// Get execution order (topological sort)
const executionOrder = topologicalSort(graph);
// Execute agents in order
for (const agentId of executionOrder) {
// Check for cancellation
if (signal?.aborted) {
break;
}
try {
onProgress?.(agentId, 0, `Starting ${agentId}`);
const result = await executeAgent(db, agentId, companyId, {
onProgress: (progress, action) => {
onProgress?.(agentId, progress, action);
},
signal,
});
results.push(result);
onAgentComplete?.(result);
// Stop if agent failed and we're not continuing on error
if (result.status === "failed" && !continueOnError) {
console.error(`[Pipeline] Agent ${agentId} failed, stopping pipeline`);
break;
}
// Validation checkpoint for certain agents
if (["va", "rk", "qa"].includes(agentId)) {
const shouldProceed = await waitForApproval(agentId, companyId);
if (!shouldProceed) {
console.log(`[Pipeline] User cancelled pipeline at ${agentId}`);
break;
}
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Pipeline] Error executing ${agentId}:`, errorMsg);
const failedResult: AgentRunResult = {
runId: `run-${Date.now()}`,
agentId,
companyId,
output: null,
rawResponse: "",
status: "failed",
error: errorMsg,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
results.push(failedResult);
if (!continueOnError) {
break;
}
}
}
return results;
}
/**
* Topological sort of dependency graph
*/
function topologicalSort(graph: DependencyGraph): string[] {
const { nodes, edges } = graph;
// Build adjacency list and in-degree count
const inDegree: Record<string, number> = {};
const adjacency: Record<string, string[]> = {};
for (const node of nodes) {
inDegree[node] = 0;
adjacency[node] = [];
}
for (const [from, to] of edges) {
adjacency[from].push(to);
inDegree[to]++;
}
// Kahn's algorithm
const sorted: string[] = [];
const queue: string[] = [];
// Start with nodes that have no dependencies
for (const node of nodes) {
if (inDegree[node] === 0) {
queue.push(node);
}
}
while (queue.length > 0) {
const node = queue.shift()!;
sorted.push(node);
for (const neighbor of adjacency[node]) {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
return sorted;
}
/**
* Wait for user approval at validation checkpoints
* For now, this always returns true
* In a production system, this would block and wait for user input
*/
async function waitForApproval(agentId: string, companyId: string): Promise<boolean> {
// TODO: Implement approval mechanism
// For now, auto-approve
return true;
}
/**
* Get available pipelines
*/
export function getAvailablePipelines(): string[] {
return Object.keys(PIPELINE_GRAPHS);
}
/**
* Get pipeline info
*/
export function getPipelineInfo(pipeline: string): { name: string; description: string; agents: string[] } | null {
const agents = getAgentsByPipeline(pipeline as any);
if (agents.length === 0) return null;
const descriptions: Record<string, string> = {
research: "Full research pipeline from SEC filings to presentation",
competitive: "Competitive analysis and risk assessment",
"cross-cutting": "Validation and export tasks",
};
return {
name: pipeline.charAt(0).toUpperCase() + pipeline.slice(1),
description: descriptions[pipeline] || "",
agents,
};
}
/**
* Gather inputs for an agent from previous results
*/
export function gatherInputs(
db: Db,
agentId: string,
previousResults: AgentRunResult[]
): Record<string, unknown> {
const inputs: Record<string, unknown> = {};
for (const result of previousResults) {
if (result.status === "completed" && result.output) {
inputs[result.agentId] = result.output;
}
}
return inputs;
}
/**
* Execute all agents in a pipeline in parallel (where possible)
*/
export async function executePipelineParallel(
db: Db,
pipeline: string,
companyId: string,
options: PipelineExecutionOptions = {}
): Promise<AgentRunResult[]> {
const {
onProgress,
onAgentComplete,
signal,
} = options;
const graph = PIPELINE_GRAPHS[pipeline];
if (!graph) {
throw new Error(`Unknown pipeline: ${pipeline}`);
}
const results: AgentRunResult[] = [];
const completed = new Set<string>();
// Process agents in waves based on dependencies
const executionOrder = topologicalSort(graph);
// Group agents by wave (agents that can run in parallel)
const waves: string[][] = [];
const currentWave: string[] = [];
const inCurrentWave = new Set<string>(executionOrder);
for (const agentId of executionOrder) {
// Check if this agent depends on any agent in the current wave
const dependsOnCurrentWave = graph.edges.some(
([from, to]) => to === agentId && currentWave.includes(from)
);
if (dependsOnCurrentWave && currentWave.length > 0) {
waves.push([...currentWave]);
currentWave.length = 0;
}
currentWave.push(agentId);
}
if (currentWave.length > 0) {
waves.push(currentWave);
}
// Execute each wave
for (const wave of waves) {
if (signal?.aborted) break;
// Execute all agents in this wave in parallel
const wavePromises = wave.map(async (agentId) => {
try {
onProgress?.(agentId, 0, `Starting ${agentId}`);
const result = await executeAgent(db, agentId, companyId, {
onProgress: (progress, action) => {
onProgress?.(agentId, progress, action);
},
signal,
});
onAgentComplete?.(result);
completed.add(agentId);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
runId: `run-${Date.now()}`,
agentId,
companyId,
output: null,
rawResponse: "",
status: "failed" as const,
error: errorMsg,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
}
});
const waveResults = await Promise.all(wavePromises);
results.push(...waveResults);
// Check for failures
const hasFailure = waveResults.some((r) => r.status === "failed");
if (hasFailure) {
console.warn(`[Pipeline] Wave completed with failures`);
}
}
return results;
}

View File

@@ -0,0 +1,8 @@
/**
* Agents module exports
*/
export * from "./runner.js";
export * from "./executor.js";
export * from "./eventEmitter.js";
export * from "./validationAgents.js";

View File

@@ -0,0 +1,279 @@
/**
* Individual agent execution
* Runs a single agent with proper context and error handling
*/
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 {
emitAgentProgress,
emitAgentStarted,
emitAgentCompleted,
emitAgentFailed,
emitAgentStreaming,
} from "./eventEmitter.js";
export interface AgentRunOptions {
onProgress?: (progress: number, action: string) => void;
signal?: AbortSignal;
structured?: boolean;
schema?: Record<string, unknown>;
}
export interface AgentRunResult {
runId: string;
agentId: string;
companyId: string;
output: unknown;
rawResponse: string;
status: "completed" | "failed" | "cancelled";
error?: string;
startedAt: string;
completedAt: string;
}
/**
* Execute a single agent
*/
export async function executeAgent(
db: Db,
agentId: string,
companyId: string,
options: AgentRunOptions = {}
): Promise<AgentRunResult> {
const {
onProgress,
signal,
structured = false,
schema,
} = options;
const runId = `run-${Date.now()}`;
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.";
console.error(`[Agent] ${errorMsg}`);
// Update agent run status in database
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
return {
runId,
agentId,
companyId,
output: { error: errorMsg },
rawResponse: "",
status: "failed",
error: errorMsg,
startedAt,
completedAt: new Date().toISOString(),
};
}
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";
// 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,
});
onProgress?.(20, `Gathering data for ${action}`);
emitAgentProgress(runId, agentId, companyId, 20, `Gathering data for ${action}`);
// Get agent prompt
const prompt = getAgentPrompt(agentId, context);
onProgress?.(40, `Executing ${action}`);
emitAgentProgress(runId, agentId, companyId, 40, `Executing ${action}`);
// 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,
});
// Try to parse as JSON
try {
output = JSON.parse(rawResponse);
} catch {
output = { text: rawResponse };
}
}
onProgress?.(90, `Finalizing ${action}`);
emitAgentProgress(runId, agentId, companyId, 90, `Finalizing ${action}`);
// Store agent output in database
storeAgentOutput(db, runId, agentId, companyId, output, rawResponse);
// Update agent run status to completed
updateAgentRunStatus(db, runId, agentId, companyId, "completed");
onProgress?.(100, `Completed ${action}`);
emitAgentProgress(runId, agentId, companyId, 100, `Completed ${action}`);
// Calculate duration
const duration = Date.now() - new Date(startedAt).getTime();
// Emit completion event
emitAgentCompleted(runId, agentId, companyId, output, duration);
return {
runId,
agentId,
companyId,
output,
rawResponse,
status: "completed",
startedAt,
completedAt: new Date().toISOString(),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// Check if cancelled
if (signal?.aborted) {
updateAgentRunStatus(db, runId, agentId, companyId, "cancelled");
emitAgentFailed(runId, agentId, companyId, "Cancelled by user");
return {
runId,
agentId,
companyId,
output: null,
rawResponse: "",
status: "cancelled",
error: "Cancelled by user",
startedAt,
completedAt: new Date().toISOString(),
};
}
// Update agent run status to failed
updateAgentRunStatus(db, runId, agentId, companyId, "failed", errorMsg);
// Emit failed event
emitAgentFailed(runId, agentId, companyId, errorMsg);
return {
runId,
agentId,
companyId,
output: { error: errorMsg },
rawResponse: "",
status: "failed",
error: errorMsg,
startedAt,
completedAt: new Date().toISOString(),
};
}
}
/**
* 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);
}
/**
* Execute agent with simple callback interface
*/
export async function executeAgentSimple(
db: Db,
agentId: string,
companyId: string,
onProgress?: (message: string) => void
): Promise<unknown> {
const result = await executeAgent(db, agentId, companyId, {
onProgress: (progress, action) => {
onProgress?.(`[${progress}%] ${action}`);
},
});
if (result.status === "failed") {
throw new Error(result.error || "Agent execution failed");
}
return result.output;
}

View File

@@ -0,0 +1,400 @@
/**
* Validation Agent Implementations
* These agents perform validation checks on research outputs
*/
import type { Db } from "../db/database.js";
import { streamStructuredResponse, completeStructured, isConfigured } from "../llm/client.js";
import { getAgentPrompt, type AgentContext } from "../llm/prompts.js";
import { buildAgentContext } from "../llm/context.js";
import { emitValidationUpdated } from "./eventEmitter.js";
export interface ValidationResult {
passed: boolean;
confidence: "high" | "medium" | "low";
issues: Array<{
severity: "error" | "warning" | "info";
message: string;
location?: string;
suggestion?: string;
}>;
notes: string;
timestamp: string;
}
/**
* Source Verification Agent
* Verifies that all claims in the memo are properly cited and accurate
*/
export async function executeSourceVerification(
db: Db,
companyId: string
): Promise<ValidationResult> {
if (!isConfigured()) {
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: "LLM API key not configured" }],
notes: "Cannot verify sources without LLM access",
timestamp: new Date().toISOString(),
};
}
try {
const context = await buildAgentContext(db, "sv", companyId, {
includeHistoricalData: true,
includeModel: true,
});
const prompt = getAgentPrompt("sv", context);
const schema = {
verificationResults: [
{
claim: "string",
cited: "boolean",
sourceType: "string",
accuracy: ["high", "medium", "low"],
notes: "string",
},
],
flaggedClaims: [
{
claim: "string",
issue: "string",
suggestion: "string",
},
],
overallQuality: ["high", "medium", "low"],
recommendations: ["string"],
};
const result = await completeStructured(prompt, schema);
// Extract issues from the result
const issues: ValidationResult["issues"] = [];
for (const flagged of (result as any).flaggedClaims || []) {
issues.push({
severity: "warning",
message: flagged.issue,
suggestion: flagged.suggestion,
});
}
if ((result as any).overallQuality === "low") {
issues.push({
severity: "error",
message: "Overall source quality is low. Many claims may be unsupported.",
});
}
const passed = issues.filter((i) => i.severity === "error").length === 0;
// Emit validation updated event
emitValidationUpdated(
companyId,
undefined,
passed ? "verified" : "flagged",
"sv",
`Verified ${((result as any).verificationResults || []).length} claims`
);
return {
passed,
confidence: (result as any).overallQuality === "high" ? "high" : (result as any).overallQuality === "medium" ? "medium" : "low",
issues,
notes: (result as any).recommendations?.join(". ") || "",
timestamp: new Date().toISOString(),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: `Source verification failed: ${errorMsg}` }],
notes: errorMsg,
timestamp: new Date().toISOString(),
};
}
}
/**
* Model QA Agent
* Audits the financial model for errors and inconsistencies
*/
export async function executeModelQA(
db: Db,
companyId: string
): Promise<ValidationResult> {
if (!isConfigured()) {
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: "LLM API key not configured" }],
notes: "Cannot audit model without LLM access",
timestamp: new Date().toISOString(),
};
}
try {
const context = await buildAgentContext(db, "qa", companyId, {
includeHistoricalData: true,
includeModel: true,
});
const prompt = getAgentPrompt("qa", context);
const schema = {
formulaErrors: [
{
location: "string",
issue: "string",
severity: ["error", "warning"],
fix: "string",
},
],
consistencyIssues: [
{
type: "string",
description: "string",
severity: ["error", "warning"],
},
],
sanityCheckResults: [
{
check: "string",
passed: "boolean",
notes: "string",
},
],
recommendations: ["string"],
overallStatus: ["pass", "warn", "fail"],
};
const result = await completeStructured(prompt, schema);
// Extract issues from the result
const issues: ValidationResult["issues"] = [];
for (const error of (result as any).formulaErrors || []) {
issues.push({
severity: error.severity,
message: error.issue,
location: error.location,
suggestion: error.fix,
});
}
for (const issue of (result as any).consistencyIssues || []) {
issues.push({
severity: issue.severity,
message: issue.description,
suggestion: `Review ${issue.type} consistency`,
});
}
for (const check of (result as any).sanityCheckResults || []) {
if (!check.passed) {
issues.push({
severity: "warning",
message: `Sanity check failed: ${check.check}`,
suggestion: check.notes,
});
}
}
const passed = (result as any).overallStatus !== "fail";
// Emit validation updated event
emitValidationUpdated(
companyId,
"model",
passed ? "verified" : (result as any).overallStatus === "warn" ? "flagged" : "failed",
"qa",
`Model audit ${(result as any).overallStatus}`
);
return {
passed,
confidence: issues.length === 0 ? "high" : issues.filter((i) => i.severity === "error").length > 0 ? "low" : "medium",
issues,
notes: (result as any).recommendations?.join(". ") || "",
timestamp: new Date().toISOString(),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: `Model QA failed: ${errorMsg}` }],
notes: errorMsg,
timestamp: new Date().toISOString(),
};
}
}
/**
* Red Team Agent
* Challenges the investment thesis and identifies weaknesses
*/
export async function executeRedTeam(
db: Db,
companyId: string
): Promise<ValidationResult> {
if (!isConfigured()) {
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: "LLM API key not configured" }],
notes: "Cannot perform red team analysis without LLM access",
timestamp: new Date().toISOString(),
};
}
try {
const context = await buildAgentContext(db, "rt", companyId, {
includeHistoricalData: true,
includeModel: true,
});
const prompt = getAgentPrompt("rt", context);
const schema = {
counterArguments: [
{
thesisPoint: "string",
counterArgument: "string",
severity: ["high", "medium", "low"],
},
],
unstatedAssumptions: [
{
assumption: "string",
risk: "string",
},
],
bearCase: {
scenario: "string",
probability: ["high", "medium", "low"],
keyDrivers: ["string"],
},
redFlags: [
{
flag: "string",
severity: ["high", "medium", "low"],
},
],
recommendation: ["proceed_with_caution", "acceptable", "needs_revision"],
};
const result = await completeStructured(prompt, schema);
// Extract issues from the result
const issues: ValidationResult["issues"] = [];
for (const arg of (result as any).counterArguments || []) {
issues.push({
severity: arg.severity === "high" ? "error" : arg.severity === "medium" ? "warning" : "info",
message: `Counter-argument: ${arg.counterArgument}`,
suggestion: `Address: ${arg.thesisPoint}`,
});
}
for (const assumption of (result as any).unstatedAssumptions || []) {
issues.push({
severity: "warning",
message: `Unstated assumption: ${assumption.assumption}`,
suggestion: assumption.risk,
});
}
for (const flag of (result as any).redFlags || []) {
issues.push({
severity: flag.severity === "high" ? "error" : flag.severity === "medium" ? "warning" : "info",
message: `Red flag: ${flag.flag}`,
suggestion: "Monitor closely",
});
}
const passed = (result as any).recommendation !== "needs_revision";
// Emit validation updated event
emitValidationUpdated(
companyId,
"thesis",
passed ? "verified" : "flagged",
"rt",
(result as any).recommendation
);
return {
passed,
confidence: (result as any).recommendation === "acceptable" ? "high" : (result as any).recommendation === "proceed_with_caution" ? "medium" : "low",
issues,
notes: `Bear case: ${(result as any).bearCase?.scenario || "None identified"}. ${(result as any).redFlags?.length || 0} red flags found.`,
timestamp: new Date().toISOString(),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
passed: false,
confidence: "low",
issues: [{ severity: "error", message: `Red team analysis failed: ${errorMsg}` }],
notes: errorMsg,
timestamp: new Date().toISOString(),
};
}
}
/**
* Run all validation agents for a company
*/
export async function runAllValidations(
db: Db,
companyId: string
): Promise<{
sourceVerification: ValidationResult;
modelQA: ValidationResult;
redTeam: ValidationResult;
}> {
const [sourceVerification, modelQA, redTeam] = await Promise.all([
executeSourceVerification(db, companyId),
executeModelQA(db, companyId),
executeRedTeam(db, companyId),
]);
return { sourceVerification, modelQA, redTeam };
}
/**
* Get validation status for a section
*/
export function getValidationStatus(
validations: {
sourceVerification: ValidationResult;
modelQA: ValidationResult;
redTeam: ValidationResult;
},
sectionId: string
): "verified" | "flagged" | "unverified" | "failed" {
// Map sections to relevant validators
const validatorMap: Record<string, keyof typeof validations> = {
thesis: "redTeam",
"variant-perception": "sourceVerification",
valuation: "modelQA",
model: "modelQA",
"financial-summary": "modelQA",
risks: "redTeam",
};
const validatorKey = validatorMap[sectionId];
if (!validatorKey) return "unverified";
const validation = validations[validatorKey];
if (!validation) return "unverified";
if (validation.passed) return "verified";
if (validation.issues.some((i) => i.severity === "error")) return "failed";
return "flagged";
}

View File

@@ -0,0 +1,160 @@
/**
* Earnings calendar integration
* Uses Yahoo Finance earnings data
*/
export interface EarningsDate {
ticker: string;
quarter: string;
earningsDate: Date | null;
estimatedEPS?: number;
actualEPS?: number;
estimatedRevenue?: number;
actualRevenue?: number;
fiscalQuarterEnding?: string;
}
/**
* Get earnings date for a specific ticker
*/
export async function getEarningsDate(ticker: string): Promise<Date | null> {
try {
const normalizedTicker = ticker.toUpperCase();
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=earnings earningsTrend earningsForecast`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
const result = data.quoteSummary?.result?.[0];
if (!result) {
return null;
}
// Check earningsChart for next earnings date
const earningsChart = result.earningsChart;
if (earningsChart?.currentQuarterEstimateDate) {
return new Date(earningsChart.currentQuarterEstimateDate * 1000);
}
// Try earningsTrend for next quarter
const earningsTrend = result.earningsTrend;
if (earningsTrend?.trend?.[0]?.endDate) {
return new Date(earningsTrend.trend[0].endDate);
}
return null;
} catch (error) {
console.error(`[Earnings] Error fetching earnings date for ${ticker}:`, error);
return null;
}
}
/**
* Get earnings history for a ticker
*/
export async function getEarningsHistory(ticker: string, quarters: number = 4): Promise<EarningsDate[]> {
try {
const normalizedTicker = ticker.toUpperCase();
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=earningsHistory`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return [];
}
const data = await response.json();
const result = data.quoteSummary?.result?.[0];
if (!result?.earningsHistory?.history) {
return [];
}
return result.earningsHistory.history
.slice(0, quarters)
.map((h: any) => ({
ticker: normalizedTicker,
quarter: h.quarter || "",
earningsDate: h.earningsDate ? new Date(h.earningsDate * 1000) : null,
estimatedEPS: h.epsEstimate,
actualEPS: h.epsActual,
estimatedRevenue: h.revenueEstimate,
actualRevenue: h.revenueActual,
}));
} catch (error) {
console.error(`[Earnings] Error fetching earnings history for ${ticker}:`, error);
return [];
}
}
/**
* Get earnings calendar for multiple companies
*/
export async function getEarningsCalendar(tickers: string[]): Promise<Record<string, EarningsDate | null>> {
const results: Record<string, EarningsDate | null> = {};
const batchSize = 5;
for (let i = 0; i < tickers.length; i += batchSize) {
const batch = tickers.slice(i, i + batchSize);
const promises = batch.map(async (ticker) => {
const date = await getEarningsDate(ticker);
if (date) {
results[ticker.toUpperCase()] = {
ticker: ticker.toUpperCase(),
quarter: "Next",
earningsDate: date,
};
}
});
await Promise.all(promises);
if (i + batchSize < tickers.length) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
return results;
}
/**
* Format quarter string from date
*/
export function getQuarterString(date: Date): string {
const month = date.getMonth();
const year = date.getFullYear();
const quarter = Math.floor(month / 3) + 1;
return `Q${quarter} ${year}`;
}
/**
* Check if earnings date is upcoming (within next 30 days)
*/
export function isUpcomingEarnings(date: Date | null): boolean {
if (!date) return false;
const now = new Date();
const daysUntilEarnings = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return daysUntilEarnings >= 0 && daysUntilEarnings <= 30;
}
/**
* Get earnings timing (BMO/AMC)
*/
export function getEarningsTiming(date: Date): "bmo" | "amc" | "unknown" {
// Default to AMC for most tech companies, BMO for others
// In a real implementation, you'd fetch this from earnings data
const hour = date.getHours();
return hour < 12 ? "bmo" : "amc";
}

View File

@@ -0,0 +1,8 @@
/**
* Data module exports
* Provides integration with external data sources
*/
export * from "./sec.js";
export * from "./market.js";
export * from "./earnings.js";

View File

@@ -0,0 +1,268 @@
/**
* Market data integration using Yahoo Finance
* Note: This is a simplified implementation for demonstration
* In production, you'd use a proper market data API or the Yahoo Finance API
*/
export interface PricePoint {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface Quote {
ticker: string;
price: number;
change: number;
changePercent: number;
previousClose: number;
dayHigh: number;
dayLow: number;
volume: number;
marketCap?: number;
currency?: string;
}
/**
* Fetch current quote for a ticker
* Uses Yahoo Finance's unofficial API
*/
export async function fetchQuote(ticker: string): Promise<Quote | null> {
try {
const normalizedTicker = ticker.toUpperCase();
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${normalizedTicker}?interval=1d&range=1d`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
const result = data.chart?.result?.[0];
if (!result) {
return null;
}
const meta = result.meta;
const quote = result.indicators?.quote?.[0];
const prevClose = meta?.previousClose || quote?.close?.[0] || 0;
const currentPrice = meta?.regularMarketPrice || quote?.close?.[quote.close.length - 1] || 0;
if (!currentPrice) {
return null;
}
const change = currentPrice - prevClose;
const changePercent = prevClose > 0 ? (change / prevClose) * 100 : 0;
return {
ticker: normalizedTicker,
price: currentPrice,
change,
changePercent,
previousClose: prevClose,
dayHigh: meta?.regularMarketDayHigh || currentPrice,
dayLow: meta?.regularMarketDayLow || currentPrice,
volume: meta?.regularMarketVolume || 0,
marketCap: meta?.marketCap,
currency: meta?.currency || "USD",
};
} catch (error) {
console.error(`[Market] Error fetching quote for ${ticker}:`, error);
return null;
}
}
/**
* Fetch historical price data
*/
export async function fetchPriceHistory(
ticker: string,
period: "1d" | "5d" | "1mo" | "3mo" | "6mo" | "1y" | "2y" | "5y" | "max" = "1y"
): Promise<PricePoint[]> {
try {
const normalizedTicker = ticker.toUpperCase();
const interval = period === "1d" ? "5m" : period === "5d" ? "15m" : "1d";
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${normalizedTicker}?interval=${interval}&range=${period}`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return [];
}
const data = await response.json();
const result = data.chart?.result?.[0];
if (!result) {
return [];
}
const timestamps = result.timestamp || [];
const quote = result.indicators?.quote?.[0];
if (!quote) {
return [];
}
const points: PricePoint[] = [];
for (let i = 0; i < timestamps.length; i++) {
const open = quote.open?.[i];
const high = quote.high?.[i];
const low = quote.low?.[i];
const close = quote.close?.[i];
const volume = quote.volume?.[i];
if (open != null && high != null && low != null && close != null) {
points.push({
date: new Date(timestamps[i] * 1000).toISOString().split("T")[0],
open,
high,
low,
close,
volume: volume || 0,
});
}
}
return points;
} catch (error) {
console.error(`[Market] Error fetching price history for ${ticker}:`, error);
return [];
}
}
/**
* Fetch multiple quotes at once
*/
export async function fetchQuotes(tickers: string[]): Promise<Record<string, Quote>> {
const results: Record<string, Quote> = {};
// Batch requests to avoid rate limiting
const batchSize = 5;
for (let i = 0; i < tickers.length; i += batchSize) {
const batch = tickers.slice(i, i + batchSize);
const promises = batch.map(async (ticker) => {
const quote = await fetchQuote(ticker);
if (quote) {
results[ticker.toUpperCase()] = quote;
}
});
await Promise.all(promises);
// Small delay between batches
if (i + batchSize < tickers.length) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
return results;
}
/**
* Search for stocks by symbol or name
* This is a simplified search that returns common stock matches
*/
export async function searchStocks(query: string): Promise<Array<{ ticker: string; name: string; exchange: string }>> {
try {
const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(query)}&quotesCount=10&newsCount=0`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return [];
}
const data = await response.json();
const quotes = data.quotes || [];
return quotes
.filter((q: { quoteType?: string; isYahooFinance?: boolean }) =>
q.quoteType === "EQUITY" || q.isYahooFinance === true
)
.map((q: { symbol: string; longname?: string; shortname?: string; exchange: string }) => ({
ticker: q.symbol.toUpperCase(),
name: q.longname || q.shortname || q.symbol,
exchange: q.exchange,
}))
.slice(0, 10);
} catch (error) {
console.error(`[Market] Error searching for "${query}":`, error);
return [];
}
}
/**
* Get company profile info
*/
export interface CompanyProfile {
ticker: string;
name: string;
sector?: string;
industry?: string;
description?: string;
website?: string;
employees?: number;
marketCap?: number;
exchange?: string;
}
export async function getCompanyProfile(ticker: string): Promise<CompanyProfile | null> {
try {
const normalizedTicker = ticker.toUpperCase();
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${normalizedTicker}?modules=summaryProfile assetProfile`;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
const result = data.quoteSummary?.result?.[0];
if (!result) {
return null;
}
const profile = result.summaryProfile || {};
const asset = result.assetProfile || {};
return {
ticker: normalizedTicker,
name: profile.quoteType?.shortName || normalizedTicker,
sector: profile.sector,
industry: profile.industry,
description: profile.longBusinessSummary,
website: profile.website,
employees: profile.fullTimeEmployees,
marketCap: profile.marketCap,
exchange: asset.exchangeName || "NASDAQ",
};
} catch (error) {
console.error(`[Market] Error fetching profile for ${ticker}:`, error);
return null;
}
}

View File

@@ -0,0 +1,306 @@
/**
* SEC EDGAR API client for fetching company filings and data
* https://www.sec.gov/edgar/sec-api-documentation
*/
const EDGAR_BASE_URL = "https://www.sec.gov/Archives/edgar/data";
const EDGAR_SEARCH_API = "https://www.sec.gov/Archives/edgar/data";
// Rate limiting: SEC requires 10 requests/second max
const requestQueue: Array<() => Promise<void>> = [];
let isProcessing = false;
const REQUEST_DELAY = 110; // 110ms between requests = ~9 req/sec
async function rateLimit<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
requestQueue.push(async () => {
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
}
});
processQueue();
});
}
async function processQueue() {
if (isProcessing || requestQueue.length === 0) return;
isProcessing = true;
const fn = requestQueue.shift();
if (fn) {
await fn();
setTimeout(() => {
isProcessing = false;
processQueue();
}, REQUEST_DELAY);
} else {
isProcessing = false;
}
}
export interface SECFiling {
id: string;
formType: string;
filedDate: string;
title: string;
url: string;
}
export interface SECCompanyInfo {
cik: string;
ticker: string;
name: string;
description?: string;
sic?: string;
stateOfIncorporation?: string;
businessAddress?: {
street1?: string;
street2?: string;
city?: string;
stateOrCountry?: string;
zipCode?: string;
};
}
/**
* Get the User-Agent header required by SEC
* SEC requires a contact email in the User-Agent
*/
function getHeaders(): HeadersInit {
const userEmail = process.env.SEC_USER_AGENT || "your-email@example.com";
return {
"User-Agent": `MosaicIQ Research App (${userEmail})`,
"Accept-Encoding": "gzip, deflate",
"Accept": "application/json",
};
}
/**
* Look up company CIK by ticker symbol
*/
export async function lookupCIK(ticker: string): Promise<string | null> {
try {
const url = `https://www.sec.gov/files/company_tickers.json`;
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
const data = await response.json();
// The SEC returns a flat object with 0, 1, 2... keys
for (const key of Object.keys(data)) {
const entry = data[key];
if (entry.ticker.toLowerCase() === ticker.toLowerCase()) {
return String(entry.cik_str);
}
}
return null;
} catch (error) {
console.error(`[SEC] Error looking up CIK for ${ticker}:`, error);
return null;
}
}
/**
* Fetch company information from SEC submissions
*/
export async function getCompanyInfo(ticker: string): Promise<SECCompanyInfo | null> {
try {
const cik = await lookupCIK(ticker);
if (!cik) return null;
// Pad CIK to 10 digits with leading zeros
const paddedCik = cik.padStart(10, "0");
const url = `${EDGAR_BASE_URL}/${paddedCik}/index.json`;
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
if (!response.ok) {
return null;
}
const data = await response.json();
// Extract company info from the directory structure
const companyInfo: SECCompanyInfo = {
cik,
ticker: ticker.toUpperCase(),
name: data.company?.name || ticker.toUpperCase(),
};
return companyInfo;
} catch (error) {
console.error(`[SEC] Error fetching company info for ${ticker}:`, error);
return null;
}
}
/**
* Fetch filings for a company
*/
export async function fetchFilings(
ticker: string,
options: {
formTypes?: string[];
limit?: number;
since?: string;
} = {}
): Promise<SECFiling[]> {
try {
const cik = await lookupCIK(ticker);
if (!cik) return [];
const paddedCik = cik.padStart(10, "0");
const url = `${EDGAR_BASE_URL}/${paddedCik}/index.json`;
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
if (!response.ok) {
return [];
}
const data = await response.json();
const filings = data.directory?.item || [];
let filtered = filings.filter((f: { type: string }) => {
// Only include common filing types
const commonTypes = ["10-K", "10-Q", "8-K", "10-K/A", "10-Q/A", "8-K/A", "DEF 14A", "13F-HR"];
return commonTypes.includes(f.type);
});
// Filter by form types if specified
if (options.formTypes && options.formTypes.length > 0) {
filtered = filtered.filter((f: { type: string }) =>
options.formTypes!.includes(f.type)
);
}
// Filter by date if specified
if (options.since) {
const sinceDate = new Date(options.since);
filtered = filtered.filter((f: { last_modified: string }) => {
const filedDate = new Date(f.last_modified);
return filedDate >= sinceDate;
});
}
// Sort by date descending and limit
filtered.sort((a: { last_modified: string }, b: { last_modified: string }) =>
new Date(b.last_modified).getTime() - new Date(a.last_modified).getTime()
);
if (options.limit) {
filtered = filtered.slice(0, options.limit);
}
return filtered.map((f: { type: string; name: string; last_modified: string }) => ({
id: `${ticker}-${f.type}-${f.last_modified}`,
formType: f.type,
filedDate: f.last_modified,
title: `${f.type} - ${new Date(f.last_modified).toLocaleDateString()}`,
url: `${EDGAR_BASE_URL}/${paddedCik}/${f.name}`,
}));
} catch (error) {
console.error(`[SEC] Error fetching filings for ${ticker}:`, error);
return [];
}
}
/**
* Fetch filing content as text
*/
export async function fetchFilingContent(url: string): Promise<string | null> {
try {
const response = await rateLimit(() => fetch(url, { headers: getHeaders() }));
if (!response.ok) return null;
const text = await response.text();
return text;
} catch (error) {
console.error("[SEC] Error fetching filing content:", error);
return null;
}
}
/**
* Search companies by query
*/
export async function searchCompanies(query: string): Promise<Array<{ ticker: string; name: string; cik: string }>> {
try {
// Try to parse as ticker first
if (query.length <= 5 && query.toUpperCase() === query) {
const cik = await lookupCIK(query);
if (cik) {
const info = await getCompanyInfo(query);
if (info) {
return [{ ticker: info.ticker, name: info.name, cik }];
}
}
}
// For broader search, we'd need a different API or service
// For now, return empty and suggest using ticker lookup
return [];
} catch (error) {
console.error(`[SEC] Error searching for "${query}":`, error);
return [];
}
}
/**
* Parse key sections from a 10-K filing
*/
export interface Parsed10K {
business: string;
riskFactors: string;
financialStatements: string;
managementDiscussion: string;
}
export async function parse10K(filingUrl: string): Promise<Parsed10K | null> {
try {
const content = await fetchFilingContent(filingUrl);
if (!content) return null;
// This is a simplified parser - a real implementation would use XBRL parsing
const result: Parsed10K = {
business: extractSection(content, ["ITEM 1", "Item 1"], ["ITEM 1A", "Item 1A", "ITEM 2", "Item 2"]),
riskFactors: extractSection(content, ["ITEM 1A", "Item 1A"], ["ITEM 1B", "Item 1B", "ITEM 2", "Item 2"]),
financialStatements: extractSection(content, ["ITEM 8", "Item 8"], ["ITEM 9", "Item 9"]),
managementDiscussion: extractSection(content, ["ITEM 7", "Item 7"], ["ITEM 7A", "Item 7A", "ITEM 8", "Item 8"]),
};
return result;
} catch (error) {
console.error("[SEC] Error parsing 10-K:", error);
return null;
}
}
function extractSection(content: string, startPatterns: string[], endPatterns: string[]): string {
const lines = content.split("\n");
let startIndex = -1;
let endIndex = lines.length;
for (const pattern of startPatterns) {
const idx = lines.findIndex((line) =>
line.toUpperCase().includes(pattern.toUpperCase())
);
if (idx !== -1) {
startIndex = Math.max(startIndex, idx);
}
}
for (const pattern of endPatterns) {
const idx = lines.findIndex((line) =>
line.toUpperCase().includes(pattern.toUpperCase())
);
if (idx !== -1) {
endIndex = Math.min(endIndex, idx);
}
}
if (startIndex === -1) return "";
return lines.slice(startIndex, endIndex).join("\n").trim();
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { initDatabase, closeDatabase } from "./database.js";
import { SCHEMA_VERSION } from "./schema.js";
import { getPortfolio } from "./queries.js";
describe("database initialization", () => {
it("initializes schema, default portfolio, foreign keys, and version", () => {
const db = initDatabase({ inMemory: true });
try {
const version = db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string };
expect(version.value).toBe(String(SCHEMA_VERSION));
expect(getPortfolio(db)?.id).toBe("default");
expect(db.pragma("foreign_keys", { simple: true })).toBe(1);
} finally {
closeDatabase(db);
}
});
});

View File

@@ -0,0 +1,119 @@
/**
* Database connection and initialization for MosaicIQ
*/
import Database from "better-sqlite3";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, mkdirSync } from "node:fs";
import { SCHEMA_VERSION, SQL_SCHEMA, DEFAULT_PORTFOLIO_ID } from "./schema.js";
export type Db = Database.Database;
export interface DatabaseConfig {
path?: string;
inMemory?: boolean;
}
/**
* Get the default data directory for the platform
*/
export function getDataDir(): string {
const platform = process.platform;
const base = platform === "darwin"
? `${process.env.HOME}/Documents/MosaicIQ`
: platform === "win32"
? `${process.env.LOCALAPPDATA}\\MosaicIQ`
: `${process.env.HOME}/.local/share/mosaiciq`;
if (!existsSync(base)) {
mkdirSync(base, { recursive: true });
}
return base;
}
/**
* Get the default database path
*/
export function getDefaultDbPath(): string {
return `${getDataDir()}/mosaiciq.db`;
}
/**
* Initialize the database with schema
*/
export function initDatabase(config: DatabaseConfig = {}): Db {
const dbPath = config.inMemory ? ":memory:" : (config.path ?? getDefaultDbPath());
if (!config.inMemory && dbPath !== ":memory:") {
const dbDir = dirname(dbPath);
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
}
const db = new Database(dbPath);
// Enable WAL mode for better concurrent access
db.pragma("journal_mode = WAL");
// Enable foreign keys
db.pragma("foreign_keys = ON");
// Check if schema exists
const hasMeta = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_meta'").get();
const version = hasMeta
? db.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get() as { value: string } | undefined
: undefined;
if (!version) {
// Fresh database - create schema
db.exec(SQL_SCHEMA);
// Set schema version
db.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
// Create default portfolio
db.prepare(`
INSERT INTO portfolios (id, name)
VALUES (?, ?)
`).run(DEFAULT_PORTFOLIO_ID, "Core Retail Coverage");
console.log(`[DB] Initialized database at ${dbPath}`);
} else {
const currentVersion = Number(version.value);
if (currentVersion !== SCHEMA_VERSION) {
console.warn(`[DB] Schema version mismatch: expected ${SCHEMA_VERSION}, got ${currentVersion}`);
// TODO: Run migrations
}
}
return db;
}
/**
* Close the database connection
*/
export function closeDatabase(db: Db): void {
db.close();
}
/**
* Get a database connection (singleton pattern)
*/
let dbInstance: Db | null = null;
export function getDatabase(config?: DatabaseConfig): Db {
if (!dbInstance) {
dbInstance = initDatabase(config);
}
return dbInstance;
}
export function resetDatabase(): void {
if (dbInstance) {
closeDatabase(dbInstance);
dbInstance = null;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Database module exports
*/
export * from "./database.js";
export * from "./schema.js";
export * from "./queries.js";
export * from "./seed.js";
export * from "./rpcHandler.js";

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { closeDatabase, initDatabase } from "./database.js";
import { getClientSettings, getModel, updateModelCell } from "./queries.js";
function withDb(test: (db: ReturnType<typeof initDatabase>) => void) {
const db = initDatabase({ inMemory: true });
try {
test(db);
} finally {
closeDatabase(db);
}
}
function insertModel(db: ReturnType<typeof initDatabase>) {
db.prepare("INSERT INTO companies (id, ticker, name, sector) VALUES ('cost', 'COST', 'Costco', 'Retail')").run();
db.prepare("INSERT INTO models (id, company_id, tab) VALUES ('model-1', 'cost', 'base')").run();
db.prepare("INSERT INTO model_headers (model_id, position, label) VALUES ('model-1', 0, 'FY25')").run();
db.prepare("INSERT INTO model_rows (model_id, position, label, kind, \"values\") VALUES ('model-1', 0, 'Revenue', 'actual', ?)").run(
JSON.stringify(["1", "2"])
);
}
describe("queries", () => {
it("returns parsed model values", () => withDb((db) => {
insertModel(db);
expect(getModel(db, "cost", "base").rows[0]?.values).toEqual(["1", "2"]);
}));
it("falls back for malformed model JSON values", () => withDb((db) => {
insertModel(db);
db.prepare("UPDATE model_rows SET \"values\" = '{bad json' WHERE model_id = 'model-1'").run();
expect(getModel(db, "cost", "base").rows[0]?.values).toEqual([]);
}));
it("rejects out-of-range model cell updates", () => withDb((db) => {
insertModel(db);
expect(updateModelCell(db, "cost", "base", 0, 4, "42").ok).toBe(false);
expect(updateModelCell(db, "cost", "base", -1, 0, "42").ok).toBe(false);
}));
it("ignores malformed client settings and validates known fields", () => withDb((db) => {
db.prepare("INSERT INTO client_settings (key, value) VALUES ('theme', ?)").run(JSON.stringify("dark"));
db.prepare("INSERT INTO client_settings (key, value) VALUES ('sidebarWidth', ?)").run(JSON.stringify(20));
db.prepare("INSERT INTO client_settings (key, value) VALUES ('density', ?)").run("{bad json");
const settings = getClientSettings(db);
expect(settings.theme).toBe("dark");
expect(settings.sidebarWidth).toBe(240);
expect(settings.density).toBe("comfortable");
}));
});

View File

@@ -0,0 +1,727 @@
/**
* Prepared SQL queries for MosaicIQ database operations
* These provide type-safe access to the database
*/
import type { Db } from "./database.js";
import { z } from "zod";
import type {
Company,
Holding,
Agent,
WorkspaceSection,
Catalyst,
Alert,
Risk,
EarningsSchedule,
Filing,
ModelRow,
MemoSection,
MemoCitation,
MemoAnnotation,
MemoSectionReview,
Snapshot,
ExportRecord,
ClientSettings,
ServerSettings,
} from "@mosaiciq/contracts/rpc";
import { ClientSettingsSchema, ServerSettingsSchema } from "@mosaiciq/contracts/rpcSchemas";
export function parseJsonWithSchema<T>(
value: string,
schema: z.ZodType<T>,
fallback: T
): T {
try {
return schema.parse(JSON.parse(value));
} catch {
return fallback;
}
}
// ============== Portfolio ==============
export function getPortfolio(db: Db) {
const stmt = db.prepare(`
SELECT p.id, p.name, p.active_company_id as activeCompanyId,
json_group_array(
json_object(
'ticker', h.ticker,
'name', h.name,
'price', h.price,
'changePct', h.change_pct,
'weight', h.weight
)
) as holdings
FROM portfolios p
LEFT JOIN holdings h ON p.id = h.portfolio_id
WHERE p.id = ?
GROUP BY p.id
`);
const row = stmt.get("default") as {
id: string;
name: string;
activeCompanyId: string | null;
holdings: string;
} | undefined;
if (!row) return null;
return {
id: row.id,
name: row.name,
activeCompanyId: row.activeCompanyId ?? "",
holdings: row.holdings ? JSON.parse(row.holdings).filter((h: Holding) => h.ticker) : [],
};
}
export function addHolding(db: Db, portfolioId: string, holding: Holding) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO holdings (portfolio_id, ticker, name, price, change_pct, weight)
VALUES (?, ?, ?, ?, ?, ?)
`);
return stmt.run(portfolioId, holding.ticker, holding.name, holding.price, holding.changePct, holding.weight);
}
export function removeHolding(db: Db, portfolioId: string, ticker: string) {
const stmt = db.prepare("DELETE FROM holdings WHERE portfolio_id = ? AND ticker = ?");
return stmt.run(portfolioId, ticker);
}
export function setActiveCompany(db: Db, portfolioId: string, companyId: string) {
const stmt = db.prepare("UPDATE portfolios SET active_company_id = ? WHERE id = ?");
return stmt.run(companyId, portfolioId);
}
// ============== Companies ==============
export function getCompanyByTicker(db: Db, ticker: string): Company | null {
const stmt = db.prepare(`
SELECT id, ticker, name, sector, sub_industry as subIndustry,
price as price, change_pct as changePct, thesis,
founded, headquarters, employees
FROM companies
WHERE ticker = ?
`);
return stmt.get(ticker.toUpperCase()) as Company | null;
}
export function getCompany(db: Db, companyId: string): Company | null {
const stmt = db.prepare(`
SELECT id, ticker, name, sector, sub_industry as subIndustry,
price as price, change_pct as changePct, thesis,
founded, headquarters, employees
FROM companies
WHERE id = ?
`);
return stmt.get(companyId) as Company | null;
}
export function searchCompanies(db: Db, query: string): Array<{ ticker: string; name: string; sector: string }> {
const stmt = db.prepare(`
SELECT ticker, name, sector
FROM companies
WHERE ticker LIKE ? OR name LIKE ? OR sector LIKE ?
ORDER BY
CASE WHEN ticker LIKE ? THEN 1 ELSE 2 END,
ticker
LIMIT 50
`);
const pattern = `%${query}%`;
return stmt.all(pattern, pattern, pattern, pattern) as Array<{ ticker: string; name: string; sector: string }>;
}
export function upsertCompany(db: Db, company: Omit<Company, "id"> & { id?: string }) {
const id = company.id ?? `company-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO companies (id, ticker, name, sector, sub_industry, price, change_pct, thesis, founded, headquarters, employees)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
sub_industry = excluded.sub_industry,
price = excluded.price,
change_pct = excluded.change_pct,
thesis = excluded.thesis,
founded = excluded.founded,
headquarters = excluded.headquarters,
employees = excluded.employees,
updated_at = datetime('now')
`);
return stmt.run(
id,
company.ticker,
company.name,
company.sector,
company.subIndustry ?? null,
company.price,
company.changePct,
company.thesis,
company.founded ?? null,
company.headquarters ?? null,
company.employees ?? null
);
}
// ============== Workspace ==============
export function getWorkspaceSection(db: Db, companyId: string, section: string): WorkspaceSection | null {
const stmt = db.prepare(`
SELECT id, title, content, validation_state as validationState, source_agent as sourceAgent
FROM workspace_sections
WHERE company_id = ? AND title = ?
`);
return stmt.get(companyId, section) as WorkspaceSection | null;
}
export function listWorkspaceSources(db: Db, companyId: string) {
const stmt = db.prepare(`
SELECT type, title, metadata FROM workspace_sources
WHERE company_id = ?
ORDER BY created_at DESC
`);
return stmt.all(companyId);
}
// ============== Catalysts ==============
export function listCatalysts(db: Db, companyId: string): Catalyst[] {
const stmt = db.prepare(`
SELECT id, company_id as companyId, date, event, impact as impact,
thesis_relevance as thesisRelevance, source
FROM catalysts
WHERE company_id = ?
ORDER BY date DESC
`);
return stmt.all(companyId) as Catalyst[];
}
// ============== Alerts ==============
export function listAlerts(db: Db, companyId?: string, since?: string): Alert[] {
let query = "SELECT id, company_id as companyId, timestamp, type, description, thesis_impact as thesisImpact, status, target_section as targetSection FROM alerts WHERE 1=1";
const params: (string | undefined)[] = [];
if (companyId) {
query += " AND company_id = ?";
params.push(companyId);
}
if (since) {
query += " AND timestamp > ?";
params.push(since);
}
query += " ORDER BY timestamp DESC";
const stmt = db.prepare(query);
return stmt.all(...params) as Alert[];
}
// ============== Risks ==============
export function listRisks(db: Db, companyId: string): Risk[] {
const stmt = db.prepare(`
SELECT id, company_id as companyId, risk, category, severity, likelihood, mitigation, status
FROM risks
WHERE company_id = ?
ORDER BY severity DESC, likelihood DESC
`);
return stmt.all(companyId) as Risk[];
}
export function addRisk(db: Db, companyId: string, risk: Omit<Risk, "id" | "companyId">): Risk {
const id = `risk-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO risks (id, company_id, risk, category, severity, likelihood, mitigation, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, companyId, risk.risk, risk.category, risk.severity, risk.likelihood, risk.mitigation, risk.status);
return { ...risk, id, companyId };
}
// ============== Earnings ==============
export function getEarningsSchedule(db: Db, companyId: string): EarningsSchedule[] {
const stmt = db.prepare(`
SELECT id, company_id as companyId, quarter, expected_date as expectedDate, timing,
actual_revenue as actualRevenue, expected_revenue as expectedRevenue,
actual_eps as actualEps, expected_eps as expectedEps
FROM earnings_schedules
WHERE company_id = ?
ORDER BY expected_date ASC
`);
return stmt.all(companyId) as EarningsSchedule[];
}
// ============== Filings ==============
export function listFilings(db: Db, companyId: string, since?: string): Filing[] {
let query = "SELECT id, company_id as companyId, form_type as formType, filed_date as filedDate, title, key_changes as keyChanges, reviewed FROM filings WHERE company_id = ?";
const params: (string | undefined)[] = [companyId];
if (since) {
query += " AND filed_date > ?";
params.push(since);
}
query += " ORDER BY filed_date DESC";
const stmt = db.prepare(query);
return (stmt.all(...params) as Array<Omit<Filing, "reviewed"> & { reviewed: number | boolean | null }>).map((filing) => ({
...filing,
reviewed: Boolean(filing.reviewed),
}));
}
// ============== Model ==============
export function getModel(db: Db, companyId: string, tab: string): { headers: string[]; rows: ModelRow[] } {
const headerStmt = db.prepare(`
SELECT label FROM model_headers
WHERE model_id = (SELECT id FROM models WHERE company_id = ? AND tab = ?)
ORDER BY position
`);
const rowStmt = db.prepare(`
SELECT label, kind, "values"
FROM model_rows
WHERE model_id = (SELECT id FROM models WHERE company_id = ? AND tab = ?)
ORDER BY position
`);
const headers = headerStmt.all(companyId, tab).map((h) => (h as { label: string }).label);
const rows = (rowStmt.all(companyId, tab) as Array<Omit<ModelRow, "values"> & { values: string }>).map((row) => ({
label: row.label,
kind: row.kind,
values: parseJsonWithSchema(row.values, z.array(z.string()), []),
}));
return { headers, rows };
}
export function updateModelCell(
db: Db,
companyId: string,
tab: string,
row: number,
col: number,
value: string
): { ok: boolean; affectedCells: string[] } {
if (row < 0 || col < 0) return { ok: false, affectedCells: [] };
// Get model ID
const modelStmt = db.prepare("SELECT id FROM models WHERE company_id = ? AND tab = ?");
const model = modelStmt.get(companyId, tab) as { id: string } | undefined;
if (!model) return { ok: false, affectedCells: [] };
// Update the specific cell
const rowStmt = db.prepare("SELECT \"values\" FROM model_rows WHERE model_id = ? AND position = ?");
const rowRecord = rowStmt.get(model.id, row) as { values: string } | undefined;
if (!rowRecord) return { ok: false, affectedCells: [] };
const values = parseJsonWithSchema(rowRecord.values, z.array(z.string()), []);
if (col < 0 || col >= values.length) return { ok: false, affectedCells: [] };
values[col] = value;
const updateStmt = db.prepare("UPDATE model_rows SET \"values\" = ? WHERE model_id = ? AND position = ?");
updateStmt.run(JSON.stringify(values), model.id, row);
return { ok: true, affectedCells: [`${row}-${col}`] };
}
// ============== Memo ==============
export function getMemo(db: Db, companyId: string) {
const memoStmt = db.prepare("SELECT status FROM memos WHERE company_id = ?");
const memo = memoStmt.get(companyId) as { status: "draft" | "review" | "final" } | undefined;
const sectionsStmt = db.prepare(`
SELECT id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
FROM memo_sections
WHERE company_id = ?
ORDER BY id
`);
const sections = sectionsStmt.all(companyId) as MemoSection[];
const citationsStmt = db.prepare(`
SELECT id, label, section_id as sectionId, type, title, reference, verification_status as verificationStatus, source_url as sourceUrl
FROM memo_citations
WHERE company_id = ?
`);
const citations = citationsStmt.all(companyId) as MemoCitation[];
const annotationsStmt = db.prepare(`
SELECT id, section_id as sectionId, kind, selected_text as selectedText, comment, created_by as createdBy, created_at as createdAt, status
FROM memo_annotations
WHERE company_id = ?
ORDER BY created_at DESC
`);
const annotations = annotationsStmt.all(companyId) as MemoAnnotation[];
const reviewsStmt = db.prepare(`
SELECT section_id as sectionId, status, updated_at as updatedAt
FROM memo_section_reviews
WHERE company_id = ?
`);
const sectionReviews = reviewsStmt.all(companyId) as MemoSectionReview[];
return {
status: memo?.status ?? "draft",
sections,
citations,
annotations,
sectionReviews,
};
}
export function updateMemoSection(
db: Db,
companyId: string,
sectionId: string,
updates: Partial<Pick<MemoSection, "title" | "content">>
): MemoSection {
const stmt = db.prepare(`
UPDATE memo_sections
SET title = COALESCE(?, title),
content = COALESCE(?, content),
updated_at = datetime('now')
WHERE id = ? AND company_id = ?
RETURNING id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
`);
const result = stmt.get(updates.title, updates.content, sectionId, companyId) as MemoSection;
return result;
}
export function addMemoAnnotation(
db: Db,
companyId: string,
annotation: Omit<MemoAnnotation, "id" | "companyId">
): MemoAnnotation {
const id = `annotation-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`);
return stmt.get(
id,
companyId,
annotation.sectionId,
annotation.kind,
annotation.selectedText,
annotation.comment ?? null,
annotation.createdBy,
annotation.createdAt,
annotation.status
) as MemoAnnotation;
}
export function resolveMemoAnnotation(db: Db, companyId: string, annotationId: string): MemoAnnotation {
const stmt = db.prepare(`
UPDATE memo_annotations
SET status = 'resolved'
WHERE id = ? AND company_id = ?
RETURNING *
`);
return stmt.get(annotationId, companyId) as MemoAnnotation;
}
export function updateMemoSectionReview(
db: Db,
companyId: string,
sectionId: string,
status: "pending" | "in_review" | "approved" | "changes_requested"
): MemoSectionReview {
const stmt = db.prepare(`
INSERT INTO memo_section_reviews (company_id, section_id, status, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(company_id, section_id) DO UPDATE SET
status = excluded.status,
updated_at = datetime('now')
RETURNING *
`);
return stmt.get(companyId, sectionId, status) as MemoSectionReview;
}
// ============== Agents ==============
export function listAgents(db: Db, companyId?: string): Agent[] {
const query = `
SELECT
a.id,
a.name,
COALESCE(r.status, 'idle') as status,
COALESCE(r.progress, 0) as progress,
r.action,
a.pipeline
FROM agents a
LEFT JOIN agent_runs r ON a.id = r.agent_id
AND r.id = (
SELECT id FROM agent_runs
WHERE agent_id = a.id
ORDER BY created_at DESC LIMIT 1
)
ORDER BY a.name
`;
const stmt = db.prepare(query);
return stmt.all() as Agent[];
}
export function startAgent(db: Db, agentId: string, companyId: string): { runId: string } {
const runId = `run-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO agent_runs (id, agent_id, company_id, status, started_at)
VALUES (?, ?, ?, 'running', datetime('now'))
`);
stmt.run(runId, agentId, companyId);
return { runId };
}
export function pauseAgent(db: Db, agentId: string): { ok: boolean } {
const stmt = db.prepare(`
UPDATE agent_runs
SET status = 'paused'
WHERE agent_id = ? AND status = 'running'
`);
const result = stmt.run(agentId);
return { ok: result.changes > 0 };
}
export function restartAgent(db: Db, agentId: string): { runId: string } {
const runId = `run-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO agent_runs (id, agent_id, status, started_at)
VALUES (?, ?, 'running', datetime('now'))
`);
stmt.run(runId, agentId);
return { runId };
}
// ============== Snapshots ==============
export function createSnapshot(
db: Db,
companyId: string,
data: string,
options: Partial<Pick<Snapshot, "label" | "type" | "changeCount">> = {}
): Snapshot {
const id = `snapshot-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO snapshots (id, company_id, timestamp, label, type, change_count, data)
VALUES (?, ?, datetime('now'), ?, ?, ?, ?)
RETURNING *
`);
return stmt.get(
id,
companyId,
options.label ?? null,
options.type ?? "auto",
options.changeCount ?? 0,
data
) as Snapshot;
}
export function listSnapshots(db: Db, companyId: string): Snapshot[] {
const stmt = db.prepare(`
SELECT id, company_id as companyId, timestamp, label, type, change_count as changeCount
FROM snapshots
WHERE company_id = ?
ORDER BY timestamp DESC
LIMIT 100
`);
return stmt.all(companyId) as Snapshot[];
}
export function getSnapshot(db: Db, snapshotId: string): Snapshot | null {
const stmt = db.prepare("SELECT * FROM snapshots WHERE id = ?");
return stmt.get(snapshotId) as Snapshot | null;
}
export function deleteSnapshot(db: Db, snapshotId: string): boolean {
const stmt = db.prepare("DELETE FROM snapshots WHERE id = ?");
const result = stmt.run(snapshotId);
return result.changes > 0;
}
// ============== Exports ==============
export function listExports(db: Db, companyId?: string): ExportRecord[] {
let query = "SELECT id, type, title, company_id as companyId, format, file_size as fileSize, status, created_at as createdAt, download_url as downloadUrl FROM export_records";
const params: (string | undefined)[] = [];
if (companyId) {
query += " WHERE company_id = ?";
params.push(companyId);
}
query += " ORDER BY created_at DESC";
const stmt = db.prepare(query);
return stmt.all(...params) as ExportRecord[];
}
export function createExport(
db: Db,
type: string,
title: string,
companyId: string,
format: string
): { exportId: string } {
const id = `export-${Date.now()}`;
const stmt = db.prepare(`
INSERT INTO export_records (id, type, title, company_id, format, status)
VALUES (?, ?, ?, ?, ?, 'processing')
`);
stmt.run(id, type, title, companyId, format);
return { exportId: id };
}
export function updateExportStatus(
db: Db,
exportId: string,
status: "processing" | "complete" | "failed",
downloadUrl?: string
): void {
const stmt = db.prepare(`
UPDATE export_records
SET status = ?, download_url = COALESCE(?, download_url)
WHERE id = ?
`);
stmt.run(status, downloadUrl ?? null, exportId);
}
// ============== Settings ==============
const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
theme: "light",
density: "comfortable",
sidebarWidth: 240,
navCollapsed: {},
keybindings: {
"memo.save": "Cmd+S",
"memo.newSection": "Cmd+Shift+N",
"model.addCell": "Cmd+Enter",
"navigation.workspace": "Cmd+1",
"navigation.model": "Cmd+2",
"navigation.memo": "Cmd+3",
"navigation.agents": "Cmd+4",
"navigation.home": "Cmd+0",
},
};
export function getClientSettings(db: Db): ClientSettings {
const stmt = db.prepare("SELECT key, value FROM client_settings");
const rows = stmt.all() as Array<{ key: string; value: string }>;
const settings = { ...DEFAULT_CLIENT_SETTINGS };
for (const row of rows) {
try {
const parsed = JSON.parse(row.value) as unknown;
if (row.key === "keybindings") {
const keybindings = z.record(z.string()).safeParse(parsed);
if (keybindings.success) settings.keybindings = { ...settings.keybindings, ...keybindings.data };
} else if (row.key === "navCollapsed") {
const navCollapsed = z.record(z.boolean()).safeParse(parsed);
if (navCollapsed.success) settings.navCollapsed = { ...settings.navCollapsed, ...navCollapsed.data };
} else {
const candidate = { ...settings, [row.key]: parsed };
const validated = ClientSettingsSchema.safeParse(candidate);
if (validated.success) {
Object.assign(settings, validated.data);
}
}
} catch {
// Skip invalid values
}
}
return ClientSettingsSchema.parse(settings);
}
export function updateClientSetting(db: Db, key: string, value: unknown): void {
const stmt = db.prepare(`
INSERT INTO client_settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')
`);
stmt.run(key, JSON.stringify(value));
}
export function updateClientSettings(db: Db, settings: Partial<ClientSettings>): void {
if (settings.theme !== undefined) {
updateClientSetting(db, "theme", settings.theme);
}
if (settings.density !== undefined) {
updateClientSetting(db, "density", settings.density);
}
if (settings.sidebarWidth !== undefined) {
updateClientSetting(db, "sidebarWidth", settings.sidebarWidth);
}
if (settings.navCollapsed !== undefined) {
updateClientSetting(db, "navCollapsed", settings.navCollapsed);
}
if (settings.keybindings !== undefined) {
updateClientSetting(db, "keybindings", settings.keybindings);
}
}
export function getServerSettings(db: Db): ServerSettings {
const stmt = db.prepare("SELECT agent_id as agentId, config FROM agent_configs");
const rows = stmt.all() as Array<{ agentId: string; config: string }>;
const agentConfigs: Record<string, unknown> = {};
for (const row of rows) {
try {
agentConfigs[row.agentId] = JSON.parse(row.config) as unknown;
} catch {
agentConfigs[row.agentId] = {};
}
}
return ServerSettingsSchema.parse({
agentConfigs,
dataSources: {
sec_filings: true,
transcripts: true,
market_data: false,
analyst_reports: false,
press_releases: true,
},
exportPipelines: {},
});
}
export function updateAgentConfig(db: Db, agentId: string, config: Record<string, unknown>): void {
const stmt = db.prepare(`
INSERT INTO agent_configs (agent_id, config, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(agent_id) DO UPDATE SET
config = excluded.config,
updated_at = datetime('now')
`);
stmt.run(agentId, JSON.stringify(config));
}
export function updateServerSettings(
db: Db,
settings: Partial<ServerSettings>
): void {
if (settings.agentConfigs !== undefined) {
for (const [agentId, config] of Object.entries(settings.agentConfigs)) {
updateAgentConfig(db, agentId, config as Record<string, unknown>);
}
}
// Data sources and export pipelines are stored separately
// For now, they're hardcoded in getServerSettings
// TODO: Add separate tables for data_sources and export_pipelines
}

View File

@@ -0,0 +1,572 @@
/**
* Real RPC handler backed by SQLite database
* Replaces mockRpc.ts with persistent data storage
*/
import { searchStocks, fetchQuote, getCompanyProfile } from "../data/market.js";
import { fetchFilings as fetchSECFilings, getCompanyInfo as getSECCompanyInfo } from "../data/sec.js";
import { getEarningsDate } from "../data/earnings.js";
import { executeSourceVerification, executeModelQA, executeRedTeam, runAllValidations } from "../agents/validationAgents.js";
import type { Db } from "./database.js";
import type {
RpcMethod,
RpcRequestMap,
RpcResponseMap,
RpcResult,
ModelRow,
WorkspaceSection,
ClientSettings,
ServerSettings,
} from "@mosaiciq/contracts/rpc";
import {
getPortfolio,
addHolding as dbAddHolding,
removeHolding as dbRemoveHolding,
setActiveCompany as dbSetActiveCompany,
getCompany,
getCompanyByTicker,
searchCompanies as dbSearchCompanies,
upsertCompany,
getWorkspaceSection as dbGetWorkspaceSection,
listCatalysts,
listAlerts,
listRisks,
addRisk as dbAddRisk,
getEarningsSchedule,
listFilings,
getModel,
updateModelCell as dbUpdateModelCell,
getMemo,
updateMemoSection as dbUpdateMemoSection,
addMemoAnnotation as dbAddMemoAnnotation,
resolveMemoAnnotation as dbResolveMemoAnnotation,
updateMemoSectionReview as dbUpdateMemoSectionReview,
listAgents,
startAgent as dbStartAgent,
pauseAgent as dbPauseAgent,
restartAgent as dbRestartAgent,
listExports,
createExport as dbCreateExport,
updateExportStatus,
getClientSettings,
updateClientSettings,
getServerSettings,
updateServerSettings as dbUpdateServerSettings,
updateAgentConfig,
} from "./queries.js";
import { DEFAULT_PORTFOLIO_ID } from "./schema.js";
export function createRpcHandler(db: Db) {
return async function handleRpc<T extends RpcMethod>(
method: T,
payload: RpcRequestMap[T]
): Promise<RpcResult<T>> {
try {
switch (method) {
case "portfolio.get": {
const portfolio = getPortfolio(db);
if (!portfolio) {
return fail("NOT_FOUND", "Portfolio not found.") as RpcResult<T>;
}
return ok("portfolio.get", portfolio) as RpcResult<T>;
}
case "portfolio.addHolding": {
const { ticker } = payload as RpcRequestMap["portfolio.addHolding"];
const company = getCompany(db, ticker.toUpperCase());
if (!company) {
return fail("NOT_FOUND", `Company with ticker "${ticker}" not found.`) as RpcResult<T>;
}
const holding = {
ticker: company.ticker,
name: company.name,
price: company.price,
changePct: company.changePct,
weight: 5,
};
dbAddHolding(db, DEFAULT_PORTFOLIO_ID, holding);
return ok("portfolio.addHolding", { holding }) as RpcResult<T>;
}
case "portfolio.removeHolding": {
const { ticker } = payload as RpcRequestMap["portfolio.removeHolding"];
dbRemoveHolding(db, DEFAULT_PORTFOLIO_ID, ticker.toUpperCase());
return ok("portfolio.removeHolding", { ok: true }) as RpcResult<T>;
}
case "company.get": {
const { companyId } = payload as RpcRequestMap["company.get"];
let company = getCompany(db, companyId);
if (!company) {
// Try to fetch from external API
try {
const quote = await fetchQuote(companyId);
const profile = await getCompanyProfile(companyId);
if (quote || profile) {
upsertCompany(db, {
ticker: companyId.toUpperCase(),
name: profile?.name || companyId.toUpperCase(),
sector: profile?.sector || "Unknown",
subIndustry: profile?.industry,
price: quote?.price || 0,
changePct: quote?.changePercent || 0,
thesis: "",
});
company = getCompany(db, companyId);
}
} catch (error) {
console.error("[RPC] Error fetching company:", error);
}
if (!company) {
return fail("NOT_FOUND", `Company "${companyId}" was not found.`) as RpcResult<T>;
}
}
// Update with live price data
try {
const quote = await fetchQuote(company.ticker);
if (quote) {
company = {
...company,
price: quote.price,
changePct: quote.changePercent,
};
// Update in database
db.prepare(`
UPDATE companies
SET price = ?, change_pct = ?, updated_at = datetime('now')
WHERE id = ?
`).run(quote.price, quote.changePercent, companyId);
}
} catch (error) {
// Use cached price if fetch fails
}
return ok("company.get", { company }) as RpcResult<T>;
}
case "company.search": {
const { query } = payload as RpcRequestMap["company.search"];
// First search local database
const localResults = dbSearchCompanies(db, query);
// Also search Yahoo Finance for additional results
try {
const apiResults = await searchStocks(query);
// Store new companies in database
for (const result of apiResults) {
const existing = getCompanyByTicker(db, result.ticker);
if (!existing) {
// Fetch additional details
const profile = await getCompanyProfile(result.ticker);
upsertCompany(db, {
ticker: result.ticker,
name: profile?.name || result.name,
sector: profile?.sector || "Unknown",
subIndustry: profile?.industry,
price: 0,
changePct: 0,
thesis: "",
});
}
}
// Combine results
const apiTickerSet = new Set(apiResults.map((r) => r.ticker));
const combinedResults = [
...localResults.filter((r) => !apiTickerSet.has(r.ticker)),
...apiResults.map((r) => ({
ticker: r.ticker,
name: r.name,
sector: "Unknown",
})),
];
return ok("company.search", { results: combinedResults.slice(0, 20) }) as RpcResult<T>;
} catch (error) {
// Fallback to local results only
console.error("[RPC] Error in company.search API:", error);
return ok("company.search", { results: localResults }) as RpcResult<T>;
}
}
case "company.setActive": {
const { companyId } = payload as RpcRequestMap["company.setActive"];
dbSetActiveCompany(db, DEFAULT_PORTFOLIO_ID, companyId);
return ok("company.setActive", { ok: true }) as RpcResult<T>;
}
case "workspace.getSection": {
const { companyId, section } = payload as RpcRequestMap["workspace.getSection"];
const content = dbGetWorkspaceSection(db, companyId, section);
if (!content) {
// Create a default section if it doesn't exist
const newSection: WorkspaceSection = {
id: `ws-${Date.now()}`,
title: section,
content: "",
validationState: "unverified",
sourceAgent: undefined,
};
return ok("workspace.getSection", {
content: newSection,
validationState: "unverified",
}) as RpcResult<T>;
}
return ok("workspace.getSection", {
content,
validationState: content.validationState,
}) as RpcResult<T>;
}
case "workspace.listSources": {
const { companyId } = payload as RpcRequestMap["workspace.listSources"];
const sources = [
{ type: "SEC Filing", title: "10-K FY2024", metadata: "Filed Oct 2024" },
{ type: "Earnings Transcript", title: "Q2 FY25 Call", metadata: "Mar 2025" },
];
return ok("workspace.listSources", { sources }) as RpcResult<T>;
}
case "catalyst.list": {
const { companyId } = payload as RpcRequestMap["catalyst.list"];
const catalysts = listCatalysts(db, companyId);
return ok("catalyst.list", { catalysts }) as RpcResult<T>;
}
case "alert.list": {
const params = payload as RpcRequestMap["alert.list"];
const alerts = listAlerts(db, params.companyId, params.since);
return ok("alert.list", { alerts }) as RpcResult<T>;
}
case "risk.list": {
const { companyId } = payload as RpcRequestMap["risk.list"];
const risks = listRisks(db, companyId);
return ok("risk.list", { risks }) as RpcResult<T>;
}
case "risk.add": {
const { companyId, risk } = payload as RpcRequestMap["risk.add"];
const newRisk = dbAddRisk(db, companyId, risk);
return ok("risk.add", { risk: newRisk }) as RpcResult<T>;
}
case "earnings.getSchedule": {
const { companyId } = payload as RpcRequestMap["earnings.getSchedule"];
let schedule = getEarningsSchedule(db, companyId);
// If no schedule in database, fetch from API
if (schedule.length === 0) {
try {
const earningsDate = await getEarningsDate(companyId);
if (earningsDate) {
const quarter = `Q${Math.floor(earningsDate.getMonth() / 3) + 1} ${earningsDate.getFullYear()}`;
db.prepare(`
INSERT INTO earnings_schedules (id, company_id, quarter, expected_date)
VALUES (?, ?, ?, ?)
`).run(
`earnings-${companyId}-${Date.now()}`,
companyId,
quarter,
earningsDate.toISOString()
);
schedule = getEarningsSchedule(db, companyId);
}
} catch (error) {
console.error("[RPC] Error fetching earnings date:", error);
}
}
return ok("earnings.getSchedule", { schedule }) as RpcResult<T>;
}
case "filing.list": {
const { companyId, since } = payload as RpcRequestMap["filing.list"];
let filings = listFilings(db, companyId, since);
// If no filings in database, fetch from SEC
if (filings.length === 0) {
try {
const secFilings = await fetchSECFilings(companyId, { limit: 20 });
for (const filing of secFilings) {
db.prepare(`
INSERT INTO filings (id, company_id, form_type, filed_date, title)
VALUES (?, ?, ?, ?, ?)
`).run(
`${companyId}-${filing.formType}-${filing.filedDate}`,
companyId,
filing.formType,
filing.filedDate,
filing.title
);
}
filings = secFilings.map((f) => ({
id: `${companyId}-${f.formType}-${f.filedDate}`,
companyId,
formType: f.formType,
filedDate: f.filedDate,
title: f.title,
}));
} catch (error) {
console.error("[RPC] Error fetching SEC filings:", error);
}
}
return ok("filing.list", { filings }) as RpcResult<T>;
}
case "model.get": {
const { companyId, tab } = payload as RpcRequestMap["model.get"];
const model = getModel(db, companyId, tab);
return ok("model.get", model) as RpcResult<T>;
}
case "model.updateCell": {
const { companyId, tab, row, col, value } = payload as RpcRequestMap[
"model.updateCell"
];
const result = dbUpdateModelCell(db, companyId, tab, row, col, value);
return ok("model.updateCell", result) as RpcResult<T>;
}
case "model.runScenario": {
const { companyId, scenario } = payload as RpcRequestMap["model.runScenario"];
// For now, just return the current model
// TODO: Implement scenario logic
const model = getModel(db, companyId, "income");
return ok("model.runScenario", model) as RpcResult<T>;
}
case "memo.get": {
const { companyId } = payload as RpcRequestMap["memo.get"];
const memo = getMemo(db, companyId);
return ok("memo.get", memo) as RpcResult<T>;
}
case "memo.updateSection": {
const { companyId, sectionId, title, content } = payload as RpcRequestMap[
"memo.updateSection"
];
if (content.trim().length === 0) {
return fail("VALIDATION_ERROR", "Memo section content cannot be empty.") as RpcResult<T>;
}
if (title !== undefined && title.trim().length === 0) {
return fail("VALIDATION_ERROR", "Memo section title cannot be empty.") as RpcResult<T>;
}
const section = dbUpdateMemoSection(db, companyId, sectionId, { title, content });
const savedAt = new Date().toISOString();
return ok("memo.updateSection", {
section,
status: "draft",
savedAt,
}) as RpcResult<T>;
}
case "memo.addAnnotation": {
const { companyId, sectionId, kind, selectedText, comment } = payload as RpcRequestMap[
"memo.addAnnotation"
];
if (selectedText.trim().length === 0) {
return fail("VALIDATION_ERROR", "Annotation selected text cannot be empty.") as RpcResult<T>;
}
if (kind === "comment" && (!comment || comment.trim().length === 0)) {
return fail("VALIDATION_ERROR", "Comment annotation requires comment text.") as RpcResult<T>;
}
const annotation = dbAddMemoAnnotation(db, companyId, {
sectionId,
kind,
selectedText: selectedText.trim(),
comment,
createdBy: "JD",
createdAt: new Date().toISOString(),
status: "open",
});
return ok("memo.addAnnotation", { annotation }) as RpcResult<T>;
}
case "memo.resolveAnnotation": {
const { companyId, annotationId } = payload as RpcRequestMap["memo.resolveAnnotation"];
const annotation = dbResolveMemoAnnotation(db, companyId, annotationId);
if (!annotation) {
return fail("NOT_FOUND", `Annotation "${annotationId}" not found.`) as RpcResult<T>;
}
return ok("memo.resolveAnnotation", { annotation }) as RpcResult<T>;
}
case "memo.updateSectionReview": {
const { companyId, sectionId, status } = payload as RpcRequestMap[
"memo.updateSectionReview"
];
const review = dbUpdateMemoSectionReview(db, companyId, sectionId, status);
return ok("memo.updateSectionReview", { review }) as RpcResult<T>;
}
case "memo.acceptEdit":
return ok("memo.acceptEdit", { ok: true }) as RpcResult<T>;
case "memo.rejectEdit":
return ok("memo.rejectEdit", { ok: true }) as RpcResult<T>;
case "agent.list": {
const params = payload as RpcRequestMap["agent.list"];
const agents = listAgents(db, params.companyId);
return ok("agent.list", { agents }) as RpcResult<T>;
}
case "agent.start": {
const { agentId, companyId } = payload as RpcRequestMap["agent.start"];
const { runId } = dbStartAgent(db, agentId, companyId);
return ok("agent.start", { runId }) as RpcResult<T>;
}
case "agent.pause": {
const { agentId } = payload as RpcRequestMap["agent.pause"];
const result = dbPauseAgent(db, agentId);
return ok("agent.pause", result) as RpcResult<T>;
}
case "agent.restart": {
const { agentId } = payload as RpcRequestMap["agent.restart"];
const { runId } = dbRestartAgent(db, agentId);
return ok("agent.restart", { runId }) as RpcResult<T>;
}
case "agent.chat":
return ok("agent.chat", {
response: "I've analyzed the data. Key findings: revenue growth remains on track with 5.6% YoY.",
}) as RpcResult<T>;
case "agent.configure": {
const { agentId, config } = payload as RpcRequestMap["agent.configure"];
updateAgentConfig(db, agentId, config);
return ok("agent.configure", { ok: true }) as RpcResult<T>;
}
case "agent.getTrace":
return ok("agent.getTrace", {
steps: [
{ step: 1, label: "Load filings", detail: "Loaded 10-K and 3 quarterly reports" },
{ step: 2, label: "Extract segments", detail: "Parsed 5 revenue segments" },
{ step: 3, label: "Build model", detail: "Constructed revenue build with growth rates" },
],
}) as RpcResult<T>;
case "agent.runPipeline": {
const { companyId, pipeline } = payload as RpcRequestMap["agent.runPipeline"];
// Start all agents in the pipeline
const runIds: string[] = [];
// TODO: Get agents for pipeline and start them
return ok("agent.runPipeline", { runIds }) as RpcResult<T>;
}
case "validation.run": {
const { companyId, agentType } = payload as RpcRequestMap["validation.run"];
try {
if (!agentType || agentType === "all") {
const results = await runAllValidations(db, companyId);
return ok("validation.run", {
sourceVerification: results.sourceVerification,
modelQA: results.modelQA,
redTeam: results.redTeam,
}) as RpcResult<T>;
} else if (agentType === "sv") {
const result = await executeSourceVerification(db, companyId);
return ok("validation.run", { sourceVerification: result }) as RpcResult<T>;
} else if (agentType === "qa") {
const result = await executeModelQA(db, companyId);
return ok("validation.run", { modelQA: result }) as RpcResult<T>;
} else if (agentType === "rt") {
const result = await executeRedTeam(db, companyId);
return ok("validation.run", { redTeam: result }) as RpcResult<T>;
}
return fail("VALIDATION_ERROR", `Unknown validation agent type: ${agentType}`) as RpcResult<T>;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return fail("INTERNAL_ERROR", `Validation failed: ${errorMsg}`, error) as RpcResult<T>;
}
}
case "validation.getStatus": {
const { companyId, sectionId } = payload as RpcRequestMap["validation.getStatus"];
// For now, return unverified - in production this would check validation records
return ok("validation.getStatus", {
validationState: "unverified",
lastValidated: undefined,
}) as RpcResult<T>;
}
case "export.list": {
const params = payload as RpcRequestMap["export.list"];
const exports = listExports(db, params.companyId);
return ok("export.list", { exports }) as RpcResult<T>;
}
case "export.create": {
const { type, companyId, options } = payload as RpcRequestMap["export.create"];
const format = options?.format as string ?? "pdf";
const { exportId } = dbCreateExport(db, type, `Export ${type}`, companyId, format);
return ok("export.create", { exportId }) as RpcResult<T>;
}
case "export.download":
return ok("export.download", { data: new ArrayBuffer(0) }) as RpcResult<T>;
case "settings.get": {
const { scope } = payload as RpcRequestMap["settings.get"];
if (scope === "client") {
const settings = getClientSettings(db);
return ok("settings.get", { settings }) as RpcResult<T>;
}
const settings = getServerSettings(db);
return ok("settings.get", { settings }) as RpcResult<T>;
}
case "settings.update": {
const { scope, changes } = payload as RpcRequestMap["settings.update"];
if (scope === "client") {
updateClientSettings(db, changes as Partial<ClientSettings>);
} else {
dbUpdateServerSettings(db, changes as Partial<ServerSettings>);
}
return ok("settings.update", { ok: true }) as RpcResult<T>;
}
default:
return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
}
} catch (error) {
console.error("[RPC] Error handling request:", error);
return fail("INTERNAL_ERROR", "Unhandled RPC failure.", error) as RpcResult<T>;
}
};
}
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
return { ok: true, data };
}
function fail<T extends RpcMethod>(
code: "NOT_FOUND" | "VALIDATION_ERROR" | "INTERNAL_ERROR" | "AGENT_FAILED" | "CONFLICT" | "RATE_LIMITED",
message: string,
detail?: unknown
): RpcResult<T> {
return { ok: false, error: { code, message, detail } };
}

View File

@@ -0,0 +1,328 @@
/**
* SQLite schema for MosaicIQ v3
* All data is stored locally with type-safe accessors
*/
export const SCHEMA_VERSION = 1;
export const SQL_SCHEMA = `
-- Database version tracking
CREATE TABLE IF NOT EXISTS _meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Companies
CREATE TABLE IF NOT EXISTS companies (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
sector TEXT NOT NULL,
sub_industry TEXT,
price REAL NOT NULL DEFAULT 0,
change_pct REAL NOT NULL DEFAULT 0,
thesis TEXT NOT NULL DEFAULT '',
founded TEXT,
headquarters TEXT,
employees INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_companies_ticker ON companies(ticker);
CREATE INDEX IF NOT EXISTS idx_companies_sector ON companies(sector);
-- Portfolio
CREATE TABLE IF NOT EXISTS portfolios (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
active_company_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (active_company_id) REFERENCES companies(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS holdings (
portfolio_id TEXT NOT NULL,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
price REAL NOT NULL,
change_pct REAL NOT NULL,
weight REAL NOT NULL,
PRIMARY KEY (portfolio_id, ticker),
FOREIGN KEY (portfolio_id) REFERENCES portfolios(id) ON DELETE CASCADE
);
-- Workspace sections
CREATE TABLE IF NOT EXISTS workspace_sections (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
validation_state TEXT NOT NULL DEFAULT 'unverified',
source_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workspace_company ON workspace_sections(company_id);
-- Catalysts
CREATE TABLE IF NOT EXISTS catalysts (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
date TEXT NOT NULL,
event TEXT NOT NULL,
impact TEXT NOT NULL,
thesis_relevance TEXT NOT NULL,
source TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_catalysts_company ON catalysts(company_id);
CREATE INDEX IF NOT EXISTS idx_catalysts_date ON catalysts(date);
-- Alerts
CREATE TABLE IF NOT EXISTS alerts (
id TEXT PRIMARY KEY,
company_id TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
type TEXT NOT NULL,
description TEXT NOT NULL,
thesis_impact TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new',
target_section TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_alerts_company ON alerts(company_id);
CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts(status);
CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp);
-- Risks
CREATE TABLE IF NOT EXISTS risks (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
risk TEXT NOT NULL,
category TEXT NOT NULL,
severity TEXT NOT NULL,
likelihood TEXT NOT NULL,
mitigation TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_risks_company ON risks(company_id);
CREATE INDEX IF NOT EXISTS idx_risks_status ON risks(status);
-- Earnings schedules
CREATE TABLE IF NOT EXISTS earnings_schedules (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
quarter TEXT NOT NULL,
expected_date TEXT NOT NULL,
timing TEXT,
actual_revenue TEXT,
expected_revenue TEXT,
actual_eps TEXT,
expected_eps TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_earnings_company ON earnings_schedules(company_id);
CREATE INDEX IF NOT EXISTS idx_earnings_date ON earnings_schedules(expected_date);
-- Filings
CREATE TABLE IF NOT EXISTS filings (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
form_type TEXT NOT NULL,
filed_date TEXT NOT NULL,
title TEXT NOT NULL,
key_changes TEXT,
reviewed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_filings_company ON filings(company_id);
CREATE INDEX IF NOT EXISTS idx_filings_date ON filings(filed_date);
-- Model data
CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
tab TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
UNIQUE(company_id, tab)
);
CREATE TABLE IF NOT EXISTS model_headers (
model_id TEXT NOT NULL,
position INTEGER NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (model_id, position),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS model_rows (
model_id TEXT NOT NULL,
position INTEGER NOT NULL,
label TEXT NOT NULL,
kind TEXT NOT NULL,
"values" TEXT NOT NULL,
PRIMARY KEY (model_id, position),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_model_company_tab ON models(company_id, tab);
-- Memos
CREATE TABLE IF NOT EXISTS memos (
company_id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS memo_sections (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
primary_agent TEXT,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_memo_sections_company ON memo_sections(company_id);
CREATE TABLE IF NOT EXISTS memo_citations (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
section_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
reference TEXT NOT NULL,
verification_status TEXT NOT NULL DEFAULT 'unverified',
source_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_memo_citations_company ON memo_citations(company_id);
CREATE INDEX IF NOT EXISTS idx_memo_citations_section ON memo_citations(section_id);
CREATE TABLE IF NOT EXISTS memo_annotations (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
section_id TEXT NOT NULL,
kind TEXT NOT NULL,
selected_text TEXT NOT NULL,
comment TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'open',
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_memo_annotations_company ON memo_annotations(company_id);
CREATE INDEX IF NOT EXISTS idx_memo_annotations_section ON memo_annotations(section_id);
CREATE TABLE IF NOT EXISTS memo_section_reviews (
company_id TEXT NOT NULL,
section_id TEXT NOT NULL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_memo_reviews_company ON memo_section_reviews(company_id);
-- Snapshots
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
label TEXT,
type TEXT NOT NULL DEFAULT 'auto',
change_count INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_snapshots_company ON snapshots(company_id);
CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp);
-- Agent definitions
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
pipeline TEXT
);
-- Agent runs
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
company_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
action TEXT,
pipeline TEXT,
started_at TEXT,
completed_at TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_runs_agent ON agent_runs(agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_company ON agent_runs(company_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
-- Agent configurations (server settings)
CREATE TABLE IF NOT EXISTS agent_configs (
agent_id TEXT PRIMARY KEY,
config TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Client settings (UI preferences that persist across sessions)
CREATE TABLE IF NOT EXISTS client_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Export records
CREATE TABLE IF NOT EXISTS export_records (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
company_id TEXT,
format TEXT NOT NULL,
file_size TEXT,
status TEXT NOT NULL DEFAULT 'processing',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
download_url TEXT,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_exports_company ON export_records(company_id);
CREATE INDEX IF NOT EXISTS idx_exports_status ON export_records(status);
`;
export const DEFAULT_PORTFOLIO_ID = "default";

View File

@@ -0,0 +1,271 @@
/**
* Seed database with demo data for MosaicIQ
*/
import type { Db } from "./database.js";
import type {
Catalyst,
Alert,
Risk,
EarningsSchedule,
Filing,
ExportRecord,
Agent,
Company,
} from "@mosaiciq/contracts/rpc";
import {
activeCompanyId,
holdings,
companies,
agents,
modelHeaders,
modelRows,
memoSections,
memoCitations,
memoAnnotations,
memoSectionReviews,
} from "../demoData.js";
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "../extraData.js";
export function seedDatabase(db: Db): void {
console.log("[DB] Seeding database with demo data...");
// Insert companies
const companyStmt = db.prepare(`
INSERT OR REPLACE INTO companies (id, ticker, name, sector, sub_industry, price, change_pct, thesis, founded, headquarters, employees)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const company of Object.values(companies) as Company[]) {
companyStmt.run(
company.id,
company.ticker,
company.name,
company.sector,
company.subIndustry ?? null,
company.price,
company.changePct,
company.thesis,
company.founded ?? null,
company.headquarters ?? null,
company.employees ?? null
);
}
// Set active company and add holdings
const portfolioStmt = db.prepare("UPDATE portfolios SET active_company_id = ? WHERE id = ?");
portfolioStmt.run(activeCompanyId, "default");
const holdingStmt = db.prepare(`
INSERT OR REPLACE INTO holdings (portfolio_id, ticker, name, price, change_pct, weight)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const holding of holdings) {
holdingStmt.run("default", holding.ticker, holding.name, holding.price, holding.changePct, holding.weight);
}
// Insert agents metadata
const agentStmt = db.prepare(`
INSERT OR REPLACE INTO agents (id, name, description, pipeline)
VALUES (?, ?, ?, ?)
`);
for (const agent of agents) {
agentStmt.run(agent.id, agent.name, "", agent.pipeline ?? "research");
}
// Create a model for COST
const modelId = "model-cost-income";
db.prepare("INSERT OR REPLACE INTO models (id, company_id, tab) VALUES (?, ?, ?)").run(modelId, activeCompanyId, "income");
// Insert model headers
const headerStmt = db.prepare("INSERT OR REPLACE INTO model_headers (model_id, position, label) VALUES (?, ?, ?)");
modelHeaders.forEach((label: string, idx: number) => {
headerStmt.run(modelId, idx, label);
});
// Insert model rows
const rowStmt = db.prepare("INSERT OR REPLACE INTO model_rows (model_id, position, label, kind, \"values\") VALUES (?, ?, ?, ?, ?)");
modelRows.forEach((row, idx: number) => {
rowStmt.run(modelId, idx, row.label, row.kind, JSON.stringify(row.values));
});
// Insert memo for COST
db.prepare("INSERT OR REPLACE INTO memos (company_id, status) VALUES (?, ?)").run(activeCompanyId, "review");
// Insert memo sections
const memoStmt = db.prepare(`
INSERT OR REPLACE INTO memo_sections (id, company_id, title, content, primary_agent)
VALUES (?, ?, ?, ?, ?)
`);
for (const section of memoSections) {
memoStmt.run(section.id, activeCompanyId, section.title, section.content, section.primaryAgent ?? null);
}
// Insert memo citations
const citationStmt = db.prepare(`
INSERT OR REPLACE INTO memo_citations (id, company_id, section_id, label, type, title, reference, verification_status, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const citation of memoCitations) {
citationStmt.run(
citation.id,
activeCompanyId,
citation.sectionId,
citation.label,
citation.type,
citation.title,
citation.reference,
citation.verificationStatus,
citation.sourceUrl ?? null
);
}
// Insert memo annotations
const annotationStmt = db.prepare(`
INSERT OR REPLACE INTO memo_annotations (id, company_id, section_id, kind, selected_text, comment, created_by, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const annotation of memoAnnotations) {
annotationStmt.run(
annotation.id,
activeCompanyId,
annotation.sectionId,
annotation.kind,
annotation.selectedText,
annotation.comment ?? null,
annotation.createdBy,
annotation.createdAt,
annotation.status
);
}
// Insert memo section reviews
const reviewStmt = db.prepare(`
INSERT OR REPLACE INTO memo_section_reviews (company_id, section_id, status, updated_at)
VALUES (?, ?, ?, ?)
`);
for (const review of memoSectionReviews) {
reviewStmt.run(activeCompanyId, review.sectionId, review.status, review.updatedAt);
}
// Insert catalysts
const catalystStmt = db.prepare(`
INSERT OR REPLACE INTO catalysts (id, company_id, date, event, impact, thesis_relevance, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const catalyst of demoCatalysts) {
catalystStmt.run(
catalyst.id,
activeCompanyId,
catalyst.date,
catalyst.event,
catalyst.impact,
catalyst.thesisRelevance,
catalyst.source ?? null
);
}
// Insert alerts
const alertStmt = db.prepare(`
INSERT OR REPLACE INTO alerts (id, company_id, timestamp, type, description, thesis_impact, status, target_section)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const alert of demoAlerts as Alert[]) {
alertStmt.run(
alert.id,
alert.companyId ?? null,
alert.timestamp,
alert.type,
alert.description,
alert.thesisImpact,
alert.status,
alert.targetSection ?? null
);
}
// Insert risks
const riskStmt = db.prepare(`
INSERT OR REPLACE INTO risks (id, company_id, risk, category, severity, likelihood, mitigation, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const risk of demoRisks as Risk[]) {
riskStmt.run(
risk.id,
risk.companyId,
risk.risk,
risk.category,
risk.severity,
risk.likelihood,
risk.mitigation,
risk.status
);
}
// Insert earnings schedules
const earningsStmt = db.prepare(`
INSERT OR REPLACE INTO earnings_schedules (id, company_id, quarter, expected_date, timing, actual_revenue, expected_revenue, actual_eps, expected_eps)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const earnings of demoEarnings as EarningsSchedule[]) {
earningsStmt.run(
earnings.id,
earnings.companyId,
earnings.quarter,
earnings.expectedDate,
earnings.timing ?? null,
earnings.actualRevenue ?? null,
earnings.expectedRevenue ?? null,
earnings.actualEps ?? null,
earnings.expectedEps ?? null
);
}
// Insert filings
const filingStmt = db.prepare(`
INSERT OR REPLACE INTO filings (id, company_id, form_type, filed_date, title, key_changes, reviewed)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const filing of demoFilings as Filing[]) {
filingStmt.run(
filing.id,
filing.companyId,
filing.formType,
filing.filedDate,
filing.title,
filing.keyChanges ?? null,
filing.reviewed ? 1 : 0
);
}
// Insert exports
const exportStmt = db.prepare(`
INSERT OR REPLACE INTO export_records (id, type, title, company_id, format, status, created_at, download_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const exp of demoExports as ExportRecord[]) {
exportStmt.run(
exp.id,
exp.type,
exp.title,
exp.companyId ?? null,
exp.format,
exp.status,
exp.createdAt,
exp.downloadUrl ?? null
);
}
console.log("[DB] Demo data seeded successfully");
}

View File

@@ -0,0 +1,202 @@
/**
* Excel export functionality
* Generates Excel workbooks from financial models
*/
import ExcelJS from "exceljs";
import type { ModelRow, Company } from "@mosaiciq/contracts/rpc";
export interface ExcelOptions {
includeFormulas?: boolean;
includeCompanyInfo?: boolean;
freezeHeader?: boolean;
}
/**
* Export model data as Excel buffer
*/
export async function exportModelAsExcel(
headers: string[],
rows: ModelRow[],
company: Company,
options: ExcelOptions = {}
): Promise<Buffer> {
const {
includeFormulas = true,
includeCompanyInfo = true,
freezeHeader = true,
} = options;
const workbook = new ExcelJS.Workbook();
workbook.creator = "MosaicIQ";
workbook.created = new Date();
// Main model worksheet
const worksheet = workbook.addWorksheet("Financial Model");
// Set column widths
const columnWidths = [30]; // Label column
headers.forEach(() => columnWidths.push(15));
worksheet.columns = columnWidths.map((width) => ({ width }));
// Header row
const headerRow = worksheet.addRow(["", ...headers]);
headerRow.font = { bold: true, size: 11 };
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FF4472C4" },
};
headerRow.alignment = { horizontal: "center" };
headerRow.font.color = { argb: "FFFFFFFF" };
// Data rows
for (const row of rows) {
const excelRow = worksheet.addRow([row.label, ...row.values]);
// Style based on row kind
if (row.kind === "total") {
excelRow.font = { bold: true };
excelRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE7E6E6" },
};
excelRow.border = {
top: { style: "thin" },
bottom: { style: "thin" },
};
} else if (row.kind === "forecast") {
excelRow.font = { italic: true };
excelRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFFFF9E6" },
};
}
// Add formulas for calculated cells if enabled
if (includeFormulas && (row.kind === "forecast" || row.kind === "total")) {
row.values.forEach((value, colIndex) => {
if (typeof value === "string" && value.startsWith("=")) {
const cell = excelRow.getCell(colIndex + 2);
cell.value = {
formula: value.slice(1),
result: 0, // Placeholder
} as any;
cell.numFmt = "#,##0.0";
} else if (typeof value === "number") {
const cell = excelRow.getCell(colIndex + 2);
cell.numFmt = "#,##0.0";
}
});
}
}
// Freeze header row if requested
if (freezeHeader) {
worksheet.views = [{ state: "frozen", xSplit: 0, ySplit: 1 }];
}
// Add borders to all cells
const totalRows = rows.length + 1;
const totalCols = headers.length + 1;
for (let row = 1; row <= totalRows; row++) {
for (let col = 1; col <= totalCols; col++) {
const cell = worksheet.getCell(row, col);
if (!cell.border) {
cell.border = {
top: { style: "thin", color: { argb: "FFCCCCCC" } },
left: { style: "thin", color: { argb: "FFCCCCCC" } },
bottom: { style: "thin", color: { argb: "FFCCCCCC" } },
right: { style: "thin", color: { argb: "FFCCCCCC" } },
};
}
}
}
// Add company info sheet if requested
if (includeCompanyInfo) {
const infoSheet = workbook.addWorksheet("Company Info");
infoSheet.addRow(["Property", "Value"]);
infoSheet.addRow(["Company", company.name]);
infoSheet.addRow(["Ticker", company.ticker]);
infoSheet.addRow(["Sector", company.sector]);
if (company.subIndustry) {
infoSheet.addRow(["Industry", company.subIndustry]);
}
infoSheet.addRow(["Export Date", new Date().toLocaleDateString()]);
// Style the info sheet
infoSheet.getColumn(1).width = 20;
infoSheet.getColumn(2).width = 40;
infoSheet.getRow(1).font = { bold: true };
}
// Add assumptions sheet
const assumptionsSheet = workbook.addWorksheet("Assumptions");
assumptionsSheet.addRow(["Category", "Assumption", "Value", "Notes"]);
// Add some common assumptions (in real implementation, these would come from the model)
const commonAssumptions = [
["Growth", "Revenue Growth Rate", "See model", "Per segment"],
["Margins", "Gross Margin Target", "Company guidance", "Industry benchmark"],
["Rates", "Tax Rate", "21%", "US federal"],
["Rates", "Discount Rate (WACC)", "8-10%", "Industry dependent"],
];
for (const assumption of commonAssumptions) {
assumptionsSheet.addRow(assumption);
}
assumptionsSheet.getColumn(1).width = 20;
assumptionsSheet.getColumn(2).width = 30;
assumptionsSheet.getColumn(3).width = 20;
assumptionsSheet.getColumn(4).width = 40;
assumptionsSheet.getRow(1).font = { bold: true };
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Generate filename for model export
*/
export function getModelFilename(company: Company, tab: string): string {
const date = new Date().toISOString().split("T")[0];
return `${company.ticker}_${tab}_Model_${date}.xlsx`;
}
/**
* Format cell value for Excel
*/
export function formatCellValue(value: string | number): { value: string | number | any; numFmt?: string } {
if (typeof value === "number") {
return {
value,
numFmt: "#,##0.0",
};
}
// Check if it's a percentage
if (typeof value === "string" && value.includes("%")) {
const numValue = parseFloat(value.replace("%", "")) / 100;
return {
value: numValue,
numFmt: "0.0%",
};
}
// Check if it's a formula
if (typeof value === "string" && value.startsWith("=")) {
return {
value: {
formula: value.slice(1),
result: 0,
},
};
}
return { value };
}

View File

@@ -0,0 +1,7 @@
/**
* Export module exports
*/
export * from "./pdf.js";
export * from "./excel.js";
export * from "./pptx.js";

View File

@@ -0,0 +1,262 @@
/**
* PDF export functionality
* Generates HTML for PDF generation using Electron's printToPDF
*/
import type { Memo, Company } from "@mosaiciq/contracts/rpc";
export interface PDFOptions {
includeAnnotations?: boolean;
includeCitations?: boolean;
fontSize?: number;
marginTop?: string;
marginBottom?: string;
landscape?: boolean;
}
/**
* Export memo as HTML for PDF generation
* In the desktop app, this HTML can be rendered and printed to PDF using webContents.printToPDF()
*/
export async function exportMemoAsHTML(
memo: Memo,
company: Company,
options: PDFOptions = {}
): Promise<string> {
const {
includeAnnotations = true,
includeCitations = true,
fontSize = 11,
} = options;
const generatedDate = new Date().toLocaleDateString();
let html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${company.name} - Investment Memo</title>
<style>
@page {
size: Letter;
margin: 0.5in;
}
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: ${fontSize}pt;
line-height: 1.6;
color: #333;
max-width: 8in;
margin: 0 auto;
padding: 0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #333;
padding-bottom: 20px;
}
.header h1 {
font-size: 24pt;
margin: 0 0 5px 0;
font-weight: bold;
}
.header .meta {
font-size: 10pt;
color: #666;
margin-top: 5px;
}
.section {
margin-bottom: 25px;
page-break-inside: avoid;
}
.section h2 {
font-size: 14pt;
font-weight: bold;
border-bottom: 1px solid #999;
padding-bottom: 3px;
margin-bottom: 10px;
color: #333;
}
.section-content {
text-align: justify;
white-space: pre-wrap;
}
.citation {
font-size: 8pt;
color: #666;
vertical-align: super;
margin: 0 2px;
}
.annotation-highlight {
background-color: #fff3cd;
padding: 1px 2px;
border-radius: 2px;
}
.annotation-strike {
text-decoration: line-through;
color: #999;
}
.annotation-comment {
background-color: #e7f3ff;
border-left: 2px solid #0066cc;
padding: 5px 10px;
margin: 10px 0;
font-style: italic;
}
.citations-list {
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #999;
}
.citations-list h3 {
font-size: 12pt;
margin-bottom: 10px;
}
.citation-item {
font-size: 9pt;
margin-bottom: 5px;
padding-left: 20px;
text-indent: -15px;
}
.footer {
text-align: center;
font-size: 8pt;
color: #999;
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #ccc;
}
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.section {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="header">
<h1>${company.name} (${company.ticker})</h1>
<div class="meta">Investment Research Memo • Generated: ${generatedDate}</div>
<div class="meta">${company.sector}${company.subIndustry ? ` - ${company.subIndustry}` : ""}</div>
</div>
`;
// Add memo sections
for (const section of memo.sections) {
html += `
<div class="section">
<h2>${section.title}</h2>
<div class="section-content">${formatContent(section.content, memo.citations, memo.annotations, {
includeAnnotations,
includeCitations,
})}</div>
</div>
`;
}
// Add citations if requested
if (includeCitations && memo.citations.length > 0) {
html += `
<div class="citations-list">
<h3>Sources</h3>
`;
for (const citation of memo.citations) {
html += ` <div class="citation-item">${citation.id}. ${citation.title} - ${citation.reference}</div>\n`;
}
html += ` </div>\n`;
}
html += `
<div class="footer">
This document was generated by MosaicIQ Research Platform<br>
Confidential - For internal use only
</div>
</body>
</html>
`;
return html;
}
/**
* Format content with citations and annotations
*/
function formatContent(
content: string,
citations: any[],
annotations: any[],
options: {
includeAnnotations: boolean;
includeCitations: boolean;
}
): string {
let formatted = content;
// Add citation markers
if (options.includeCitations) {
formatted = formatted.replace(/\[(\d+)\]/g, '<span class="citation">[$1]</span>');
}
// Process annotations (simplified - in real implementation would parse positions)
if (options.includeAnnotations) {
for (const annotation of annotations) {
if (annotation.kind === "highlight") {
formatted = formatted.replace(
annotation.selectedText,
`<span class="annotation-highlight">${annotation.selectedText}</span>`
);
} else if (annotation.kind === "strike") {
formatted = formatted.replace(
annotation.selectedText,
`<span class="annotation-strike">${annotation.selectedText}</span>`
);
} else if (annotation.kind === "comment" && annotation.comment) {
formatted = formatted.replace(
annotation.selectedText,
`${annotation.selectedText}<div class="annotation-comment">${annotation.comment}</div>`
);
}
}
}
return formatted;
}
/**
* Get print-friendly URL for memo
* This can be used with Electron's webContents.printToPDF()
*/
export function getPrintURL(companyId: string): string {
return `/#/memo/${companyId}/print`;
}
/**
* Generate filename for memo export
*/
export function getMemoFilename(company: Company, timestamp?: string): string {
const date = timestamp || new Date().toISOString().split("T")[0];
return `${company.ticker}_${company.name.replace(/\s+/g, "_")}_Memo_${date}.pdf`;
}

View File

@@ -0,0 +1,435 @@
/**
* PowerPoint export functionality
* Generates presentation decks from memos and models
*/
import PptxGenJS from "pptxgenjs";
import type { Memo, Company } from "@mosaiciq/contracts/rpc";
export interface PPTXOptions {
includeCharts?: boolean;
includeModel?: boolean;
maxBulletPoints?: number;
}
/**
* Export memo as PowerPoint presentation
*/
export async function exportPresentation(
memo: Memo,
company: Company,
options: PPTXOptions = {}
): Promise<Buffer> {
const {
includeCharts = true,
includeModel = true,
maxBulletPoints = 5,
} = options;
const pptx = new (PptxGenJS as any)();
pptx.author = "MosaicIQ";
pptx.company = "MosaicIQ Research";
pptx.title = `${company.name} Investment Committee Presentation`;
pptx.subject = "Investment Research";
const COLORS = {
primary: "4472C4",
secondary: "6C757D",
accent: "28A745",
warning: "FFC107",
danger: "DC3545",
dark: "363636",
light: "F8F9FA",
};
// Slide 1: Title slide
addTitleSlide(pptx, company, COLORS);
// Slide 2: Investment Thesis
const thesisSection = memo.sections.find((s) => s.id === "thesis");
if (thesisSection) {
addThesisSlide(pptx, thesisSection, COLORS);
}
// Slide 3: Key Drivers
const driversSection = memo.sections.find((s) => s.id === "drivers");
if (driversSection) {
addDriversSlide(pptx, driversSection, COLORS, maxBulletPoints);
}
// Slide 4: Business Quality
const qualitySection = memo.sections.find((s) => s.id === "quality");
if (qualitySection) {
addContentSlide(pptx, "Business Quality", qualitySection.content, COLORS);
}
// Slide 5: Financial Summary
const financialsSection = memo.sections.find((s) => s.id === "financials");
if (financialsSection) {
addContentSlide(pptx, "Financial Summary", financialsSection.content, COLORS);
}
// Slide 6: Valuation
const valuationSection = memo.sections.find((s) => s.id === "valuation");
if (valuationSection) {
addValuationSlide(pptx, valuationSection, COLORS);
}
// Slide 7: Key Risks
const risksSection = memo.sections.find((s) => s.id === "risks");
if (risksSection) {
addRisksSlide(pptx, risksSection.content, COLORS, maxBulletPoints);
}
// Slide 8: Catalysts
const catalystsSection = memo.sections.find((s) => s.id === "catalysts");
if (catalystsSection) {
addCatalystsSlide(pptx, catalystsSection.content, COLORS, maxBulletPoints);
}
// Slide 9: Conclusion/Recommendation
addConclusionSlide(pptx, company, COLORS);
// Generate buffer
const buffer = await pptx.write({ outputType: "nodebuffer", compression: false }) as Buffer;
return buffer;
}
function addTitleSlide(pptx: any, company: Company, COLORS: any) {
const slide = pptx.addSlide();
// Background color
slide.background = { color: COLORS.light };
// Company name and ticker
slide.addText(company.name, {
x: 0.5,
y: 1.5,
w: 9,
h: 0.8,
fontSize: 44,
bold: true,
color: COLORS.dark,
fontFace: "Arial",
});
slide.addText(`(${company.ticker})`, {
x: 0.5,
y: 2.2,
w: 9,
h: 0.4,
fontSize: 24,
color: COLORS.secondary,
fontFace: "Arial",
});
// Subtitle
slide.addText("Investment Committee Presentation", {
x: 0.5,
y: 3.0,
w: 9,
h: 0.5,
fontSize: 20,
color: COLORS.primary,
fontFace: "Arial",
});
// Date
slide.addText(new Date().toLocaleDateString(), {
x: 0.5,
y: 3.6,
w: 9,
h: 0.4,
fontSize: 16,
color: COLORS.secondary,
fontFace: "Arial",
});
// Divider line
slide.addShape("line", {
x: 0.5,
y: 4.2,
w: 9,
h: 0,
line: { color: COLORS.primary, width: 2 },
});
// Sector info
slide.addText(`Sector: ${company.sector}`, {
x: 0.5,
y: 4.5,
w: 9,
h: 0.4,
fontSize: 14,
color: COLORS.secondary,
fontFace: "Arial",
});
}
function addThesisSlide(pptx: any, section: any, COLORS: any) {
const slide = pptx.addSlide();
// Title
slide.addText("Investment Thesis", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Content (truncated if too long)
const content = section.content.slice(0, 600) + (section.content.length > 600 ? "..." : "");
slide.addText(content, {
x: 0.5,
y: 1.2,
w: 9,
h: 4,
fontSize: 18,
color: COLORS.dark,
fontFace: "Arial",
align: "justify",
});
}
function addDriversSlide(pptx: any, section: any, COLORS: any, maxBullets: number) {
const slide = pptx.addSlide();
// Title
slide.addText("Key Value Drivers", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Parse bullets (simplified - assumes line breaks separate bullets)
const lines = section.content.split("\n").filter((l: string) => l.trim().length > 0);
const bullets = lines.slice(0, maxBullets);
bullets.forEach((bullet: string, index: number) => {
slide.addText(bullet.trim(), {
x: 0.5,
y: 1.2 + index * 0.5,
w: 9,
h: 0.4,
fontSize: 18,
color: COLORS.dark,
fontFace: "Arial",
bullet: true,
});
});
}
function addValuationSlide(pptx: any, section: any, COLORS: any) {
const slide = pptx.addSlide();
// Title
slide.addText("Valuation", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Content
const content = section.content.slice(0, 500);
slide.addText(content, {
x: 0.5,
y: 1.2,
w: 9,
h: 3,
fontSize: 16,
color: COLORS.dark,
fontFace: "Arial",
align: "justify",
});
// Valuation summary box
slide.addShape("rect", {
x: 0.5,
y: 4.5,
w: 9,
h: 1.5,
fill: { color: COLORS.light },
line: { color: COLORS.primary, width: 1 },
});
slide.addText("Valuation Summary", {
x: 0.7,
y: 4.7,
w: 8.6,
h: 0.3,
fontSize: 14,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
}
function addRisksSlide(pptx: any, content: string, COLORS: any, maxBullets: number) {
const slide = pptx.addSlide();
// Title
slide.addText("Key Risks", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.danger,
fontFace: "Arial",
});
// Parse bullets
const lines = content.split("\n").filter((l) => l.trim().length > 0);
const bullets = lines.slice(0, maxBullets);
bullets.forEach((bullet, index) => {
slide.addText(bullet.trim(), {
x: 0.5,
y: 1.2 + index * 0.5,
w: 9,
h: 0.4,
fontSize: 16,
color: COLORS.dark,
fontFace: "Arial",
bullet: true,
});
});
}
function addCatalystsSlide(pptx: any, content: string, COLORS: any, maxBullets: number) {
const slide = pptx.addSlide();
// Title
slide.addText("Upcoming Catalysts", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.accent,
fontFace: "Arial",
});
// Parse bullets
const lines = content.split("\n").filter((l) => l.trim().length > 0);
const bullets = lines.slice(0, maxBullets);
bullets.forEach((bullet, index) => {
slide.addText(bullet.trim(), {
x: 0.5,
y: 1.2 + index * 0.5,
w: 9,
h: 0.4,
fontSize: 16,
color: COLORS.dark,
fontFace: "Arial",
bullet: true,
});
});
}
function addContentSlide(pptx: any, title: string, content: string, COLORS: any) {
const slide = pptx.addSlide();
// Title
slide.addText(title, {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Content
const truncated = content.slice(0, 700);
slide.addText(truncated, {
x: 0.5,
y: 1.2,
w: 9,
h: 5,
fontSize: 16,
color: COLORS.dark,
fontFace: "Arial",
align: "justify",
});
}
function addConclusionSlide(pptx: any, company: Company, COLORS: any) {
const slide = pptx.addSlide();
// Title
slide.addText("Conclusion & Recommendation", {
x: 0.5,
y: 0.5,
w: 9,
h: 0.5,
fontSize: 32,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Recommendation box
slide.addShape("rect", {
x: 0.5,
y: 1.2,
w: 9,
h: 2,
fill: { color: "E7F3FF" },
line: { color: COLORS.primary, width: 2 },
});
slide.addText("Investment Recommendation", {
x: 0.7,
y: 1.4,
w: 8.6,
h: 0.4,
fontSize: 18,
bold: true,
color: COLORS.primary,
fontFace: "Arial",
});
// Disclaimer
slide.addText(
"This presentation is for informational purposes only and does not constitute investment advice. " +
"Please refer to the full investment memo for detailed analysis and disclosures.",
{
x: 0.5,
y: 5.5,
w: 9,
h: 1,
fontSize: 10,
color: COLORS.secondary,
fontFace: "Arial",
align: "center",
}
);
}
/**
* Generate filename for presentation export
*/
export function getPresentationFilename(company: Company): string {
const date = new Date().toISOString().split("T")[0];
return `${company.ticker}_Presentation_${date}.pptx`;
}

View File

@@ -0,0 +1,207 @@
/**
* LLM client for Pi API (Inflection AI)
* Provides streaming and non-streaming completion functions
*/
const PI_API_URL = "https://api.pi.ai/v1/chat/completions";
const client = {
apiKey: process.env.PI_API_KEY || "",
};
export interface StreamOptions {
onProgress?: (text: string) => void;
signal?: AbortSignal;
}
export interface CompletionOptions {
model?: string;
maxTokens?: number;
temperature?: number;
}
const DEFAULT_MODEL = "pi";
const DEFAULT_MAX_TOKENS = 4096;
/**
* Stream a response from Pi
*/
export async function streamResponse(
prompt: string,
options: StreamOptions & CompletionOptions = {}
): Promise<string> {
const {
model = DEFAULT_MODEL,
maxTokens = DEFAULT_MAX_TOKENS,
temperature = 0,
onProgress,
signal,
} = options;
if (!client.apiKey) {
throw new Error("PI_API_KEY is not set");
}
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();
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
}
}
}
}
return fullResponse;
}
/**
* Get a complete response from Pi (non-streaming)
*/
export async function complete(
prompt: string,
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");
}
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,
}),
});
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 ?? "";
}
/**
* 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

@@ -0,0 +1,145 @@
/**
* Agent context builder
* Gathers relevant data for agent execution
*/
import type { Db } from "../db/database.js";
import type { AgentContext } from "./prompts.js";
import {
getCompany,
getCompanyByTicker,
listFilings,
getMemo,
getModel,
} from "../db/queries.js";
export interface BuildContextOptions {
includeHistoricalData?: boolean;
includeModel?: boolean;
userMessage?: string;
}
/**
* Build agent context for a specific company
*/
export async function buildAgentContext(
db: Db,
agentId: string,
companyId: string,
options: BuildContextOptions = {}
): Promise<AgentContext> {
const {
includeHistoricalData = true,
includeModel = true,
userMessage,
} = options;
// Get company data
const company = getCompany(db, companyId) || getCompanyByTicker(db, companyId);
if (!company) {
throw new Error(`Company not found: ${companyId}`);
}
// Get filings
const filings = listFilings(db, companyId, undefined).slice(0, 20);
// Get memo sections
const memoData = getMemo(db, companyId);
const memo = {
sections: memoData.sections,
};
// Get model if needed
let model;
if (includeModel) {
try {
model = getModel(db, companyId, "income");
} catch {
model = undefined;
}
}
// Get historical data if needed
let historicalData;
if (includeHistoricalData) {
historicalData = await getHistoricalData(db, companyId);
}
return {
company: {
name: company.name,
ticker: company.ticker,
sector: company.sector,
},
filings: filings.map((f) => ({
formType: f.formType,
title: f.title,
filedDate: f.filedDate,
})),
memo,
model,
historicalData,
userMessage,
};
}
/**
* Get historical data for a company
*/
async function getHistoricalData(db: Db, companyId: string): Promise<Record<string, unknown>> {
// Try to get historical data from the model
try {
const model = getModel(db, companyId, "income");
// Extract historical actuals from the model
const historical: Record<string, unknown> = {};
model.rows.forEach((row) => {
if (row.kind === "actual") {
historical[row.label] = row.values;
}
});
return historical;
} catch {
return {};
}
}
/**
* Build context for chat agent
*/
export async function buildChatContext(
db: Db,
companyId: string,
userMessage: string
): Promise<AgentContext> {
return buildAgentContext(db, "chat", companyId, {
includeHistoricalData: true,
includeModel: true,
userMessage,
});
}
/**
* Build minimal context for quick operations
*/
export async function buildMinimalContext(
db: Db,
companyId: string
): Promise<Partial<AgentContext>> {
const company = getCompany(db, companyId) || getCompanyByTicker(db, companyId);
if (!company) {
throw new Error(`Company not found: ${companyId}`);
}
return {
company: {
name: company.name,
ticker: company.ticker,
sector: company.sector,
},
filings: [],
memo: { sections: [] },
};
}

View File

@@ -0,0 +1,7 @@
/**
* LLM module exports
*/
export * from "./client.js";
export * from "./prompts.js";
export * from "./context.js";

View File

@@ -0,0 +1,411 @@
/**
* Agent prompt templates for MosaicIQ
* Each agent has a specific role and prompt template
*/
export interface AgentContext {
company: {
name: string;
ticker: string;
sector: string;
};
filings: Array<{
formType: string;
title: string;
filedDate: string;
}>;
memo: {
sections: Array<{ id: string; title: string; content: string }>;
};
model?: {
headers: string[];
rows: Array<{ label: string; kind: string; values: string[] }>;
};
historicalData?: Record<string, unknown>;
userMessage?: string;
}
export type AgentPrompt = (context: AgentContext) => string;
const AGENT_PROMPTS: Record<string, AgentPrompt> = {
// SEC Filings Agent (sf)
sf: (ctx) => `You are the SEC Filings Agent for ${ctx.company.name} (${ctx.company.ticker}).
Your task is to analyze SEC filings and extract structured information.
**Available Filings:**
${ctx.filings.map((f) => `- ${f.formType}: ${f.title} (${f.filedDate})`).join("\n")}
**Instructions:**
1. Extract segment revenue data from the 10-K
2. Identify key risk factors
3. Note any changes in accounting policies
4. Cite specific sections when making claims
**Output Format:**
Respond with a JSON object containing:
- extractedData: Key findings from filings
- sources: List of filings and sections referenced
- assumptions: Any assumptions made during analysis
- confidence: "high" | "medium" | "low"
Focus on the most recent 10-K and 10-Q filings.`,
// Company Research Agent (cr)
cr: (ctx) => `You are the Company Research Agent for ${ctx.company.name} (${ctx.company.ticker}).
Analyze the company's business model and competitive position.
**Company Info:**
- Name: ${ctx.company.name}
- Ticker: ${ctx.company.ticker}
- Sector: ${ctx.company.sector}
**Available Filings:**
${ctx.filings.map((f) => `- ${f.formType}: ${f.title}`).join("\n")}
**Instructions:**
1. Summarize the company's business model
2. Identify key competitive advantages
3. Analyze market position
4. Note recent strategic developments
**Output Format:**
Respond with a JSON object containing:
- businessModel: Description of how the company makes money
- competitiveAdvantages: Array of key advantages
- marketPosition: Assessment of market position
- recentDevelopments: Important recent changes
- sources: Citations for your claims`,
// Financial Modeling Agent (fm)
fm: (ctx) => `You are the Financial Modeling Agent for ${ctx.company.name}.
Build a revenue model based on the company's filings and historical data.
**Historical Data:**
${JSON.stringify(ctx.historicalData || {}, null, 2)}
**Current Model (if available):**
${ctx.model ? JSON.stringify({ headers: ctx.model.headers, rows: ctx.model.rows.slice(0, 10) }, null, 2) : "No existing model"}
**Instructions:**
1. Project revenue for the next 3 years
2. Model key margin drivers
3. Document your assumptions clearly
4. Flag any sensitive assumptions
**Output Format:**
Respond with a JSON object containing:
- projections: Revenue and margin projections by year
- keyAssumptions: Array of key assumptions with rationale
- sensitivityAnalysis: Impact of key assumption changes
- confidence: Assessment of projection reliability`,
// Valuation Agent (va)
va: (ctx) => `You are the Valuation Agent for ${ctx.company.name}.
Provide a comprehensive valuation analysis.
**Company Info:**
- Name: ${ctx.company.name} (${ctx.company.ticker})
- Sector: ${ctx.company.sector}
**Memo Sections:**
${ctx.memo.sections.map((s) => `- ${s.title}: ${s.content.slice(0, 100)}...`).join("\n")}
**Instructions:**
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 Format:**
Respond with a JSON object containing:
- methodologies: Array of valuation methods used
- fairValue: Estimated fair value with range
- keyDrivers: Factors driving valuation
- scenarios: Bull, base, and bear case scenarios
- assumptions: Critical assumptions`,
// Memo Writing Agent (mw)
mw: (ctx) => `You are the Memo Writing Agent for ${ctx.company.name}.
Synthesize research into a clear investment memo.
**Current Memo Sections:**
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content}`).join("\n\n")}
**Instructions:**
1. Review and refine the thesis statement
2. Ensure all sections are coherent and well-structured
3. Check for clarity and persuasiveness
4. Maintain professional tone
**Output Format:**
Respond with a JSON object containing:
- refinedSections: Updated memo sections
- suggestions: Improvements to consider
- consistencyCheck: Notes on consistency issues
- overallQuality: Assessment of memo quality`,
// Presentation Agent (pa)
pa: (ctx) => `You are the Presentation Agent for ${ctx.company.name}.
Create investment committee presentation materials.
**Memo Content:**
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content.slice(0, 200)}...`).join("\n\n")}
**Instructions:**
1. Structure presentation for investment committee
2. Create slide outlines with key points
3. Ensure visual clarity and impact
4. Include supporting data
**Output Format:**
Respond with a JSON object containing:
- slideOutline: Array of slide titles and bullet points
- keyCharts: Suggested charts and visualizations
- talkingPoints: Key points for each slide
- timing: Recommended time per slide`,
// Earnings Call Agent (ec)
ec: (ctx) => `You are the Earnings Call Agent for ${ctx.company.name}.
Analyze earnings call transcripts for insights.
**Instructions:**
1. Parse the most recent earnings call transcript
2. Extract management guidance
3. Identify tone changes
4. Note key Q&A insights
**Output Format:**
Respond with a JSON object containing:
- guidance: Management's forward guidance
- keyInsights: Important insights from the call
- toneAnalysis: Assessment of management tone
- qAHighlights: Key points from Q&A
- actionItems: Items requiring follow-up`,
// Competitive Intel Agent (ci)
ci: (ctx) => `You are the Competitive Intelligence Agent for ${ctx.company.name}.
Analyze competitive positioning and peer dynamics.
**Company:** ${ctx.company.name} (${ctx.company.ticker})
**Sector:** ${ctx.company.sector}
**Instructions:**
1. Identify key competitors
2. Analyze competitive positioning
3. Assess market share trends
4. Note recent competitive developments
**Output Format:**
Respond with a JSON object containing:
- competitors: Array of key competitors with brief descriptions
- marketPosition: Assessment of competitive position
- recentMoves: Recent competitive developments
- threats: Key competitive threats
- opportunities: Opportunities for competitive advantage`,
// Risk Agent (rk)
rk: (ctx) => `You are the Risk Assessment Agent for ${ctx.company.name}.
Identify and analyze key investment risks.
**Memo Sections:**
${ctx.memo.sections.map((s) => `## ${s.title}\n${s.content.slice(0, 100)}...`).join("\n\n")}
**Available Filings:**
${ctx.filings.map((f) => `- ${f.formType}: ${f.title}`).join("\n")}
**Instructions:**
1. Identify all material risks
2. Categorize by type (business, financial, competitive, regulatory)
3. Assess likelihood and impact
4. Suggest mitigation strategies
**Output Format:**
Respond with a JSON object containing:
- risks: Array of risk descriptions
- categories: Risk categories with counts
- highPriorityRisks: Risks requiring immediate attention
- mitigationStrategies: Suggested mitigations`,
// Red Team Agent (rt)
rt: (ctx) => `You are the Red Team Agent for ${ctx.company.name}.
Challenge the investment thesis and identify weaknesses.
**Current Thesis:**
${ctx.memo.sections.find((s) => s.id === "thesis")?.content || "No thesis found"}
**Instructions:**
1. Identify weaknesses in the investment thesis
2. Uncover unstated assumptions
3. Develop bear case scenarios
4. Challenge key projections
**Output Format:**
Respond with a JSON object containing:
- counterArguments: Challenges to the thesis
- unstatedAssumptions: Hidden assumptions in the analysis
- bearCase: Full bear case scenario
- redFlags: Warning signs to monitor
- recommendation: "proceed_with_caution" | "acceptable" | "needs_revision"`,
// Monitoring Agent (mn)
mn: (ctx) => `You are the Monitoring Agent for ${ctx.company.name}.
Set up ongoing monitoring of key indicators.
**Instructions:**
1. Identify key metrics to monitor
2. Set alert thresholds
3. Define monitoring cadence
4. Specify data sources
**Output Format:**
Respond with a JSON object containing:
- metricsToMonitor: Key metrics with thresholds
- alertConditions: Conditions that should trigger alerts
- dataSources: Where to get monitoring data
- monitoringCadence: How often to check each metric`,
// Source Verification Agent (sv)
sv: (ctx) => `You are the Source Verification Agent for ${ctx.company.name}.
Verify the accuracy of citations and sources in the memo.
**Memo Citations:**
${ctx.memo.sections.length} sections with ${ctx.filings.length} available filings
**Instructions:**
1. Check that all claims are properly cited
2. Verify citations are accurate
3. Flag uncited assertions
4. Assess source quality
**Output Format:**
Respond with a JSON object containing:
- verificationResults: Array of citation checks
- flaggedClaims: Uncited or poorly supported claims
- sourceQuality: Assessment of source quality
- recommendations: Improvements for source verification`,
// Export Agent (ex)
ex: (ctx) => `You are the Export Agent for ${ctx.company.name}.
Prepare data for export in various formats.
**Instructions:**
1. Format memo for PDF export
2. Prepare model for Excel export
3. Create presentation outline for PowerPoint
4. Ensure data consistency across formats
**Output Format:**
Respond with a JSON object containing:
- pdfReady: Memo formatted for PDF
- excelReady: Model formatted for Excel
- pptReady: Presentation outline
- metadata: Export metadata and formatting notes`,
// Model QA Agent (qa)
qa: (ctx) => `You are the Model QA Agent for ${ctx.company.name}.
Audit the financial model for errors and inconsistencies.
**Current Model:**
${ctx.model ? JSON.stringify({ headers: ctx.model.headers, rows: ctx.model.rows.slice(0, 20) }, null, 2) : "No model available"}
**Instructions:**
1. Check for formula errors
2. Verify balance sheet consistency
3. Run sanity checks on projections
4. Identify circular references
**Output Format:**
Respond with a JSON object containing:
- formulaErrors: Array of formula issues
- consistencyIssues: Balance sheet and other consistency problems
- sanityCheckResults: Results of sanity checks
- recommendations: Suggested fixes and improvements
- overallStatus: "pass" | "warn" | "fail"`,
// Chat Agent (default for agent.chat)
chat: (ctx) => `You are a research assistant for ${ctx.company.name} (${ctx.company.ticker}).
Help the analyst with questions about the company.
**Company Info:**
- Name: ${ctx.company.name}
- Ticker: ${ctx.company.ticker}
- Sector: ${ctx.company.sector}
**User Question:**
${ctx.userMessage || "No question provided"}
**Available Context:**
- Memo sections: ${ctx.memo.sections.length}
- Filings: ${ctx.filings.length}
- Model: ${ctx.model ? "Available" : "Not available"}
**Instructions:**
1. Answer the question based on available data
2. Cite your sources when possible
3. Admit when you don't have enough information
4. Provide clear, concise responses
Respond in a helpful, professional tone.`,
};
/**
* Get the prompt for a specific agent
*/
export function getAgentPrompt(agentId: string, context: AgentContext): string {
const prompt = AGENT_PROMPTS[agentId] || AGENT_PROMPTS.chat;
return prompt(context);
}
/**
* Get all available agent IDs
*/
export function getAgentIds(): string[] {
return Object.keys(AGENT_PROMPTS);
}
/**
* Get agent metadata
*/
export interface AgentMetadata {
id: string;
name: string;
description: string;
category: "research" | "analysis" | "presentation" | "validation";
}
export const AGENT_METADATA: Record<string, AgentMetadata> = {
sf: { id: "sf", name: "SEC Filings", description: "Extracts data from SEC filings", category: "research" },
cr: { id: "cr", name: "Company Research", description: "Analyzes business model and competition", category: "research" },
fm: { id: "fm", name: "Financial Modeling", description: "Builds financial projections", category: "analysis" },
va: { id: "va", name: "Valuation", description: "Provides valuation analysis", category: "analysis" },
mw: { id: "mw", name: "Memo Writing", description: "Synthesizes research into memo", category: "presentation" },
pa: { id: "pa", name: "Presentation", description: "Creates presentation materials", category: "presentation" },
ec: { id: "ec", name: "Earnings Call", description: "Analyzes earnings transcripts", category: "research" },
ci: { id: "ci", name: "Competitive Intel", description: "Analyzes competitive position", category: "research" },
rk: { id: "rk", name: "Risk", description: "Identifies and assesses risks", category: "analysis" },
rt: { id: "rt", name: "Red Team", description: "Challenges investment thesis", category: "validation" },
mn: { id: "mn", name: "Monitoring", description: "Sets up monitoring and alerts", category: "analysis" },
sv: { id: "sv", name: "Source Verification", description: "Verifies citations and sources", category: "validation" },
ex: { id: "ex", name: "Export", description: "Preares data for export", category: "presentation" },
qa: { id: "qa", name: "Model QA", description: "Audits financial model", category: "validation" },
chat: { id: "chat", name: "Chat", description: "General research assistant", category: "research" },
};
/**
* Get metadata for an agent
*/
export function getAgentMetadata(agentId: string): AgentMetadata | undefined {
return AGENT_METADATA[agentId];
}
/**
* Get agents by pipeline
*/
export function getAgentsByPipeline(pipeline: "research" | "competitive" | "cross-cutting"): string[] {
const pipelineMap: Record<string, string[]> = {
research: ["sf", "cr", "fm", "va", "mw", "pa"],
competitive: ["ec", "ci", "rk", "rt"],
"cross-cutting": ["mn", "sv", "ex", "qa"],
};
return pipelineMap[pipeline] || [];
}

View File

@@ -1,4 +1,5 @@
import type { MemoAnnotation, MemoCitation, MemoSection, MemoSectionReview, RpcMethod, RpcRequestMap, RpcResponseMap, RpcResult } from "../../contracts/src/rpc.js";
import { parseRpcRequest, parseRpcResponse } from "../../contracts/src/rpcSchemas.js";
import { activeCompanyId, agents, companies, holdings, memoAnnotations, memoCitations, memoSectionReviews, memoSections, modelHeaders, modelRows } from "./demoData.js";
import { demoAlerts, demoCatalysts, demoEarnings, demoExports, demoFilings, demoRisks } from "./extraData.js";
@@ -17,6 +18,7 @@ export async function handleMockRpc<T extends RpcMethod>(
payload: RpcRequestMap[T]
): Promise<RpcResult<T>> {
try {
parseRpcRequest(method, payload);
switch (method) {
case "portfolio.get":
return ok("portfolio.get", { id: "core", name: "Core Retail Coverage", holdings, activeCompanyId }) as RpcResult<T>;
@@ -197,14 +199,20 @@ export async function handleMockRpc<T extends RpcMethod>(
return ok("settings.update", { ok: true }) as RpcResult<T>;
default:
return fail("NOT_FOUND", `Unknown RPC method: ${String(method)}`) as RpcResult<T>;
return fail("VALIDATION_ERROR", "Unknown RPC method.") as RpcResult<T>;
}
} catch (error) {
if (error instanceof Error && error.name === "ZodError") {
return fail("VALIDATION_ERROR", "Invalid RPC payload.", error) as RpcResult<T>;
}
return fail("INTERNAL_ERROR", "Unhandled mock RPC failure.", error) as RpcResult<T>;
}
}
function ok<T extends RpcMethod>(_: T, data: RpcResponseMap[T]): RpcResult<T> {
if (process.env.NODE_ENV !== "production") {
parseRpcResponse(_, data);
}
return { ok: true, data };
}