From dc3e209d70580bb2ae0042d73d6820f8b249dc23 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 00:41:48 -0700 Subject: [PATCH 1/7] feat(finance): financial operator console with typed step cards Build the perception->extraction->validation->sandbox compute->verification chat experience the spec calls for. Each unit of work renders as a typed card so users see observable work, not hidden reasoning. Backend (convex/domains/financialOperator/): - types.ts: 9 step kinds (run_brief, tool_call, extraction, validation, calculation, evidence, artifact, approval_request, result) x 7 statuses. - sandbox.ts: deterministic JS compute (ETR, after-tax cost of debt, leverage, variance, compliance). Throws on NaN/divide-by-zero. - validators.ts: schema/unit/range/confidence checks. HONEST_SCORES counts what was actually checked. - extractors.ts + attFixture.ts: pinned AT&T 10-K fixture; real-PDF-shape interface for swap-in. - runOps.ts: createRun, appendStep, updateStepStatus, getRun, listSteps. BOUND at 200 steps/run. - orchestrator.ts: runAttCostOfDebtDemo + recordApprovalDecision actions. Schema (convex/schema.ts): - New financialOperatorRuns + financialOperatorSteps tables (additive). - Fixed pre-existing data drift in productEventWorkspaces by adding activeEventSessionId as optional. Frontend (src/features/financialOperator/): - 9 typed card components + StepShell common chrome + StepStatusBadge. - StepCard switch dispatcher. - FinancialOperatorTimeline live-streaming parent (Convex useQuery). - FinancialOperatorDemo standalone view at /finance-demo. Routing: - viewRegistry.ts: added financial-operator view at /finance-demo with aliases /financial-operator and /finops. - QuickCommandChips: added optional `navigate` field on chips for workspace handoff; "AT&T cost of debt" chip routes to /finance-demo. Tests (19/19 pass): - sandbox.scenario.test.ts: 13 tests covering happy path, 1000-replay determinism, NaN/divide-by-zero/out-of-range sad paths, compliance gate, signed variance formatting. - validators.scenario.test.ts: 6 tests covering missing required, wrong unit, out-of-range, low confidence, scale to 100 fields. Verification: - npx tsc --noEmit: clean - npx vitest run: 19/19 pass - npx vite build: clean - Live browser test: 10 cards stream end-to-end, approve flow produces Result card with ETR=16.86%, after-tax cost of debt=4.51% Co-Authored-By: Claude Opus 4.7 (1M context) --- convex/_generated/api.d.ts | 4762 +---------------- .../__tests__/sandbox.scenario.test.ts | 155 + .../__tests__/validators.scenario.test.ts | 171 + .../domains/financialOperator/attFixture.ts | 79 + .../domains/financialOperator/extractors.ts | 95 + convex/domains/financialOperator/index.ts | 12 + .../domains/financialOperator/orchestrator.ts | 458 ++ convex/domains/financialOperator/runOps.ts | 260 + convex/domains/financialOperator/sandbox.ts | 208 + convex/domains/financialOperator/types.ts | 175 + .../domains/financialOperator/validators.ts | 110 + convex/domains/product/schema.ts | 2 + convex/schema.ts | 87 + .../FastAgentPanel/QuickCommandChips.tsx | 20 +- .../components/ApprovalCard.tsx | 109 + .../components/ArtifactCard.tsx | 62 + .../components/CalculationCard.tsx | 89 + .../components/EvidenceCard.tsx | 47 + .../components/ExtractionCard.tsx | 111 + .../components/FinancialOperatorTimeline.tsx | 112 + .../components/ResultCard.tsx | 65 + .../components/RunBriefCard.tsx | 38 + .../financialOperator/components/StepCard.tsx | 112 + .../components/StepShell.tsx | 88 + .../components/StepStatusBadge.tsx | 60 + .../components/ToolCallCard.tsx | 60 + .../components/ValidationCard.tsx | 92 + src/features/financialOperator/index.ts | 22 + src/features/financialOperator/types.ts | 24 + .../views/FinancialOperatorDemo.tsx | 107 + src/lib/registry/viewRegistry.ts | 19 + 31 files changed, 3100 insertions(+), 4711 deletions(-) create mode 100644 convex/domains/financialOperator/__tests__/sandbox.scenario.test.ts create mode 100644 convex/domains/financialOperator/__tests__/validators.scenario.test.ts create mode 100644 convex/domains/financialOperator/attFixture.ts create mode 100644 convex/domains/financialOperator/extractors.ts create mode 100644 convex/domains/financialOperator/index.ts create mode 100644 convex/domains/financialOperator/orchestrator.ts create mode 100644 convex/domains/financialOperator/runOps.ts create mode 100644 convex/domains/financialOperator/sandbox.ts create mode 100644 convex/domains/financialOperator/types.ts create mode 100644 convex/domains/financialOperator/validators.ts create mode 100644 src/features/financialOperator/components/ApprovalCard.tsx create mode 100644 src/features/financialOperator/components/ArtifactCard.tsx create mode 100644 src/features/financialOperator/components/CalculationCard.tsx create mode 100644 src/features/financialOperator/components/EvidenceCard.tsx create mode 100644 src/features/financialOperator/components/ExtractionCard.tsx create mode 100644 src/features/financialOperator/components/FinancialOperatorTimeline.tsx create mode 100644 src/features/financialOperator/components/ResultCard.tsx create mode 100644 src/features/financialOperator/components/RunBriefCard.tsx create mode 100644 src/features/financialOperator/components/StepCard.tsx create mode 100644 src/features/financialOperator/components/StepShell.tsx create mode 100644 src/features/financialOperator/components/StepStatusBadge.tsx create mode 100644 src/features/financialOperator/components/ToolCallCard.tsx create mode 100644 src/features/financialOperator/components/ValidationCard.tsx create mode 100644 src/features/financialOperator/index.ts create mode 100644 src/features/financialOperator/types.ts create mode 100644 src/features/financialOperator/views/FinancialOperatorDemo.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index ddc19ba14..c5f0aa67e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -71,6 +71,7 @@ import type * as domains_agents_arbitrage_tools_sourceQualityRanking from "../do import type * as domains_agents_autonomousCrons from "../domains/agents/autonomousCrons.js"; import type * as domains_agents_autonomousCronsQueries from "../domains/agents/autonomousCronsQueries.js"; import type * as domains_agents_batchAPI from "../domains/agents/batchAPI.js"; +import type * as domains_agents_budget_budgetGate from "../domains/agents/budget/budgetGate.js"; import type * as domains_agents_canonicalPlanner from "../domains/agents/canonicalPlanner.js"; import type * as domains_agents_canonicalRuntimeMutations from "../domains/agents/canonicalRuntimeMutations.js"; import type * as domains_agents_canonicalRuntimeQueries from "../domains/agents/canonicalRuntimeQueries.js"; @@ -213,6 +214,12 @@ import type * as domains_agents_hitl_tools_askHuman from "../domains/agents/hitl import type * as domains_agents_hitl_tools_index from "../domains/agents/hitl/tools/index.js"; import type * as domains_agents_humanInTheLoop from "../domains/agents/humanInTheLoop.js"; import type * as domains_agents_index from "../domains/agents/index.js"; +import type * as domains_agents_lessons_captureLesson from "../domains/agents/lessons/captureLesson.js"; +import type * as domains_agents_lessons_getRelevantLessons from "../domains/agents/lessons/getRelevantLessons.js"; +import type * as domains_agents_lessons_infraPreferIds from "../domains/agents/lessons/infraPreferIds.js"; +import type * as domains_agents_lessons_lessonInjection from "../domains/agents/lessons/lessonInjection.js"; +import type * as domains_agents_lessons_lessonsPublic from "../domains/agents/lessons/lessonsPublic.js"; +import type * as domains_agents_lessons_systemPromptBuilder from "../domains/agents/lessons/systemPromptBuilder.js"; import type * as domains_agents_mcp_tools_context_contextInitializerTool from "../domains/agents/mcp_tools/context/contextInitializerTool.js"; import type * as domains_agents_mcp_tools_context_index from "../domains/agents/mcp_tools/context/index.js"; import type * as domains_agents_mcp_tools_index from "../domains/agents/mcp_tools/index.js"; @@ -236,6 +243,7 @@ import type * as domains_agents_orchestrator_worker from "../domains/agents/orch import type * as domains_agents_parallelTaskOrchestrator from "../domains/agents/parallelTaskOrchestrator.js"; import type * as domains_agents_parallelTaskTree from "../domains/agents/parallelTaskTree.js"; import type * as domains_agents_promptEnhancer from "../domains/agents/promptEnhancer.js"; +import type * as domains_agents_publicWrappers from "../domains/agents/publicWrappers.js"; import type * as domains_agents_receipts_actionReceipts from "../domains/agents/receipts/actionReceipts.js"; import type * as domains_agents_receipts_emitWithReceipt from "../domains/agents/receipts/emitWithReceipt.js"; import type * as domains_agents_researchJobs from "../domains/agents/researchJobs.js"; @@ -247,6 +255,9 @@ import type * as domains_agents_safety_rateLimitGuard from "../domains/agents/sa import type * as domains_agents_safety_singleflightMap from "../domains/agents/safety/singleflightMap.js"; import type * as domains_agents_selfEvolution from "../domains/agents/selfEvolution.js"; import type * as domains_agents_selfEvolutionQueries from "../domains/agents/selfEvolutionQueries.js"; +import type * as domains_agents_snapshots_rollbackToCheckpoint from "../domains/agents/snapshots/rollbackToCheckpoint.js"; +import type * as domains_agents_snapshots_snapshotCheckpoint from "../domains/agents/snapshots/snapshotCheckpoint.js"; +import type * as domains_agents_spiral_spiralDetector from "../domains/agents/spiral/spiralDetector.js"; import type * as domains_agents_swarmDeliberation from "../domains/agents/swarmDeliberation.js"; import type * as domains_agents_swarmDeliberationQueries from "../domains/agents/swarmDeliberationQueries.js"; import type * as domains_agents_swarmMutations from "../domains/agents/swarmMutations.js"; @@ -268,6 +279,8 @@ import type * as domains_ai_ai from "../domains/ai/ai.js"; import type * as domains_ai_genai from "../domains/ai/genai.js"; import type * as domains_ai_metadataAnalyzer from "../domains/ai/metadataAnalyzer.js"; import type * as domains_ai_models_autonomousModelResolver from "../domains/ai/models/autonomousModelResolver.js"; +import type * as domains_ai_models_capabilityRegistry from "../domains/ai/models/capabilityRegistry.js"; +import type * as domains_ai_models_chainResolver from "../domains/ai/models/chainResolver.js"; import type * as domains_ai_models_freeModelDiscovery from "../domains/ai/models/freeModelDiscovery.js"; import type * as domains_ai_models_index from "../domains/ai/models/index.js"; import type * as domains_ai_models_livePerformanceEval from "../domains/ai/models/livePerformanceEval.js"; @@ -532,6 +545,14 @@ import type * as domains_evaluation_ultraLongChat_scenarios from "../domains/eva import type * as domains_evaluation_ultraLongChat_storage from "../domains/evaluation/ultraLongChat/storage.js"; import type * as domains_evaluation_validators from "../domains/evaluation/validators.js"; import type * as domains_evaluation_workbenchQueries from "../domains/evaluation/workbenchQueries.js"; +import type * as domains_financialOperator_attFixture from "../domains/financialOperator/attFixture.js"; +import type * as domains_financialOperator_extractors from "../domains/financialOperator/extractors.js"; +import type * as domains_financialOperator_index from "../domains/financialOperator/index.js"; +import type * as domains_financialOperator_orchestrator from "../domains/financialOperator/orchestrator.js"; +import type * as domains_financialOperator_runOps from "../domains/financialOperator/runOps.js"; +import type * as domains_financialOperator_sandbox from "../domains/financialOperator/sandbox.js"; +import type * as domains_financialOperator_types from "../domains/financialOperator/types.js"; +import type * as domains_financialOperator_validators from "../domains/financialOperator/validators.js"; import type * as domains_financial_balanceSheetFetcher from "../domains/financial/balanceSheetFetcher.js"; import type * as domains_financial_corporateActions from "../domains/financial/corporateActions.js"; import type * as domains_financial_corrections from "../domains/financial/corrections.js"; @@ -1497,6 +1518,7 @@ declare const fullApi: ApiFromModules<{ "domains/agents/autonomousCrons": typeof domains_agents_autonomousCrons; "domains/agents/autonomousCronsQueries": typeof domains_agents_autonomousCronsQueries; "domains/agents/batchAPI": typeof domains_agents_batchAPI; + "domains/agents/budget/budgetGate": typeof domains_agents_budget_budgetGate; "domains/agents/canonicalPlanner": typeof domains_agents_canonicalPlanner; "domains/agents/canonicalRuntimeMutations": typeof domains_agents_canonicalRuntimeMutations; "domains/agents/canonicalRuntimeQueries": typeof domains_agents_canonicalRuntimeQueries; @@ -1639,6 +1661,12 @@ declare const fullApi: ApiFromModules<{ "domains/agents/hitl/tools/index": typeof domains_agents_hitl_tools_index; "domains/agents/humanInTheLoop": typeof domains_agents_humanInTheLoop; "domains/agents/index": typeof domains_agents_index; + "domains/agents/lessons/captureLesson": typeof domains_agents_lessons_captureLesson; + "domains/agents/lessons/getRelevantLessons": typeof domains_agents_lessons_getRelevantLessons; + "domains/agents/lessons/infraPreferIds": typeof domains_agents_lessons_infraPreferIds; + "domains/agents/lessons/lessonInjection": typeof domains_agents_lessons_lessonInjection; + "domains/agents/lessons/lessonsPublic": typeof domains_agents_lessons_lessonsPublic; + "domains/agents/lessons/systemPromptBuilder": typeof domains_agents_lessons_systemPromptBuilder; "domains/agents/mcp_tools/context/contextInitializerTool": typeof domains_agents_mcp_tools_context_contextInitializerTool; "domains/agents/mcp_tools/context/index": typeof domains_agents_mcp_tools_context_index; "domains/agents/mcp_tools/index": typeof domains_agents_mcp_tools_index; @@ -1662,6 +1690,7 @@ declare const fullApi: ApiFromModules<{ "domains/agents/parallelTaskOrchestrator": typeof domains_agents_parallelTaskOrchestrator; "domains/agents/parallelTaskTree": typeof domains_agents_parallelTaskTree; "domains/agents/promptEnhancer": typeof domains_agents_promptEnhancer; + "domains/agents/publicWrappers": typeof domains_agents_publicWrappers; "domains/agents/receipts/actionReceipts": typeof domains_agents_receipts_actionReceipts; "domains/agents/receipts/emitWithReceipt": typeof domains_agents_receipts_emitWithReceipt; "domains/agents/researchJobs": typeof domains_agents_researchJobs; @@ -1673,6 +1702,9 @@ declare const fullApi: ApiFromModules<{ "domains/agents/safety/singleflightMap": typeof domains_agents_safety_singleflightMap; "domains/agents/selfEvolution": typeof domains_agents_selfEvolution; "domains/agents/selfEvolutionQueries": typeof domains_agents_selfEvolutionQueries; + "domains/agents/snapshots/rollbackToCheckpoint": typeof domains_agents_snapshots_rollbackToCheckpoint; + "domains/agents/snapshots/snapshotCheckpoint": typeof domains_agents_snapshots_snapshotCheckpoint; + "domains/agents/spiral/spiralDetector": typeof domains_agents_spiral_spiralDetector; "domains/agents/swarmDeliberation": typeof domains_agents_swarmDeliberation; "domains/agents/swarmDeliberationQueries": typeof domains_agents_swarmDeliberationQueries; "domains/agents/swarmMutations": typeof domains_agents_swarmMutations; @@ -1694,6 +1726,8 @@ declare const fullApi: ApiFromModules<{ "domains/ai/genai": typeof domains_ai_genai; "domains/ai/metadataAnalyzer": typeof domains_ai_metadataAnalyzer; "domains/ai/models/autonomousModelResolver": typeof domains_ai_models_autonomousModelResolver; + "domains/ai/models/capabilityRegistry": typeof domains_ai_models_capabilityRegistry; + "domains/ai/models/chainResolver": typeof domains_ai_models_chainResolver; "domains/ai/models/freeModelDiscovery": typeof domains_ai_models_freeModelDiscovery; "domains/ai/models/index": typeof domains_ai_models_index; "domains/ai/models/livePerformanceEval": typeof domains_ai_models_livePerformanceEval; @@ -1958,6 +1992,14 @@ declare const fullApi: ApiFromModules<{ "domains/evaluation/ultraLongChat/storage": typeof domains_evaluation_ultraLongChat_storage; "domains/evaluation/validators": typeof domains_evaluation_validators; "domains/evaluation/workbenchQueries": typeof domains_evaluation_workbenchQueries; + "domains/financialOperator/attFixture": typeof domains_financialOperator_attFixture; + "domains/financialOperator/extractors": typeof domains_financialOperator_extractors; + "domains/financialOperator/index": typeof domains_financialOperator_index; + "domains/financialOperator/orchestrator": typeof domains_financialOperator_orchestrator; + "domains/financialOperator/runOps": typeof domains_financialOperator_runOps; + "domains/financialOperator/sandbox": typeof domains_financialOperator_sandbox; + "domains/financialOperator/types": typeof domains_financialOperator_types; + "domains/financialOperator/validators": typeof domains_financialOperator_validators; "domains/financial/balanceSheetFetcher": typeof domains_financial_balanceSheetFetcher; "domains/financial/corporateActions": typeof domains_financial_corporateActions; "domains/financial/corrections": typeof domains_financial_corrections; @@ -2881,4714 +2923,14 @@ export declare const internal: FilterApi< >; export declare const components: { - prosemirrorSync: { - lib: { - deleteDocument: FunctionReference< - "mutation", - "internal", - { id: string }, - null - >; - deleteSnapshots: FunctionReference< - "mutation", - "internal", - { afterVersion?: number; beforeVersion?: number; id: string }, - null - >; - deleteSteps: FunctionReference< - "mutation", - "internal", - { - afterVersion?: number; - beforeTs: number; - deleteNewerThanLatestSnapshot?: boolean; - id: string; - }, - null - >; - getSnapshot: FunctionReference< - "query", - "internal", - { id: string; version?: number }, - { content: null } | { content: string; version: number } - >; - getSteps: FunctionReference< - "query", - "internal", - { id: string; version: number }, - { - clientIds: Array; - steps: Array; - version: number; - } - >; - latestVersion: FunctionReference< - "query", - "internal", - { id: string }, - null | number - >; - submitSnapshot: FunctionReference< - "mutation", - "internal", - { - content: string; - id: string; - pruneSnapshots?: boolean; - version: number; - }, - null - >; - submitSteps: FunctionReference< - "mutation", - "internal", - { - clientId: string | number; - id: string; - steps: Array; - version: number; - }, - | { - clientIds: Array; - status: "needs-rebase"; - steps: Array; - } - | { status: "synced" } - >; - }; - }; - presence: { - public: { - disconnect: FunctionReference< - "mutation", - "internal", - { sessionToken: string }, - null - >; - heartbeat: FunctionReference< - "mutation", - "internal", - { - interval?: number; - roomId: string; - sessionId: string; - userId: string; - }, - { roomToken: string; sessionToken: string } - >; - list: FunctionReference< - "query", - "internal", - { limit?: number; roomToken: string }, - Array<{ lastDisconnected: number; online: boolean; userId: string }> - >; - listRoom: FunctionReference< - "query", - "internal", - { limit?: number; onlineOnly?: boolean; roomId: string }, - Array<{ lastDisconnected: number; online: boolean; userId: string }> - >; - listUser: FunctionReference< - "query", - "internal", - { limit?: number; onlineOnly?: boolean; userId: string }, - Array<{ lastDisconnected: number; online: boolean; roomId: string }> - >; - removeRoom: FunctionReference< - "mutation", - "internal", - { roomId: string }, - null - >; - removeRoomUser: FunctionReference< - "mutation", - "internal", - { roomId: string; userId: string }, - null - >; - }; - }; - agent: { - apiKeys: { - destroy: FunctionReference< - "mutation", - "internal", - { apiKey?: string; name?: string }, - | "missing" - | "deleted" - | "name mismatch" - | "must provide either apiKey or name" - >; - issue: FunctionReference< - "mutation", - "internal", - { name?: string }, - string - >; - validate: FunctionReference< - "query", - "internal", - { apiKey: string }, - boolean - >; - }; - files: { - addFile: FunctionReference< - "mutation", - "internal", - { - filename?: string; - hash: string; - mimeType: string; - storageId: string; - }, - { fileId: string; storageId: string } - >; - copyFile: FunctionReference< - "mutation", - "internal", - { fileId: string }, - null - >; - deleteFiles: FunctionReference< - "mutation", - "internal", - { fileIds: Array; force?: boolean }, - Array - >; - get: FunctionReference< - "query", - "internal", - { fileId: string }, - null | { - _creationTime: number; - _id: string; - filename?: string; - hash: string; - lastTouchedAt: number; - mimeType: string; - refcount: number; - storageId: string; - } - >; - getFilesToDelete: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - filename?: string; - hash: string; - lastTouchedAt: number; - mimeType: string; - refcount: number; - storageId: string; - }>; - } - >; - useExistingFile: FunctionReference< - "mutation", - "internal", - { filename?: string; hash: string }, - null | { fileId: string; storageId: string } - >; - }; - messages: { - addMessages: FunctionReference< - "mutation", - "internal", - { - agentName?: string; - embeddings?: { - dimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - model: string; - vectors: Array | null>; - }; - failPendingSteps?: boolean; - messages: Array<{ - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - message: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - provider?: string; - providerMetadata?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status?: "pending" | "success" | "failed"; - text?: string; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - pendingMessageId?: string; - promptMessageId?: string; - threadId: string; - userId?: string; - }, - { - messages: Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - } - >; - deleteByIds: FunctionReference< - "mutation", - "internal", - { messageIds: Array }, - Array - >; - deleteByOrder: FunctionReference< - "mutation", - "internal", - { - endOrder: number; - endStepOrder?: number; - startOrder: number; - startStepOrder?: number; - threadId: string; - }, - { isDone: boolean; lastOrder?: number; lastStepOrder?: number } - >; - finalizeMessage: FunctionReference< - "mutation", - "internal", - { - messageId: string; - result: { status: "success" } | { error: string; status: "failed" }; - }, - null - >; - getMessagesByIds: FunctionReference< - "query", - "internal", - { messageIds: Array }, - Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - getMessageSearchFields: FunctionReference< - "query", - "internal", - { messageId: string }, - { embedding?: Array; embeddingModel?: string; text?: string } - >; - listMessagesByThreadId: FunctionReference< - "query", - "internal", - { - excludeToolMessages?: boolean; - order: "asc" | "desc"; - paginationOpts?: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - statuses?: Array<"pending" | "success" | "failed">; - threadId: string; - upToAndIncludingMessageId?: string; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - searchMessages: FunctionReference< - "action", - "internal", - { - embedding?: Array; - embeddingModel?: string; - limit: number; - messageRange?: { after: number; before: number }; - searchAllMessagesForUserId?: string; - targetMessageId?: string; - text?: string; - textSearch?: boolean; - threadId?: string; - vectorScoreThreshold?: number; - vectorSearch?: boolean; - }, - Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - textSearch: FunctionReference< - "query", - "internal", - { - limit: number; - searchAllMessagesForUserId?: string; - targetMessageId?: string; - text?: string; - threadId?: string; - }, - Array<{ - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }> - >; - updateMessage: FunctionReference< - "mutation", - "internal", - { - messageId: string; - patch: { - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record< - string, - Record - >; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { - data: string; - mimeType?: string; - type: "image"; - } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - provider?: string; - providerOptions?: Record>; - status?: "pending" | "success" | "failed"; - }; - }, - { - _creationTime: number; - _id: string; - agentName?: string; - embeddingId?: string; - error?: string; - fileIds?: Array; - finishReason?: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "other" - | "unknown"; - id?: string; - message?: - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - image: string | ArrayBuffer; - mimeType?: string; - providerOptions?: Record>; - type: "image"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - >; - providerOptions?: Record>; - role: "user"; - } - | { - content: - | string - | Array< - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - text: string; - type: "text"; - } - | { - data: string | ArrayBuffer; - filename?: string; - mimeType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "file"; - } - | { - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { - data: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - type: "redacted-reasoning"; - } - | { - args: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - toolCallId: string; - toolName: string; - type: "tool-call"; - } - | { - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { - data: string; - mediaType: string; - type: "media"; - } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - } - | { - id: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - providerOptions?: Record>; - role: "assistant"; - } - | { - content: Array<{ - args?: any; - experimental_content?: Array< - | { text: string; type: "text" } - | { data: string; mimeType?: string; type: "image" } - >; - isError?: boolean; - output?: - | { type: "text"; value: string } - | { type: "json"; value: any } - | { type: "error-text"; value: string } - | { type: "error-json"; value: any } - | { - type: "content"; - value: Array< - | { text: string; type: "text" } - | { data: string; mediaType: string; type: "media" } - >; - }; - providerExecuted?: boolean; - providerMetadata?: Record>; - providerOptions?: Record>; - result?: any; - toolCallId: string; - toolName: string; - type: "tool-result"; - }>; - providerOptions?: Record>; - role: "tool"; - } - | { - content: string; - providerOptions?: Record>; - role: "system"; - }; - model?: string; - order: number; - provider?: string; - providerMetadata?: Record>; - providerOptions?: Record>; - reasoning?: string; - reasoningDetails?: Array< - | { - providerMetadata?: Record>; - providerOptions?: Record>; - signature?: string; - text: string; - type: "reasoning"; - } - | { signature?: string; text: string; type: "text" } - | { data: string; type: "redacted" } - >; - sources?: Array< - | { - id: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "url"; - title?: string; - type?: "source"; - url: string; - } - | { - filename?: string; - id: string; - mediaType: string; - providerMetadata?: Record>; - providerOptions?: Record>; - sourceType: "document"; - title: string; - type: "source"; - } - >; - status: "pending" | "success" | "failed"; - stepOrder: number; - text?: string; - threadId: string; - tool: boolean; - usage?: { - cachedInputTokens?: number; - completionTokens: number; - promptTokens: number; - reasoningTokens?: number; - totalTokens: number; - }; - userId?: string; - warnings?: Array< - | { details?: string; setting: string; type: "unsupported-setting" } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - } - >; - }; - streams: { - abort: FunctionReference< - "mutation", - "internal", - { - finalDelta?: { - end: number; - parts: Array; - start: number; - streamId: string; - }; - reason: string; - streamId: string; - }, - boolean - >; - abortByOrder: FunctionReference< - "mutation", - "internal", - { order: number; reason: string; threadId: string }, - boolean - >; - addDelta: FunctionReference< - "mutation", - "internal", - { end: number; parts: Array; start: number; streamId: string }, - boolean - >; - create: FunctionReference< - "mutation", - "internal", - { - agentName?: string; - format?: "UIMessageChunk" | "TextStreamPart"; - model?: string; - order: number; - provider?: string; - providerOptions?: Record>; - stepOrder: number; - threadId: string; - userId?: string; - }, - string - >; - deleteAllStreamsForThreadIdAsync: FunctionReference< - "mutation", - "internal", - { deltaCursor?: string; streamOrder?: number; threadId: string }, - { deltaCursor?: string; isDone: boolean; streamOrder?: number } - >; - deleteAllStreamsForThreadIdSync: FunctionReference< - "action", - "internal", - { threadId: string }, - null - >; - deleteStreamAsync: FunctionReference< - "mutation", - "internal", - { cursor?: string; streamId: string }, - null - >; - deleteStreamSync: FunctionReference< - "mutation", - "internal", - { streamId: string }, - null - >; - finish: FunctionReference< - "mutation", - "internal", - { - finalDelta?: { - end: number; - parts: Array; - start: number; - streamId: string; - }; - streamId: string; - }, - null - >; - heartbeat: FunctionReference< - "mutation", - "internal", - { streamId: string }, - null - >; - list: FunctionReference< - "query", - "internal", - { - startOrder?: number; - statuses?: Array<"streaming" | "finished" | "aborted">; - threadId: string; - }, - Array<{ - agentName?: string; - format?: "UIMessageChunk" | "TextStreamPart"; - model?: string; - order: number; - provider?: string; - providerOptions?: Record>; - status: "streaming" | "finished" | "aborted"; - stepOrder: number; - streamId: string; - userId?: string; - }> - >; - listDeltas: FunctionReference< - "query", - "internal", - { - cursors: Array<{ cursor: number; streamId: string }>; - threadId: string; - }, - Array<{ - end: number; - parts: Array; - start: number; - streamId: string; - }> - >; - }; - threads: { - createThread: FunctionReference< - "mutation", - "internal", - { - defaultSystemPrompt?: string; - parentThreadIds?: Array; - summary?: string; - title?: string; - userId?: string; - }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } - >; - deleteAllForThreadIdAsync: FunctionReference< - "mutation", - "internal", - { - cursor?: string; - deltaCursor?: string; - limit?: number; - messagesDone?: boolean; - streamOrder?: number; - streamsDone?: boolean; - threadId: string; - }, - { isDone: boolean } - >; - deleteAllForThreadIdSync: FunctionReference< - "action", - "internal", - { limit?: number; threadId: string }, - null - >; - getThread: FunctionReference< - "query", - "internal", - { threadId: string }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } | null - >; - listThreadsByUserId: FunctionReference< - "query", - "internal", - { - order?: "asc" | "desc"; - paginationOpts?: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - userId?: string; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - searchThreadTitles: FunctionReference< - "query", - "internal", - { limit: number; query: string; userId?: string | null }, - Array<{ - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }> - >; - updateThread: FunctionReference< - "mutation", - "internal", - { - patch: { - status?: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - }; - threadId: string; - }, - { - _creationTime: number; - _id: string; - status: "active" | "archived"; - summary?: string; - title?: string; - userId?: string; - } - >; - }; - users: { - deleteAllForUserId: FunctionReference< - "action", - "internal", - { userId: string }, - null - >; - deleteAllForUserIdAsync: FunctionReference< - "mutation", - "internal", - { userId: string }, - boolean - >; - listUsersWithThreads: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - }; - vector: { - index: { - deleteBatch: FunctionReference< - "mutation", - "internal", - { - ids: Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - >; - }, - null - >; - deleteBatchForThread: FunctionReference< - "mutation", - "internal", - { - cursor?: string; - limit: number; - model: string; - threadId: string; - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - }, - { continueCursor: string; isDone: boolean } - >; - insertBatch: FunctionReference< - "mutation", - "internal", - { - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - vectors: Array<{ - messageId?: string; - model: string; - table: string; - threadId?: string; - userId?: string; - vector: Array; - }>; - }, - Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - > - >; - paginate: FunctionReference< - "query", - "internal", - { - cursor?: string; - limit: number; - table?: string; - targetModel: string; - vectorDimension: - | 128 - | 256 - | 512 - | 768 - | 1024 - | 1408 - | 1536 - | 2048 - | 3072 - | 4096; - }, - { - continueCursor: string; - ids: Array< - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string - >; - isDone: boolean; - } - >; - updateBatch: FunctionReference< - "mutation", - "internal", - { - vectors: Array<{ - id: - | string - | string - | string - | string - | string - | string - | string - | string - | string - | string; - model: string; - vector: Array; - }>; - }, - null - >; - }; - }; - }; - workpool: { - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - limit?: number; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - enqueueBatch: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism: number; - }; - items: Array<{ - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }>; - }, - Array - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - statusBatch: FunctionReference< - "query", - "internal", - { ids: Array }, - Array< - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - > - >; - }; - }; - workflow: { - journal: { - load: FunctionReference< - "query", - "internal", - { workflowId: string }, - { - journalEntries: Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - ok: boolean; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - startSteps: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - steps: Array<{ - retry?: - | boolean - | { base: number; initialBackoffMs: number; maxAttempts: number }; - schedulerOptions?: { runAt?: number } | { runAfter?: number }; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - }>; - workflowId: string; - workpoolOptions?: { - defaultRetryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - retryActionsByDefault?: boolean; - }; - }, - Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }> - >; - }; - workflow: { - cancel: FunctionReference< - "mutation", - "internal", - { workflowId: string }, - null - >; - cleanup: FunctionReference< - "mutation", - "internal", - { workflowId: string }, - boolean - >; - complete: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - runResult: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId: string; - }, - null - >; - create: FunctionReference< - "mutation", - "internal", - { - maxParallelism?: number; - onComplete?: { context?: any; fnHandle: string }; - startAsync?: boolean; - workflowArgs: any; - workflowHandle: string; - workflowName: string; - }, - string - >; - getStatus: FunctionReference< - "query", - "internal", - { workflowId: string }, - { - inProgress: Array<{ - _creationTime: number; - _id: string; - step: { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - }; - }; - rag: { - chunks: { - insert: FunctionReference< - "mutation", - "internal", - { - chunks: Array<{ - content: { metadata?: Record; text: string }; - embedding: Array; - searchableText?: string; - }>; - entryId: string; - startOrder: number; - }, - { status: "pending" | "ready" | "replaced" } - >; - list: FunctionReference< - "query", - "internal", - { - entryId: string; - order: "desc" | "asc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - metadata?: Record; - order: number; - state: "pending" | "ready" | "replaced"; - text: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - replaceChunksPage: FunctionReference< - "mutation", - "internal", - { entryId: string; startOrder: number }, - { nextStartOrder: number; status: "pending" | "ready" | "replaced" } - >; - }; - entries: { - add: FunctionReference< - "mutation", - "internal", - { - allChunks?: Array<{ - content: { metadata?: Record; text: string }; - embedding: Array; - searchableText?: string; - }>; - entry: { - contentHash?: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - namespaceId: string; - title?: string; - }; - onComplete?: string; - }, - { - created: boolean; - entryId: string; - status: "pending" | "ready" | "replaced"; - } - >; - addAsync: FunctionReference< - "mutation", - "internal", - { - chunker: string; - entry: { - contentHash?: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - namespaceId: string; - title?: string; - }; - onComplete?: string; - }, - { created: boolean; entryId: string; status: "pending" | "ready" } - >; - deleteAsync: FunctionReference< - "mutation", - "internal", - { entryId: string; startOrder: number }, - null - >; - deleteByKeyAsync: FunctionReference< - "mutation", - "internal", - { beforeVersion?: number; key: string; namespaceId: string }, - null - >; - deleteByKeySync: FunctionReference< - "action", - "internal", - { key: string; namespaceId: string }, - null - >; - deleteSync: FunctionReference< - "action", - "internal", - { entryId: string }, - null - >; - findByContentHash: FunctionReference< - "query", - "internal", - { - contentHash: string; - dimension: number; - filterNames: Array; - key: string; - modelId: string; - namespace: string; - }, - { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null - >; - get: FunctionReference< - "query", - "internal", - { entryId: string }, - { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null - >; - list: FunctionReference< - "query", - "internal", - { - namespaceId?: string; - order?: "desc" | "asc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - status: "pending" | "ready" | "replaced"; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - promoteToReady: FunctionReference< - "mutation", - "internal", - { entryId: string }, - { - replacedEntry: { - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - } | null; - } - >; - }; - namespaces: { - deleteNamespace: FunctionReference< - "mutation", - "internal", - { namespaceId: string }, - { - deletedNamespace: null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }; - } - >; - deleteNamespaceSync: FunctionReference< - "action", - "internal", - { namespaceId: string }, - null - >; - get: FunctionReference< - "query", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - }, - null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - } - >; - getOrCreate: FunctionReference< - "mutation", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - onComplete?: string; - status: "pending" | "ready"; - }, - { namespaceId: string; status: "pending" | "ready" } - >; - list: FunctionReference< - "query", - "internal", - { - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - status: "pending" | "ready" | "replaced"; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - listNamespaceVersions: FunctionReference< - "query", - "internal", - { - namespace: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - lookup: FunctionReference< - "query", - "internal", - { - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - }, - null | string - >; - promoteToReady: FunctionReference< - "mutation", - "internal", - { namespaceId: string }, - { - replacedNamespace: null | { - createdAt: number; - dimension: number; - filterNames: Array; - modelId: string; - namespace: string; - namespaceId: string; - status: "pending" | "ready" | "replaced"; - version: number; - }; - } - >; - }; - search: { - search: FunctionReference< - "action", - "internal", - { - chunkContext?: { after: number; before: number }; - embedding: Array; - filters: Array<{ name: string; value: any }>; - limit: number; - modelId: string; - namespace: string; - vectorScoreThreshold?: number; - }, - { - entries: Array<{ - contentHash?: string; - entryId: string; - filterValues: Array<{ name: string; value: any }>; - importance: number; - key?: string; - metadata?: Record; - replacedAt?: number; - status: "pending" | "ready" | "replaced"; - title?: string; - }>; - results: Array<{ - content: Array<{ metadata?: Record; text: string }>; - entryId: string; - order: number; - score: number; - startOrder: number; - }>; - } - >; - }; - }; - persistentTextStreaming: { - lib: { - addChunk: FunctionReference< - "mutation", - "internal", - { final: boolean; streamId: string; text: string }, - any - >; - createStream: FunctionReference<"mutation", "internal", {}, any>; - getStreamStatus: FunctionReference< - "query", - "internal", - { streamId: string }, - "pending" | "streaming" | "done" | "error" | "timeout" - >; - getStreamText: FunctionReference< - "query", - "internal", - { streamId: string }, - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - text: string; - } - >; - setStreamStatus: FunctionReference< - "mutation", - "internal", - { - status: "pending" | "streaming" | "done" | "error" | "timeout"; - streamId: string; - }, - any - >; - }; - }; - twilio: { - messages: { - create: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - body: string; - callback?: string; - from: string; - status_callback: string; - to: string; - }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } - >; - getByCounterparty: FunctionReference< - "query", - "internal", - { account_sid: string; counterparty: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - getBySid: FunctionReference< - "query", - "internal", - { account_sid: string; sid: string }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } | null - >; - getFrom: FunctionReference< - "query", - "internal", - { account_sid: string; from: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - getFromTwilioBySidAndInsert: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - incomingMessageCallback?: string; - sid: string; - }, - { - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - } - >; - getTo: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number; to: string }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - list: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - listIncoming: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - listOutgoing: FunctionReference< - "query", - "internal", - { account_sid: string; limit?: number }, - Array<{ - account_sid: string; - api_version: string; - body: string; - counterparty?: string; - date_created: string; - date_sent: string | null; - date_updated: string | null; - direction: string; - error_code: number | null; - error_message: string | null; - from: string; - messaging_service_sid: string | null; - num_media: string; - num_segments: string; - price: string | null; - price_unit: string | null; - rest?: any; - sid: string; - status: string; - subresource_uris: { feedback?: string; media: string } | null; - to: string; - uri: string; - }> - >; - updateStatus: FunctionReference< - "mutation", - "internal", - { account_sid: string; sid: string; status: string }, - null - >; - }; - phone_numbers: { - create: FunctionReference< - "action", - "internal", - { account_sid: string; auth_token: string; number: string }, - any - >; - updateSmsUrl: FunctionReference< - "action", - "internal", - { - account_sid: string; - auth_token: string; - sid: string; - sms_url: string; - }, - any - >; - }; - }; - polar: { - lib: { - createProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - }, - any - >; - createSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - getCurrentSubscription: FunctionReference< - "query", - "internal", - { userId: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - getCustomerByUserId: FunctionReference< - "query", - "internal", - { userId: string }, - { id: string; metadata?: Record; userId: string } | null - >; - getProduct: FunctionReference< - "query", - "internal", - { id: string }, - { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - } | null - >; - getSubscription: FunctionReference< - "query", - "internal", - { id: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - insertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - listCustomerSubscriptions: FunctionReference< - "query", - "internal", - { customerId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - listProducts: FunctionReference< - "query", - "internal", - { includeArchived?: boolean }, - Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - priceAmount?: number; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }> - >; - listUserSubscriptions: FunctionReference< - "query", - "internal", - { userId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - } | null; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - syncProducts: FunctionReference< - "action", - "internal", - { polarAccessToken: string; server: "sandbox" | "production" }, - any - >; - updateProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }; - }, - any - >; - updateProducts: FunctionReference< - "mutation", - "internal", - { - polarAccessToken: string; - products: Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - modifiedAt: string | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "month" | "year" | null; - }>; - }, - any - >; - updateSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - upsertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - }; - }; - ossStats: { - github: { - getGithubOwners: FunctionReference< - "query", - "internal", - { owners: Array }, - Array - >; - getGithubRepo: FunctionReference< - "query", - "internal", - { name: string }, - null | { - contributorCount: number; - dependentCount: number; - dependentCountPrevious?: { count: number; updatedAt: number }; - dependentCountUpdatedAt?: number; - name: string; - nameNormalized: string; - owner: string; - ownerNormalized: string; - starCount: number; - updatedAt: number; - } - >; - getGithubRepos: FunctionReference< - "query", - "internal", - { names: Array }, - Array - >; - updateGithubOwner: FunctionReference< - "mutation", - "internal", - { name: string }, - any - >; - updateGithubOwnerStats: FunctionReference< - "action", - "internal", - { githubAccessToken: string; owner: string; page?: number }, - any - >; - updateGithubRepos: FunctionReference< - "mutation", - "internal", - { - repos: Array<{ - contributorCount: number; - dependentCount: number; - name: string; - owner: string; - starCount: number; - }>; - }, - any - >; - updateGithubRepoStars: FunctionReference< - "mutation", - "internal", - { name: string; owner: string; starCount: number }, - any - >; - updateGithubRepoStats: FunctionReference< - "action", - "internal", - { githubAccessToken: string; repo: string }, - any - >; - }; - lib: { - clearAndSync: FunctionReference< - "action", - "internal", - { - githubAccessToken: string; - githubOwners?: Array; - githubRepos?: Array; - minStars?: number; - npmOrgs?: Array; - npmPackages?: Array; - }, - any - >; - clearPage: FunctionReference< - "mutation", - "internal", - { tableName: "githubRepos" | "npmPackages" }, - { isDone: boolean } - >; - clearTable: FunctionReference< - "action", - "internal", - { tableName: "githubRepos" | "npmPackages" }, - null - >; - sync: FunctionReference< - "action", - "internal", - { - githubAccessToken: string; - githubOwners?: Array; - githubRepos?: Array; - minStars?: number; - npmOrgs?: Array; - npmPackages?: Array; - }, - null - >; - }; - npm: { - getNpmOrgs: FunctionReference< - "query", - "internal", - { names: Array }, - Array; - downloadCount: number; - downloadCountUpdatedAt: number; - name: string; - updatedAt: number; - }> - >; - getNpmPackage: FunctionReference< - "query", - "internal", - { name: string }, - null | { - dayOfWeekAverages: Array; - downloadCount: number; - downloadCountUpdatedAt?: number; - name: string; - org?: string; - updatedAt: number; - } - >; - getNpmPackages: FunctionReference< - "query", - "internal", - { names: Array }, - { - dayOfWeekAverages: Array; - downloadCount: number; - downloadCountUpdatedAt: number; - updatedAt: number; - } - >; - updateNpmOrg: FunctionReference< - "mutation", - "internal", - { name: string }, - any - >; - updateNpmOrgStats: FunctionReference< - "action", - "internal", - { org: string; page?: number }, - any - >; - updateNpmPackage: FunctionReference< - "mutation", - "internal", - { - dayOfWeekAverages: Array; - downloadCount: number; - name: string; - }, - any - >; - updateNpmPackagesForOrg: FunctionReference< - "mutation", - "internal", - { - org: string; - packages: Array<{ - dayOfWeekAverages: Array; - downloadCount: number; - isNotFound?: boolean; - name: string; - }>; - }, - any - >; - updateNpmPackageStats: FunctionReference< - "action", - "internal", - { name: string }, - any - >; - }; - }; + prosemirrorSync: import("@convex-dev/prosemirror-sync/_generated/component.js").ComponentApi<"prosemirrorSync">; + presence: import("@convex-dev/presence/_generated/component.js").ComponentApi<"presence">; + agent: import("@convex-dev/agent/_generated/component.js").ComponentApi<"agent">; + workpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"workpool">; + workflow: import("@convex-dev/workflow/_generated/component.js").ComponentApi<"workflow">; + rag: import("@convex-dev/rag/_generated/component.js").ComponentApi<"rag">; + persistentTextStreaming: import("@convex-dev/persistent-text-streaming/_generated/component.js").ComponentApi<"persistentTextStreaming">; + twilio: import("@convex-dev/twilio/_generated/component.js").ComponentApi<"twilio">; + polar: import("@convex-dev/polar/_generated/component.js").ComponentApi<"polar">; + ossStats: import("@erquhart/convex-oss-stats/_generated/component.js").ComponentApi<"ossStats">; }; diff --git a/convex/domains/financialOperator/__tests__/sandbox.scenario.test.ts b/convex/domains/financialOperator/__tests__/sandbox.scenario.test.ts new file mode 100644 index 000000000..8690c5e35 --- /dev/null +++ b/convex/domains/financialOperator/__tests__/sandbox.scenario.test.ts @@ -0,0 +1,155 @@ +/** + * Scenario test for the financial sandbox. + * + * Persona: a financial analyst who needs the same number twice in a row, + * across many runs, with no surprises. The compute must be: + * - Deterministic (replay exactness) + * - Honest about errors (zero ratios, NaN, out-of-range) + * - Bounded (no silent NaN propagation into a published artifact) + * + * This test models all six scenario axes (per .claude/rules/scenario_testing): + * 1. Who: analyst replaying a published metric. + * 2. What: ETR + after-tax cost of debt + leverage + variance. + * 3. How: known inputs → expected outputs to 4 decimal places. + * 4. Scale: 1k repeats — same input must always yield same output. + * 5. Duration: long-running — 1k iterations, each must equal the first. + * 6. Failure modes: divide-by-zero, out-of-range, NaN. + */ + +import { describe, it, expect } from "vitest"; +import { + checkCompliance, + computeAfterTaxCostOfDebt, + computeETR, + computeLeverageRatio, + computeVariance, +} from "../sandbox"; + +describe("financial sandbox — scenario coverage", () => { + describe("happy path: AT&T 10-K demo numbers", () => { + const ibt = 22450; + const ite = 3785; + const debtRate = 0.0542; + + it("produces the documented ETR (~16.86%)", () => { + const r = computeETR({ + incomeBeforeTaxes: ibt, + incomeTaxExpense: ite, + }); + expect(r.outputs.etr).toBeCloseTo(0.1686, 4); + expect(r.formattedOutputs.etr).toBe("16.86%"); + expect(r.sandboxKind).toBe("js_pure"); + }); + + it("produces the documented after-tax cost of debt (~4.51%)", () => { + const etr = computeETR({ + incomeBeforeTaxes: ibt, + incomeTaxExpense: ite, + }).outputs.etr; + const r = computeAfterTaxCostOfDebt({ + preTaxDebtRate: debtRate, + effectiveTaxRate: etr, + }); + expect(r.outputs.afterTaxCostOfDebt).toBeCloseTo(0.0451, 4); + expect(r.formattedOutputs.afterTaxCostOfDebt).toBe("4.51%"); + }); + }); + + describe("long-running determinism: 1000 replays", () => { + it("ETR is bit-identical across 1000 calls", () => { + const inputs = { incomeBeforeTaxes: 22450, incomeTaxExpense: 3785 }; + const baseline = computeETR(inputs).outputs.etr; + for (let i = 0; i < 1000; i++) { + const r = computeETR(inputs); + // Stronger than toBeCloseTo — must be identical bytes. + expect(r.outputs.etr).toBe(baseline); + } + }); + + it("leverage ratio is bit-identical across 1000 calls", () => { + const inputs = { totalDebt: 840_000_000, cash: 95_000_000, ebitda: 210_000_000 }; + const baseline = computeLeverageRatio(inputs).outputs.ratio; + for (let i = 0; i < 1000; i++) { + const r = computeLeverageRatio(inputs); + expect(r.outputs.ratio).toBe(baseline); + } + }); + }); + + describe("sad paths: must throw, never silent NaN", () => { + it("ETR rejects zero income before taxes", () => { + expect(() => + computeETR({ incomeBeforeTaxes: 0, incomeTaxExpense: 100 }), + ).toThrow(/incomeBeforeTaxes/); + }); + + it("ETR rejects NaN inputs", () => { + expect(() => + computeETR({ incomeBeforeTaxes: Number.NaN, incomeTaxExpense: 100 }), + ).toThrow(/not finite/); + }); + + it("after-tax cost of debt rejects out-of-range ETR", () => { + expect(() => + computeAfterTaxCostOfDebt({ preTaxDebtRate: 0.05, effectiveTaxRate: 1.5 }), + ).toThrow(/ETR_OUT_OF_RANGE/); + expect(() => + computeAfterTaxCostOfDebt({ preTaxDebtRate: 0.05, effectiveTaxRate: -0.1 }), + ).toThrow(/ETR_OUT_OF_RANGE/); + }); + + it("leverage rejects zero EBITDA (would otherwise divide by zero)", () => { + expect(() => + computeLeverageRatio({ totalDebt: 1000, cash: 100, ebitda: 0 }), + ).toThrow(/ebitda/); + }); + + it("variance rejects zero budget (would otherwise produce Infinity)", () => { + expect(() => computeVariance({ actual: 100, budget: 0 })).toThrow( + /BUDGET_ZERO/, + ); + }); + }); + + describe("compliance gate: covenant scenario", () => { + it("flags compliant when net leverage ≤ threshold", () => { + const lev = computeLeverageRatio({ + totalDebt: 840_000_000, + cash: 95_000_000, + ebitda: 210_000_000, + }); + const c = checkCompliance({ + observedRatio: lev.outputs.ratio, + threshold: 4.25, + ratioName: "net_leverage", + }); + expect(c.outputs.compliant).toBe(1); + expect(c.outputs.headroom).toBeGreaterThan(0); + }); + + it("flags breach when ratio > threshold (no fake VERIFIED)", () => { + const c = checkCompliance({ + observedRatio: 5.1, + threshold: 4.25, + ratioName: "net_leverage", + }); + expect(c.outputs.compliant).toBe(0); + expect(c.outputs.headroom).toBeLessThan(0); + expect(c.formattedOutputs.compliant).toBe("breach"); + }); + }); + + describe("variance: signed formatting both directions", () => { + it("favorable variance shows + sign", () => { + const r = computeVariance({ actual: 4_200_000, budget: 3_700_000 }); + expect(r.outputs.variance).toBe(500_000); + expect(r.formattedOutputs.variance.startsWith("+")).toBe(true); + }); + + it("unfavorable variance shows raw negative without +", () => { + const r = computeVariance({ actual: 3_500_000, budget: 3_700_000 }); + expect(r.outputs.variance).toBe(-200_000); + expect(r.formattedOutputs.variance.startsWith("+")).toBe(false); + }); + }); +}); diff --git a/convex/domains/financialOperator/__tests__/validators.scenario.test.ts b/convex/domains/financialOperator/__tests__/validators.scenario.test.ts new file mode 100644 index 000000000..dff47fcdc --- /dev/null +++ b/convex/domains/financialOperator/__tests__/validators.scenario.test.ts @@ -0,0 +1,171 @@ +/** + * Scenario test for the validator. + * + * Persona: a reviewer who needs to know exactly what failed and why, + * without rerunning the full agent. The validator must: + * - Emit specific findings per failure (no "validation failed") + * - Count what it actually checked (HONEST_SCORES) + * - Surface low-confidence values as warnings, not errors (review, not fail) + * + * Coverage axes (per scenario_testing.md): + * 1. Who: financial reviewer reading a validation card. + * 2. What: confirms required fields, units, sanity ranges, confidence. + * 3. How: feed extraction output → expect exact findings list. + * 4. Scale: handles 100 fields without dropping a finding. + * 5. Duration: idempotent across repeats. + * 6. Failure modes: missing required, wrong unit, out of range, low confidence. + */ + +import { describe, it, expect } from "vitest"; +import { validateExtraction, type FieldSpec } from "../validators"; +import type { ExtractedField } from "../types"; + +const TAX_SPEC: FieldSpec[] = [ + { + fieldName: "Income before income taxes", + expectedUnit: "USD_millions", + required: true, + sanityRange: { min: 0, max: 5_000_000 }, + }, + { + fieldName: "Income tax expense", + expectedUnit: "USD_millions", + required: true, + }, + { + fieldName: "Weighted average debt rate", + expectedUnit: "decimal", + required: true, + sanityRange: { min: 0, max: 0.5 }, + }, +]; + +function field( + overrides: Partial = {}, +): ExtractedField { + return { + fieldName: "Income before income taxes", + value: 22450, + unit: "USD_millions", + sourceRef: "10-K p.72", + confidence: 0.97, + status: "verified", + ...overrides, + }; +} + +describe("validator — scenario coverage", () => { + it("happy path: 3 verified fields → schema passes, 0 findings", () => { + const r = validateExtraction({ + fields: [ + field(), + field({ + fieldName: "Income tax expense", + value: 3785, + }), + field({ + fieldName: "Weighted average debt rate", + value: 0.0542, + unit: "decimal", + }), + ], + spec: TAX_SPEC, + }); + expect(r.schemaPassed).toBe(true); + expect(r.unitsNormalized).toBe(true); + expect(r.findings).toEqual([]); + expect(r.checksPassed).toBe(r.checksRun); + }); + + it("low confidence → warning finding, not schema fail", () => { + const r = validateExtraction({ + fields: [ + field({ confidence: 0.97 }), + field({ fieldName: "Income tax expense", value: 3785 }), + field({ + fieldName: "Weighted average debt rate", + value: 0.0542, + unit: "decimal", + confidence: 0.85, // below 0.9 threshold + }), + ], + spec: TAX_SPEC, + }); + expect(r.schemaPassed).toBe(true); + expect(r.findings.some((f) => f.level === "warning" && f.message.includes("0.85"))).toBe(true); + }); + + it("missing required field → schema fails with specific finding", () => { + const r = validateExtraction({ + fields: [field({ value: null })], + spec: TAX_SPEC, + }); + expect(r.schemaPassed).toBe(false); + const errors = r.findings.filter((f) => f.level === "error"); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toContain("Income before income taxes"); + }); + + it("wrong unit → unitsNormalized false + warning finding", () => { + const r = validateExtraction({ + fields: [ + field({ unit: "USD_thousands" }), + field({ fieldName: "Income tax expense", value: 3785 }), + field({ + fieldName: "Weighted average debt rate", + value: 0.0542, + unit: "decimal", + }), + ], + spec: TAX_SPEC, + }); + expect(r.unitsNormalized).toBe(false); + expect( + r.findings.some( + (f) => f.level === "warning" && f.message.includes("USD_thousands"), + ), + ).toBe(true); + }); + + it("out-of-range debt rate → warning finding, not schema fail", () => { + const r = validateExtraction({ + fields: [ + field(), + field({ fieldName: "Income tax expense", value: 3785 }), + field({ + fieldName: "Weighted average debt rate", + value: 0.85, // out of range (max 0.5) + unit: "decimal", + }), + ], + spec: TAX_SPEC, + }); + expect(r.schemaPassed).toBe(true); + expect( + r.findings.some( + (f) => f.level === "warning" && f.message.includes("outside sanity range"), + ), + ).toBe(true); + }); + + it("scale: 100 fields produces deterministic finding count", () => { + const fields: ExtractedField[] = Array.from({ length: 100 }, (_, i) => + field({ + fieldName: `Income before income taxes`, + confidence: i % 2 === 0 ? 0.95 : 0.5, + }), + ); + const r = validateExtraction({ + fields, + spec: [TAX_SPEC[0]], + }); + // Required check counts for `[0]` once + 100 unit checks + low-conf warnings. + // The deterministic property: same inputs → same totals. + const r2 = validateExtraction({ + fields, + spec: [TAX_SPEC[0]], + }); + expect(r.checksRun).toBe(r2.checksRun); + expect(r.findings.length).toBe(r2.findings.length); + }); +}); diff --git a/convex/domains/financialOperator/attFixture.ts b/convex/domains/financialOperator/attFixture.ts new file mode 100644 index 000000000..1e632ac4d --- /dev/null +++ b/convex/domains/financialOperator/attFixture.ts @@ -0,0 +1,79 @@ +/** + * AT&T 10-K 2024 fixture — pinned values for the operator-console demo. + * + * These numbers stand in for what a real PDF/XBRL extractor would produce. + * They are demo data: stable, citable, and obviously approximate. The + * sandbox compute uses them deterministically — same input, same output. + * + * Replace with live extraction once `document.locate_sections` and + * `finance.extract_tax_and_debt_inputs` are wired to a real PDF reader. + */ + +export interface AttSourceSection { + page: number; + label: string; + matches: string[]; +} + +export const ATT_FIXTURE = { + meta: { + company: "AT&T Inc.", + ticker: "T", + fiscalYear: 2024, + filingType: "10-K", + filingUrl: "https://investors.att.com/financial-reports", // disclosure: not fetched live + }, + + // Locate-sections result + sections: [ + { + page: 72, + label: "Consolidated Statement of Income", + matches: ["income before income taxes", "income tax expense"], + }, + { + page: 118, + label: "Long-Term Debt Footnote", + matches: ["weighted average interest rate"], + }, + ] as AttSourceSection[], + + // Extracted financial values (demo numbers — illustrative only). + // Units: dollars in millions; rates as decimal. + extracted: { + incomeBeforeTaxes: { + value: 22450, + unit: "USD_millions", + sourceRef: "10-K p.72", + confidence: 0.97, + }, + incomeTaxExpense: { + value: 3785, + unit: "USD_millions", + sourceRef: "10-K p.72", + confidence: 0.94, + }, + weightedAverageInterestRate: { + value: 0.0542, + unit: "decimal", + sourceRef: "10-K p.118", + confidence: 0.89, // intentionally below 0.90 → triggers needs_review + }, + }, + + // Source excerpts shown in evidence card + excerpts: [ + { + sourceRef: "10-K p.72", + excerpt: + "Income before income taxes: $22,450M. Income tax expense: $3,785M.", + }, + { + sourceRef: "10-K p.118", + excerpt: + "Weighted average interest rate on long-term debt was approximately 5.42% as of December 31, 2024.", + }, + ], +} as const; + +export type AttFixture = typeof ATT_FIXTURE; diff --git a/convex/domains/financialOperator/extractors.ts b/convex/domains/financialOperator/extractors.ts new file mode 100644 index 000000000..cebcc5d2f --- /dev/null +++ b/convex/domains/financialOperator/extractors.ts @@ -0,0 +1,95 @@ +/** + * Financial extractors — produce typed {fields, sources} from inputs. + * + * Each extractor: + * - Takes raw inputs (or a fixture for the demo) + * - Returns ExtractedField[] with sourceRef + confidence + status + * - Flags low-confidence values as needs_review (HONEST_SCORES) + * + * The extractor itself is pure. The orchestrator action wraps it in a + * tool_call step + an extraction step so the chat renders the work. + * + * NOTE: the extractor signatures are designed so that swapping a fixture + * for a real PDF reader (e.g. a future `extractFromPdfAnalysis(...)`) + * does not change the orchestrator. Only the data source changes. + */ + +import { ATT_FIXTURE } from "./attFixture"; +import type { ExtractedField } from "./types"; + +const REVIEW_THRESHOLD = 0.9; + +function classifyStatus(confidence: number): ExtractedField["status"] { + if (confidence >= REVIEW_THRESHOLD) return "verified"; + if (confidence >= 0.5) return "needs_review"; + return "unresolved"; +} + +/** + * Extract income tax + debt rate inputs for an ETR / after-tax cost of debt + * calculation. Demo wiring: returns AT&T fixture values. + */ +export function extractTaxAndDebtInputs(): { + fields: ExtractedField[]; + sections: Array<{ page: number; label: string }>; +} { + const f = ATT_FIXTURE.extracted; + + const fields: ExtractedField[] = [ + { + fieldName: "Income before income taxes", + value: f.incomeBeforeTaxes.value, + unit: f.incomeBeforeTaxes.unit, + sourceRef: f.incomeBeforeTaxes.sourceRef, + confidence: f.incomeBeforeTaxes.confidence, + status: classifyStatus(f.incomeBeforeTaxes.confidence), + }, + { + fieldName: "Income tax expense", + value: f.incomeTaxExpense.value, + unit: f.incomeTaxExpense.unit, + sourceRef: f.incomeTaxExpense.sourceRef, + confidence: f.incomeTaxExpense.confidence, + status: classifyStatus(f.incomeTaxExpense.confidence), + }, + { + fieldName: "Weighted average debt rate", + value: f.weightedAverageInterestRate.value, + unit: f.weightedAverageInterestRate.unit, + sourceRef: f.weightedAverageInterestRate.sourceRef, + confidence: f.weightedAverageInterestRate.confidence, + status: classifyStatus(f.weightedAverageInterestRate.confidence), + reviewNote: + "Debt-rate footnote requires manual confirmation before final approval.", + }, + ]; + + const sections = ATT_FIXTURE.sections.map((s) => ({ + page: s.page, + label: s.label, + })); + + return { fields, sections }; +} + +/** + * Locate-sections tool result. Demo: returns AT&T fixture sections. + * Real impl would call a PDF section finder (heading detection + grep). + */ +export function locateSectionsForTaxAndDebt() { + return ATT_FIXTURE.sections.slice(); +} + +/** + * Evidence anchors — short excerpts paired with source refs for the + * evidence card. Demo: returns AT&T fixture excerpts. + */ +export function gatherEvidenceForTaxAndDebt() { + return ATT_FIXTURE.excerpts.map((e) => ({ + label: ATT_FIXTURE.sections.find((s) => + e.sourceRef.includes(`p.${s.page}`), + )?.label ?? "Source", + sourceRef: e.sourceRef, + excerpt: e.excerpt, + })); +} diff --git a/convex/domains/financialOperator/index.ts b/convex/domains/financialOperator/index.ts new file mode 100644 index 000000000..3a6ee2153 --- /dev/null +++ b/convex/domains/financialOperator/index.ts @@ -0,0 +1,12 @@ +/** + * Financial Operator Console — domain index. + * + * Public surface: + * - Mutations + queries: `runOps` + * - Orchestrator action: `orchestrator.runAttCostOfDebtDemo` + * + * The frontend imports them via `api.domains.financialOperator..`. + */ + +export * as runOps from "./runOps"; +export * as orchestrator from "./orchestrator"; diff --git a/convex/domains/financialOperator/orchestrator.ts b/convex/domains/financialOperator/orchestrator.ts new file mode 100644 index 000000000..88464f49e --- /dev/null +++ b/convex/domains/financialOperator/orchestrator.ts @@ -0,0 +1,458 @@ +/** + * Financial Operator Console — orchestrator. + * + * Runs the full chat sequence for a financial workflow: + * create run → run_brief → locate sources → extract → validate → + * compute (sandbox) → evidence → artifact → approval / result. + * + * Each transition is one mutation, so the live query in the UI streams + * the steps in as they land. Math runs in the deterministic JS sandbox + * (NOT in the LLM). Sources, confidence, and validation findings are + * surfaced verbatim — no hidden reasoning, no fake VERIFIED labels. + * + * Pattern: orchestrator-workers (Anthropic — Building Effective Agents). + * Orchestrator: this action. + * Workers: extractors.ts (data acquisition), sandbox.ts (compute), + * validators.ts (schema/unit/range checks). + */ + +import { v } from "convex/values"; +import { action } from "../../_generated/server"; +import { api } from "../../_generated/api"; +import type { Id } from "../../_generated/dataModel"; + +import { + extractTaxAndDebtInputs, + locateSectionsForTaxAndDebt, + gatherEvidenceForTaxAndDebt, +} from "./extractors"; +import { validateExtraction, type FieldSpec } from "./validators"; +import { + computeETR, + computeAfterTaxCostOfDebt, +} from "./sandbox"; +import type { + ApprovalRequestPayload, + ArtifactPayload, + CalculationPayload, + EvidencePayload, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + ToolCallPayload, + ValidationPayload, +} from "./types"; + +const STEP_PACING_MS = 350; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const TAX_AND_DEBT_SPEC: FieldSpec[] = [ + { + fieldName: "Income before income taxes", + expectedUnit: "USD_millions", + required: true, + sanityRange: { min: 0, max: 5_000_000 }, + }, + { + fieldName: "Income tax expense", + expectedUnit: "USD_millions", + required: true, + sanityRange: { min: 0, max: 1_000_000 }, + }, + { + fieldName: "Weighted average debt rate", + expectedUnit: "decimal", + required: true, + sanityRange: { min: 0, max: 0.5 }, + }, +]; + +/** + * Demo orchestrator: runs the full AT&T 10-K → ETR + after-tax cost of debt + * sequence. Returns the runId so the UI can subscribe to its step stream. + * + * For the MVP this runs end-to-end with the bundled fixture; future revs + * can point at a real PDF reader by swapping `extractTaxAndDebtInputs` + * for an action that reads from the documents domain. + */ +export const runAttCostOfDebtDemo = action({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ runId: Id<"financialOperatorRuns"> }> => { + const goal = + "Calculate AT&T 2024 effective tax rate and after-tax cost of debt with traceable sources."; + + // 1. Create the run + const runId: Id<"financialOperatorRuns"> = await ctx.runMutation( + api.domains.financialOperator.runOps.createRun, + { + userId: args.userId, + threadId: args.threadId, + taskType: "financial_metric_extraction", + goal, + files: [ + { name: "att_10k_2024.pdf", kind: "pdf" }, + { name: "income_statement.png", kind: "image" }, + ], + totalSteps: 8, + }, + ); + + // 2. Plan + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "planning", + }); + const briefPayload: RunBriefPayload = { + goal, + numberedSteps: [ + "Locate the relevant filing sections", + "Extract required financial values into a schema", + "Validate the inputs (schema, units, sanity range)", + "Run the calculation in a deterministic sandbox", + "Verify sources and prepare a reviewable artifact", + ], + estimatedDurationMs: 6000, + outputFormat: "Notebook + source-backed calculation", + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "run_brief", + status: "complete", + title: "Plan", + payload: briefPayload, + }); + + // 3. Run + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "running", + }); + + // 3a. Locate sections (tool_call card) + await sleep(STEP_PACING_MS); + const locateStart = Date.now(); + const sections = locateSectionsForTaxAndDebt(); + const locatePayload: ToolCallPayload = { + toolName: "document.locate_sections", + inputSummary: + "AT&T 10-K, target terms: income before taxes, income tax expense, weighted average debt rate", + outputSummary: sections + .map((s) => `${s.label}: page ${s.page}`) + .join("; "), + rawArgs: { + docId: "att_10k_2024.pdf", + targets: [ + "income before income taxes", + "income tax expense", + "weighted average interest rate", + ], + }, + rawResult: { sections }, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Locate filing sections", + payload: locatePayload, + durationMs: Date.now() - locateStart, + }); + + // 3b. Extract values (tool_call + extraction cards) + await sleep(STEP_PACING_MS); + const extractStart = Date.now(); + const { fields } = extractTaxAndDebtInputs(); + const extractToolPayload: ToolCallPayload = { + toolName: "finance.extract_tax_and_debt_inputs", + inputSummary: "Read sections p.72 (income statement) + p.118 (debt footnote)", + outputSummary: `${fields.length} fields extracted, ${fields.filter((f) => f.status === "needs_review").length} flagged for review`, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Extract financial inputs", + payload: extractToolPayload, + durationMs: Date.now() - extractStart, + }); + + await sleep(STEP_PACING_MS); + const extractionPayload: ExtractionPayload = { + schemaName: "tax_and_debt_inputs", + fields, + totalFound: fields.length, + needsReviewCount: fields.filter((f) => f.status === "needs_review").length, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: extractionPayload.needsReviewCount > 0 ? "needs_review" : "complete", + title: "Extracted values", + payload: extractionPayload, + }); + + // 3c. Validate + await sleep(STEP_PACING_MS); + const validation = validateExtraction({ + fields, + spec: TAX_AND_DEBT_SPEC, + }); + const validationPayload: ValidationPayload = { + schemaPassed: validation.schemaPassed, + unitsNormalized: validation.unitsNormalized, + findings: validation.findings, + checksRun: validation.checksRun, + checksPassed: validation.checksPassed, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "validation", + status: validation.schemaPassed ? "complete" : "error", + title: "Validate inputs", + payload: validationPayload, + }); + + // 3d. Compute (deterministic sandbox) + await sleep(STEP_PACING_MS); + const ibt = (fields.find((f) => f.fieldName === "Income before income taxes") + ?.value ?? 0) as number; + const ite = (fields.find((f) => f.fieldName === "Income tax expense") + ?.value ?? 0) as number; + const debtRate = (fields.find((f) => f.fieldName === "Weighted average debt rate") + ?.value ?? 0) as number; + + const etrResult = computeETR({ + incomeBeforeTaxes: ibt, + incomeTaxExpense: ite, + }); + const afterTaxResult = computeAfterTaxCostOfDebt({ + preTaxDebtRate: debtRate, + effectiveTaxRate: etrResult.outputs.etr, + }); + + const calcPayload: CalculationPayload = { + formulaLabel: "Effective tax rate + after-tax cost of debt", + formulaText: [etrResult.formulaText, afterTaxResult.formulaText].join("\n"), + inputs: { + incomeBeforeTaxes: ibt, + incomeTaxExpense: ite, + preTaxDebtRate: debtRate, + }, + outputs: { + effectiveTaxRate: etrResult.outputs.etr, + afterTaxCostOfDebt: afterTaxResult.outputs.afterTaxCostOfDebt, + }, + formattedOutputs: { + effectiveTaxRate: etrResult.formattedOutputs.etr, + afterTaxCostOfDebt: afterTaxResult.formattedOutputs.afterTaxCostOfDebt, + }, + sandboxKind: "js_pure", + computedAt: Date.now(), + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "calculation", + status: "complete", + title: "Sandbox calculation", + payload: calcPayload, + }); + + // 3e. Evidence + await sleep(STEP_PACING_MS); + const anchors = gatherEvidenceForTaxAndDebt(); + const evidencePayload: EvidencePayload = { + anchors, + totalSources: anchors.length, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "evidence", + status: "complete", + title: "Source anchors", + payload: evidencePayload, + }); + + // 3f. Artifact (notebook + PR draft) + await sleep(STEP_PACING_MS); + const artifactPayload: ArtifactPayload = { + kind: "notebook", + label: "AT&T 2024 After-Tax Cost of Debt", + description: + "Notebook with extracted inputs, sandbox calculation, source table, and reviewer notes.", + diffSummary: [ + "Added financial report markdown", + "Added deterministic calculation script", + "Added source table", + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "artifact", + status: "complete", + title: "Notebook artifact", + payload: artifactPayload, + }); + + // 3g. Approval gate (one of the fields needs review → ask user) + await sleep(STEP_PACING_MS); + const needsReviewFieldNames = fields + .filter((f) => f.status === "needs_review") + .map((f) => f.fieldName); + + if (needsReviewFieldNames.length > 0) { + const approvalPayload: ApprovalRequestPayload = { + question: `Approve calculation despite ${needsReviewFieldNames.length} low-confidence input?`, + context: `Flagged: ${needsReviewFieldNames.join(", ")}. Calculation runs deterministically; approval records who signed off and locks the artifact.`, + options: [ + { id: "approve", label: "Approve calculation", description: "Lock the notebook and mark this run verified." }, + { id: "narrow", label: "Re-extract debt footnote", description: "Run a tighter extractor over p.118 only." }, + { id: "override", label: "Override with manual value", description: "Replace 5.42% with a user-entered figure." }, + { id: "reject", label: "Reject", description: "Mark this run as failed; no artifact saved." }, + ], + consequences: { + approve: "Run status → completed, artifact locked.", + narrow: "New tool_call step appended; calc re-runs with refined value.", + override: "Field value replaced; calc re-runs.", + reject: "Run status → rejected; artifact discarded.", + }, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "approval_request", + status: "pending", + title: "Reviewer approval needed", + payload: approvalPayload, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "awaiting_approval", + }); + } else { + // Happy path — no review flags, finalize directly. + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "result", + status: "complete", + title: "Result", + payload: buildResultPayload(calcPayload), + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "completed", + finalSummary: buildResultPayload(calcPayload).headline, + }); + } + + return { runId }; + }, +}); + +/** + * Decision-recorder: applied when the user clicks an approval button. + * Adds a result/follow-up step and locks the run. + */ +export const recordApprovalDecision = action({ + args: { + runId: v.id("financialOperatorRuns"), + stepId: v.id("financialOperatorSteps"), + optionId: v.union( + v.literal("approve"), + v.literal("reject"), + v.literal("override"), + v.literal("narrow"), + v.literal("rerun"), + ), + }, + handler: async (ctx, args) => { + const decisionStatus = + args.optionId === "approve" + ? "approved" + : args.optionId === "reject" + ? "rejected" + : "complete"; + await ctx.runMutation(api.domains.financialOperator.runOps.updateStepStatus, { + stepId: args.stepId, + status: decisionStatus, + payloadPatch: { selectedOptionId: args.optionId }, + }); + + // Find the calculation step to build a result summary. + const steps = await ctx.runQuery( + api.domains.financialOperator.runOps.listSteps, + { runId: args.runId }, + ); + const calcStep = steps.find((s) => s.kind === "calculation"); + const calcPayload = (calcStep?.payload ?? null) as CalculationPayload | null; + + if (args.optionId === "approve" && calcPayload) { + const result = buildResultPayload(calcPayload); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId: args.runId, + kind: "result", + status: "complete", + title: "Result (approved)", + payload: result, + }); + await ctx.runMutation( + api.domains.financialOperator.runOps.updateRunStatus, + { + runId: args.runId, + status: "completed", + finalSummary: result.headline, + artifacts: [ + { + kind: "notebook", + label: "AT&T 2024 After-Tax Cost of Debt", + }, + ], + }, + ); + } else if (args.optionId === "reject") { + await ctx.runMutation( + api.domains.financialOperator.runOps.updateRunStatus, + { + runId: args.runId, + status: "rejected", + finalSummary: "Reviewer rejected — artifact not saved.", + }, + ); + } else { + // narrow / override / rerun → in this MVP we just record the decision. + // A real impl would dispatch a follow-up extractor here. + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId: args.runId, + kind: "tool_call", + status: "pending", + title: `Follow-up requested: ${args.optionId}`, + payload: { + toolName: `finance.${args.optionId}_followup`, + inputSummary: "Queued — implementation pending in this MVP.", + } satisfies ToolCallPayload, + }); + } + }, +}); + +function buildResultPayload(calc: CalculationPayload): ResultPayload { + return { + headline: `AT&T 2024 effective tax rate: ${calc.formattedOutputs?.effectiveTaxRate ?? "n/a"}; after-tax cost of debt: ${calc.formattedOutputs?.afterTaxCostOfDebt ?? "n/a"}.`, + prose: + "Implied 2024 effective tax rate is computed from income before taxes and income tax expense on the consolidated statement of income. After-tax cost of debt uses the weighted average interest rate from the long-term debt footnote and the computed ETR. All math executed deterministically in a JS sandbox.", + metrics: { + "Effective tax rate": String(calc.formattedOutputs?.effectiveTaxRate ?? ""), + "After-tax cost of debt": String( + calc.formattedOutputs?.afterTaxCostOfDebt ?? "", + ), + }, + openIssues: ["Debt-footnote source confidence is below threshold; reviewer note attached."], + nextActions: [ + { id: "open_notebook", label: "Open notebook", kind: "open" }, + { id: "view_sources", label: "View sources", kind: "open" }, + { id: "export_csv", label: "Export CSV", kind: "export" }, + { id: "ask_followup", label: "Ask follow-up", kind: "follow_up" }, + ], + }; +} diff --git a/convex/domains/financialOperator/runOps.ts b/convex/domains/financialOperator/runOps.ts new file mode 100644 index 000000000..c19d3da97 --- /dev/null +++ b/convex/domains/financialOperator/runOps.ts @@ -0,0 +1,260 @@ +/** + * Financial Operator Console — runs + steps CRUD. + * + * Run lifecycle: created → planning → running → (awaiting_approval | completed | error) + * Each run has an append-only stream of typed steps that the chat renders + * as cards (run_brief, tool_call, extraction, validation, calculation, + * evidence, artifact, approval_request, result). + * + * Pattern: scratchpad-first + orchestrator-workers (per .claude/rules). + * Append-only — never mutate prior steps; new state = new step. + * + * Reliability: + * - BOUND: per-run step count capped at MAX_STEPS_PER_RUN + * - HONEST_STATUS: errors set status="error" with errorMessage; never fake success + * - DETERMINISTIC: seq monotonic per run via nextSeq counter on the run row + */ + +import { v } from "convex/values"; +import { mutation, query } from "../../_generated/server"; +import type { Id } from "../../_generated/dataModel"; + +// BOUND — protects DB + UI from runaway agents. +const MAX_STEPS_PER_RUN = 200; + +const TASK_TYPE_VALIDATOR = v.union( + v.literal("financial_metric_extraction"), + v.literal("financial_data_cleanup"), + v.literal("covenant_compliance"), + v.literal("variance_analysis"), + v.literal("custom"), +); + +const RUN_STATUS_VALIDATOR = v.union( + v.literal("created"), + v.literal("planning"), + v.literal("running"), + v.literal("awaiting_approval"), + v.literal("completed"), + v.literal("rejected"), + v.literal("error"), +); + +const STEP_KIND_VALIDATOR = v.union( + v.literal("run_brief"), + v.literal("tool_call"), + v.literal("extraction"), + v.literal("validation"), + v.literal("calculation"), + v.literal("evidence"), + v.literal("artifact"), + v.literal("approval_request"), + v.literal("result"), +); + +const STEP_STATUS_VALIDATOR = v.union( + v.literal("pending"), + v.literal("running"), + v.literal("complete"), + v.literal("error"), + v.literal("needs_review"), + v.literal("approved"), + v.literal("rejected"), +); + +/* ------------------------------------------------------------------ */ +/* MUTATIONS */ +/* ------------------------------------------------------------------ */ + +export const createRun = mutation({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + taskType: TASK_TYPE_VALIDATOR, + goal: v.string(), + files: v.optional( + v.array( + v.object({ + name: v.string(), + kind: v.string(), + sizeBytes: v.optional(v.number()), + }), + ), + ), + totalSteps: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const runId = await ctx.db.insert("financialOperatorRuns", { + userId: args.userId, + threadId: args.threadId, + taskType: args.taskType, + goal: args.goal, + files: args.files, + status: "created", + totalSteps: args.totalSteps, + nextSeq: 0, + createdAt: now, + updatedAt: now, + }); + return runId; + }, +}); + +export const updateRunStatus = mutation({ + args: { + runId: v.id("financialOperatorRuns"), + status: RUN_STATUS_VALIDATOR, + finalSummary: v.optional(v.string()), + errorMessage: v.optional(v.string()), + artifacts: v.optional( + v.array( + v.object({ + kind: v.string(), + label: v.string(), + artifactRef: v.optional(v.string()), + url: v.optional(v.string()), + }), + ), + ), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run) { + throw new Error(`Run not found: ${args.runId}`); + } + const updates: Record = { + status: args.status, + updatedAt: Date.now(), + }; + if (args.finalSummary !== undefined) updates.finalSummary = args.finalSummary; + if (args.errorMessage !== undefined) updates.errorMessage = args.errorMessage; + if (args.artifacts !== undefined) updates.artifacts = args.artifacts; + await ctx.db.patch(args.runId, updates); + }, +}); + +/** + * Append a typed step to a run. Bounded by MAX_STEPS_PER_RUN. + * `seq` is assigned server-side from the run's `nextSeq` counter. + */ +export const appendStep = mutation({ + args: { + runId: v.id("financialOperatorRuns"), + kind: STEP_KIND_VALIDATOR, + status: STEP_STATUS_VALIDATOR, + title: v.string(), + payload: v.optional(v.any()), + durationMs: v.optional(v.number()), + errorMessage: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const run = await ctx.db.get(args.runId); + if (!run) { + throw new Error(`Run not found: ${args.runId}`); + } + const currentSeq = run.nextSeq ?? 0; + if (currentSeq >= MAX_STEPS_PER_RUN) { + // BOUND: refuse rather than degrade silently. HONEST_STATUS. + throw new Error( + `Run ${args.runId} exceeded ${MAX_STEPS_PER_RUN} steps (cap)`, + ); + } + const now = Date.now(); + const stepId = await ctx.db.insert("financialOperatorSteps", { + runId: args.runId, + seq: currentSeq, + kind: args.kind, + status: args.status, + title: args.title, + payload: args.payload, + durationMs: args.durationMs, + errorMessage: args.errorMessage, + createdAt: now, + updatedAt: now, + }); + await ctx.db.patch(args.runId, { + nextSeq: currentSeq + 1, + updatedAt: now, + }); + return { stepId, seq: currentSeq }; + }, +}); + +/** + * Patch an existing step's status (e.g. running → complete, or → approved). + * Used to flip an approval_request step after the user clicks a button. + */ +export const updateStepStatus = mutation({ + args: { + stepId: v.id("financialOperatorSteps"), + status: STEP_STATUS_VALIDATOR, + payloadPatch: v.optional(v.any()), + durationMs: v.optional(v.number()), + errorMessage: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const step = await ctx.db.get(args.stepId); + if (!step) { + throw new Error(`Step not found: ${args.stepId}`); + } + const updates: Record = { + status: args.status, + updatedAt: Date.now(), + }; + if (args.durationMs !== undefined) updates.durationMs = args.durationMs; + if (args.errorMessage !== undefined) { + updates.errorMessage = args.errorMessage; + } + if (args.payloadPatch !== undefined) { + // Shallow merge — caller is responsible for shape. + const current = (step.payload as Record | undefined) ?? {}; + updates.payload = { ...current, ...(args.payloadPatch as object) }; + } + await ctx.db.patch(args.stepId, updates); + }, +}); + +/* ------------------------------------------------------------------ */ +/* QUERIES */ +/* ------------------------------------------------------------------ */ + +export const getRun = query({ + args: { runId: v.id("financialOperatorRuns") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.runId); + }, +}); + +export const listSteps = query({ + args: { runId: v.id("financialOperatorRuns") }, + handler: async (ctx, args) => { + return await ctx.db + .query("financialOperatorSteps") + .withIndex("by_run", (q) => q.eq("runId", args.runId)) + .collect(); + }, +}); + +export const listRecentRuns = query({ + args: { + userId: v.optional(v.id("users")), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const cap = Math.min(args.limit ?? 20, 100); // BOUND + if (args.userId) { + return await ctx.db + .query("financialOperatorRuns") + .withIndex("by_user_createdAt", (q) => + q.eq("userId", args.userId as Id<"users">), + ) + .order("desc") + .take(cap); + } + return await ctx.db + .query("financialOperatorRuns") + .order("desc") + .take(cap); + }, +}); diff --git a/convex/domains/financialOperator/sandbox.ts b/convex/domains/financialOperator/sandbox.ts new file mode 100644 index 000000000..6cd442e16 --- /dev/null +++ b/convex/domains/financialOperator/sandbox.ts @@ -0,0 +1,208 @@ +/** + * Deterministic JS sandbox for financial computations. + * + * RULE: this module is the ONLY place math runs in the operator console. + * The agent (LLM) MUST NOT compute ratios, percentages, or rollups in + * its prompt — it gets the values from extractors, calls one of these + * pure functions, and surfaces the result. + * + * Pattern: scratchpad-first + agent-as-orchestrator. The LLM is the + * conductor, not the calculator. (Per .claude/rules/scratchpad_first.md + * and Anthropic "Building Effective Agents".) + * + * All functions: + * - Pure (no I/O, no clock) + * - Throw typed errors on invalid input (no silent NaN) + * - Return both raw numbers and pre-formatted display strings + */ + +export type SandboxKind = "js_pure"; + +export interface SandboxResult> { + inputs: Record; + outputs: T; + formattedOutputs: Record; + formulaText: string; + sandboxKind: SandboxKind; +} + +class FinancialSandboxError extends Error { + readonly code: string; + constructor(code: string, message: string) { + // Prefix code so callers can pattern-match on it in error.message. + super(`[${code}] ${message}`); + this.code = code; + this.name = "FinancialSandboxError"; + } +} + +function requireFinite(name: string, n: number): number { + if (!Number.isFinite(n)) { + throw new FinancialSandboxError("NOT_FINITE", `${name} is not finite`); + } + return n; +} + +function requirePositive(name: string, n: number): number { + if (n <= 0) { + throw new FinancialSandboxError( + "NOT_POSITIVE", + `${name} must be > 0, got ${n}`, + ); + } + return n; +} + +function pct(n: number): string { + return `${(n * 100).toFixed(2)}%`; +} + +function ratio(n: number): string { + return `${n.toFixed(2)}x`; +} + +function usdMillions(n: number): string { + return `$${(n / 1_000_000).toFixed(1)}M`; +} + +/** + * Effective tax rate = income tax expense / income before taxes + * + * Inputs in same currency unit (typically USD millions). + * Returns ETR as decimal (0..1). + */ +export function computeETR(args: { + incomeBeforeTaxes: number; + incomeTaxExpense: number; +}): SandboxResult<{ etr: number }> { + const ibt = requireFinite("incomeBeforeTaxes", args.incomeBeforeTaxes); + const ite = requireFinite("incomeTaxExpense", args.incomeTaxExpense); + requirePositive("incomeBeforeTaxes", ibt); + + const etr = ite / ibt; + + return { + inputs: { incomeBeforeTaxes: ibt, incomeTaxExpense: ite }, + outputs: { etr }, + formattedOutputs: { etr: pct(etr) }, + formulaText: "etr = income_tax_expense / income_before_taxes", + sandboxKind: "js_pure", + }; +} + +/** + * After-tax cost of debt = pre-tax debt rate × (1 − ETR) + * + * Pre-tax rate as decimal (0.0542 for 5.42%). + */ +export function computeAfterTaxCostOfDebt(args: { + preTaxDebtRate: number; + effectiveTaxRate: number; +}): SandboxResult<{ afterTaxCostOfDebt: number }> { + const r = requireFinite("preTaxDebtRate", args.preTaxDebtRate); + const etr = requireFinite("effectiveTaxRate", args.effectiveTaxRate); + if (etr < 0 || etr > 1) { + throw new FinancialSandboxError( + "ETR_OUT_OF_RANGE", + `effectiveTaxRate must be in [0,1], got ${etr}`, + ); + } + const afterTax = r * (1 - etr); + + return { + inputs: { preTaxDebtRate: r, effectiveTaxRate: etr }, + outputs: { afterTaxCostOfDebt: afterTax }, + formattedOutputs: { afterTaxCostOfDebt: pct(afterTax) }, + formulaText: + "after_tax_cost_of_debt = pre_tax_debt_rate * (1 - effective_tax_rate)", + sandboxKind: "js_pure", + }; +} + +/** + * Net leverage ratio = (total debt − cash) / EBITDA + * + * All inputs in same currency unit. + */ +export function computeLeverageRatio(args: { + totalDebt: number; + cash: number; + ebitda: number; +}): SandboxResult<{ netDebt: number; ratio: number }> { + const d = requireFinite("totalDebt", args.totalDebt); + const c = requireFinite("cash", args.cash); + const e = requireFinite("ebitda", args.ebitda); + requirePositive("ebitda", e); + + const netDebt = d - c; + const r = netDebt / e; + + return { + inputs: { totalDebt: d, cash: c, ebitda: e }, + outputs: { netDebt, ratio: r }, + formattedOutputs: { + netDebt: usdMillions(netDebt), + ratio: ratio(r), + }, + formulaText: + "net_debt = total_debt - cash; leverage = net_debt / ebitda", + sandboxKind: "js_pure", + }; +} + +/** + * Variance = actual - budget; variancePct = variance / budget. + * + * Returns both raw values and signed-percent for display. + */ +export function computeVariance(args: { + actual: number; + budget: number; +}): SandboxResult<{ variance: number; variancePct: number }> { + const a = requireFinite("actual", args.actual); + const b = requireFinite("budget", args.budget); + if (b === 0) { + throw new FinancialSandboxError( + "BUDGET_ZERO", + "Cannot compute variance pct with zero budget", + ); + } + const variance = a - b; + const variancePct = variance / b; + const sign = variance >= 0 ? "+" : ""; + return { + inputs: { actual: a, budget: b }, + outputs: { variance, variancePct }, + formattedOutputs: { + variance: `${sign}${usdMillions(variance)}`, + variancePct: `${sign}${pct(variancePct)}`, + }, + formulaText: "variance = actual - budget; variance_pct = variance / budget", + sandboxKind: "js_pure", + }; +} + +/** + * Compliance check: ratio ≤ threshold. + * Returns the boolean and the "headroom" (threshold - ratio). + */ +export function checkCompliance(args: { + observedRatio: number; + threshold: number; + ratioName: string; +}): SandboxResult<{ headroom: number; compliant: 0 | 1 }> { + const r = requireFinite("observedRatio", args.observedRatio); + const t = requireFinite("threshold", args.threshold); + const compliant: 0 | 1 = r <= t ? 1 : 0; + const headroom = t - r; + return { + inputs: { observedRatio: r, threshold: t }, + outputs: { headroom, compliant }, + formattedOutputs: { + headroom: ratio(headroom), + compliant: compliant === 1 ? "compliant" : "breach", + }, + formulaText: `compliant = ${args.ratioName} <= threshold`, + sandboxKind: "js_pure", + }; +} diff --git a/convex/domains/financialOperator/types.ts b/convex/domains/financialOperator/types.ts new file mode 100644 index 000000000..ea12cd87f --- /dev/null +++ b/convex/domains/financialOperator/types.ts @@ -0,0 +1,175 @@ +/** + * Financial Operator Console — typed step payloads. + * + * Each step's `payload` field is stored as v.any() in Convex but its + * shape per `kind` is enforced by these TS types. The frontend renders + * one card per kind by switching on `step.kind`. + * + * Pattern: scratchpad-first + orchestrator-workers (per .claude/rules) + * - Each step is one observable unit of work + * - Each step lists its sources / inputs / outputs + * - Math runs in a deterministic JS sandbox (NOT in the LLM) + * + * Trust boundary: extractors return values + confidence + sourceRef. + * The chat surface MUST display the source for every value. + */ + +export type StepKind = + | "run_brief" + | "tool_call" + | "extraction" + | "validation" + | "calculation" + | "evidence" + | "artifact" + | "approval_request" + | "result"; + +export type StepStatus = + | "pending" + | "running" + | "complete" + | "error" + | "needs_review" + | "approved" + | "rejected"; + +export type RunStatus = + | "created" + | "planning" + | "running" + | "awaiting_approval" + | "completed" + | "rejected" + | "error"; + +export type TaskType = + | "financial_metric_extraction" + | "financial_data_cleanup" + | "covenant_compliance" + | "variance_analysis" + | "custom"; + +/** Initial plan card. Lists numbered steps and offers run/cancel buttons. */ +export interface RunBriefPayload { + goal: string; + numberedSteps: string[]; // 4-6 short steps, plain language + estimatedDurationMs?: number; + outputFormat: string; // e.g. "Notebook + CSV + PR" +} + +/** Generic tool invocation. Shows input/output summaries, not raw blobs. */ +export interface ToolCallPayload { + toolName: string; // e.g. "document.locate_sections" + inputSummary: string; // human-readable input + outputSummary?: string; // human-readable output + rawArgs?: unknown; // optional drill-down (kept small; bounded) + rawResult?: unknown; // optional drill-down (kept small; bounded) +} + +/** Single extracted field with provenance + confidence. */ +export interface ExtractedField { + fieldName: string; // "Income before taxes" + value: number | string | null; + unit?: string; // "USD millions", "decimal", "percent" + sourceRef: string; // "10-K p.72" or "Balance Sheet!B21" + confidence: number; // 0..1 + status: "verified" | "needs_review" | "unresolved"; + reviewNote?: string; +} + +export interface ExtractionPayload { + schemaName: string; // "tax_and_debt_inputs" + fields: ExtractedField[]; + totalFound: number; + needsReviewCount: number; +} + +export interface ValidationFinding { + level: "info" | "warning" | "error"; + message: string; + fieldRef?: string; +} + +export interface ValidationPayload { + schemaPassed: boolean; + unitsNormalized: boolean; + findings: ValidationFinding[]; + // Bounded (HONEST_SCORES): only count what was actually checked. + checksRun: number; + checksPassed: number; +} + +/** + * Calculation card payload. + * IMPORTANT: this represents work done in a deterministic JS sandbox, + * NOT LLM math. The frontend MUST render a "Math executed in sandbox, + * not by the language model." disclosure on this card. + */ +export interface CalculationPayload { + formulaLabel: string; // "Effective tax rate" + formulaText: string; // "income_tax_expense / income_before_taxes" + inputs: Record; + outputs: Record; + sandboxKind: "js_pure"; // future: "python" | "wasm" | "convex_action" + computedAt: number; // epoch ms + formattedOutputs?: Record; // for display: "16.86%" +} + +export interface EvidenceAnchor { + label: string; + sourceRef: string; // "10-K p.72" + excerpt?: string; // short quoted snippet + url?: string; +} + +export interface EvidencePayload { + anchors: EvidenceAnchor[]; + totalSources: number; +} + +export type ArtifactKind = "notebook" | "csv" | "pr_draft" | "memo" | "report"; + +export interface ArtifactPayload { + kind: ArtifactKind; + label: string; + description?: string; + artifactRef?: string; // foreign key (documentId, fileId, etc) + url?: string; + diffSummary?: string[]; // for PR drafts: list of changes +} + +export interface ApprovalRequestPayload { + question: string; + context?: string; + options: Array<{ + id: "approve" | "reject" | "override" | "rerun" | "narrow"; + label: string; + description?: string; + }>; + // What the agent will do on each option (transparency). + consequences?: Record; +} + +export interface ResultPayload { + headline: string; // 1-line bottom-line answer + prose: string; // 2-4 sentences of detail + metrics?: Record; // formatted final values + openIssues?: string[]; // remaining caveats + nextActions: Array<{ + id: string; + label: string; + kind: "open" | "approve" | "export" | "follow_up"; + }>; +} + +export type StepPayload = + | { kind: "run_brief"; data: RunBriefPayload } + | { kind: "tool_call"; data: ToolCallPayload } + | { kind: "extraction"; data: ExtractionPayload } + | { kind: "validation"; data: ValidationPayload } + | { kind: "calculation"; data: CalculationPayload } + | { kind: "evidence"; data: EvidencePayload } + | { kind: "artifact"; data: ArtifactPayload } + | { kind: "approval_request"; data: ApprovalRequestPayload } + | { kind: "result"; data: ResultPayload }; diff --git a/convex/domains/financialOperator/validators.ts b/convex/domains/financialOperator/validators.ts new file mode 100644 index 000000000..d548bb731 --- /dev/null +++ b/convex/domains/financialOperator/validators.ts @@ -0,0 +1,110 @@ +/** + * Schema + unit validators for extracted financial inputs. + * + * Pattern: HONEST_SCORES (per .claude/rules/agentic_reliability.md). + * Each validator counts what it actually checked. No hardcoded floors. + * Findings are surfaced verbatim to the user, never swallowed. + */ + +import type { ExtractedField, ValidationFinding } from "./types"; + +export interface ValidationResult { + schemaPassed: boolean; + unitsNormalized: boolean; + findings: ValidationFinding[]; + checksRun: number; + checksPassed: number; +} + +export interface FieldSpec { + fieldName: string; + expectedUnit: string; // "USD_millions" | "decimal" | "percent" + required: boolean; + // Optional sanity range — if set and value is outside, emit warning. + sanityRange?: { min: number; max: number }; +} + +const CONFIDENCE_REVIEW_THRESHOLD = 0.9; + +export function validateExtraction(args: { + fields: ExtractedField[]; + spec: FieldSpec[]; +}): ValidationResult { + const findings: ValidationFinding[] = []; + let checksRun = 0; + let checksPassed = 0; + let schemaPassed = true; + let unitsNormalized = true; + + // 1. Required-field check + for (const required of args.spec.filter((s) => s.required)) { + checksRun++; + const found = args.fields.find((f) => f.fieldName === required.fieldName); + if (!found || found.value === null || found.value === undefined) { + findings.push({ + level: "error", + message: `Required field "${required.fieldName}" missing`, + fieldRef: required.fieldName, + }); + schemaPassed = false; + } else { + checksPassed++; + } + } + + // 2. Unit-match check + for (const field of args.fields) { + const spec = args.spec.find((s) => s.fieldName === field.fieldName); + if (!spec) continue; + checksRun++; + if (field.unit && field.unit !== spec.expectedUnit) { + findings.push({ + level: "warning", + message: `Field "${field.fieldName}" has unit "${field.unit}", expected "${spec.expectedUnit}"`, + fieldRef: field.fieldName, + }); + unitsNormalized = false; + } else { + checksPassed++; + } + } + + // 3. Sanity-range check (optional) + for (const field of args.fields) { + const spec = args.spec.find((s) => s.fieldName === field.fieldName); + if (!spec || !spec.sanityRange) continue; + if (typeof field.value !== "number") continue; + checksRun++; + if ( + field.value < spec.sanityRange.min || + field.value > spec.sanityRange.max + ) { + findings.push({ + level: "warning", + message: `Field "${field.fieldName}" value ${field.value} outside sanity range [${spec.sanityRange.min}, ${spec.sanityRange.max}]`, + fieldRef: field.fieldName, + }); + } else { + checksPassed++; + } + } + + // 4. Confidence review-flag (informational, NOT a fail) + for (const field of args.fields) { + if (field.confidence < CONFIDENCE_REVIEW_THRESHOLD) { + findings.push({ + level: "warning", + message: `Field "${field.fieldName}" source confidence ${field.confidence.toFixed(2)} below ${CONFIDENCE_REVIEW_THRESHOLD}. Human review recommended.`, + fieldRef: field.fieldName, + }); + } + } + + return { + schemaPassed, + unitsNormalized, + findings, + checksRun, + checksPassed, + }; +} diff --git a/convex/domains/product/schema.ts b/convex/domains/product/schema.ts index fd57f974b..5a90dc11a 100644 --- a/convex/domains/product/schema.ts +++ b/convex/domains/product/schema.ts @@ -1131,6 +1131,8 @@ export const productEventWorkspaces = defineTable({ reportId: v.optional(v.id("productReports")), defaultTabs: v.array(productEventWorkspaceTabValidator), source: productEventWorkspaceSourceValidator, + // Optional — pre-existing data drift; tracked here so schema validates. + activeEventSessionId: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) diff --git a/convex/schema.ts b/convex/schema.ts index 600b1cc9c..f4ec59a7b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1091,6 +1091,91 @@ const agentRunEvents = defineTable({ createdAt: v.number(), }).index("by_run", ["runId", "seq"]); +/* ------------------------------------------------------------------ */ +/* FINANCIAL OPERATOR CONSOLE — typed run + step stream for */ +/* perception -> extraction -> deterministic compute -> verification */ +/* User-facing chat renders one card per step kind. */ +/* ------------------------------------------------------------------ */ +const financialOperatorRuns = defineTable({ + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + taskType: v.union( + v.literal("financial_metric_extraction"), // ETR, cost of debt, ratios + v.literal("financial_data_cleanup"), // CRM list cleanup + v.literal("covenant_compliance"), // credit agreement review + v.literal("variance_analysis"), // actuals vs budget + v.literal("custom"), + ), + goal: v.string(), + files: v.optional(v.array(v.object({ + name: v.string(), + kind: v.string(), // "pdf" | "xlsx" | "image" | "csv" + sizeBytes: v.optional(v.number()), + }))), + status: v.union( + v.literal("created"), + v.literal("planning"), + v.literal("running"), + v.literal("awaiting_approval"), + v.literal("completed"), + v.literal("rejected"), + v.literal("error"), + ), + // Bounded step counter for UI progress (HONEST_SCORES — only what we count) + totalSteps: v.optional(v.number()), + nextSeq: v.optional(v.number()), + // Final operator summary surfaced above raw step stream + finalSummary: v.optional(v.string()), + artifacts: v.optional(v.array(v.object({ + kind: v.string(), // "notebook" | "csv" | "pr_draft" | "memo" + label: v.string(), + artifactRef: v.optional(v.string()), // foreign key to documents/files/etc + url: v.optional(v.string()), + }))), + errorMessage: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), +}) + .index("by_user", ["userId"]) + .index("by_threadId", ["threadId"]) + .index("by_status_createdAt", ["status", "createdAt"]) + .index("by_user_createdAt", ["userId", "createdAt"]); + +const financialOperatorSteps = defineTable({ + runId: v.id("financialOperatorRuns"), + seq: v.number(), // monotonic per run + kind: v.union( + v.literal("run_brief"), // initial plan with numbered steps + v.literal("tool_call"), // generic tool invocation card + v.literal("extraction"), // structured field extraction with sources + v.literal("validation"), // schema/unit/range checks + v.literal("calculation"), // deterministic sandbox compute + v.literal("evidence"), // source anchors + citations + v.literal("artifact"), // produced output (notebook, csv, PR) + v.literal("approval_request"), // needs user decision + v.literal("result"), // final operator summary + ), + status: v.union( + v.literal("pending"), + v.literal("running"), + v.literal("complete"), + v.literal("error"), + v.literal("needs_review"), + v.literal("approved"), + v.literal("rejected"), + ), + title: v.string(), // short label shown in card header + // Payload typed by kind in TS; stored as v.any() to keep schema flexible. + // Each kind's exact shape is enforced by its mutation (validators in domain). + payload: v.optional(v.any()), + durationMs: v.optional(v.number()), + errorMessage: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), +}) + .index("by_run", ["runId", "seq"]) + .index("by_run_kind", ["runId", "kind"]); + /* ------------------------------------------------------------------ */ /* INSTAGRAM POSTS - Social media content ingestion */ /* ------------------------------------------------------------------ */ @@ -3766,6 +3851,8 @@ export default defineSchema({ promptEnhancementFeedback, projectContext, agentRunEvents, + financialOperatorRuns, + financialOperatorSteps, instagramPosts, agentDelegations, agentWriteEvents, diff --git a/src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx b/src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx index ac7092730..d7a44f600 100644 --- a/src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx +++ b/src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx @@ -13,6 +13,7 @@ import { Search, FileText, BarChart3, + Calculator, AlertTriangle, Globe, Briefcase, @@ -25,6 +26,13 @@ export interface QuickCommand { label: string; query: string; icon: React.ElementType; + /** + * Optional navigation target. When set, clicking the chip routes the + * user to this path instead of dispatching `query` as a chat message. + * Used for chips that hand off to a dedicated workspace (e.g. the + * financial operator console). + */ + navigate?: string; } interface QuickCommandChipsProps { @@ -39,6 +47,7 @@ interface QuickCommandChipsProps { const COMMANDS: Record = { ask: [ + { id: "fin-att-demo", label: "AT&T cost of debt", query: "AT&T 10-K → ETR + after-tax cost of debt (operator console)", icon: Calculator, navigate: "/finance-demo" }, { id: "investigate", label: "Investigate", query: "Investigate the top competitor in my space — what changed this week?", icon: Search }, { id: "daily-brief", label: "Daily Brief", query: "Generate my founder weekly reset — what changed, main contradiction, next 3 moves", icon: FileText }, { id: "compare", label: "Compare", query: "Compare the top 3 companies in this category — strengths, weaknesses, recent moves", icon: BarChart3 }, @@ -87,7 +96,16 @@ export const QuickCommandChips = memo(function QuickCommandChips({ + ); + })} + + + {error && ( +

+ {error} +

+ )} + {isLocked && ( +

+ Decision recorded — this step is locked. +

+ )} + + ); +} diff --git a/src/features/financialOperator/components/ArtifactCard.tsx b/src/features/financialOperator/components/ArtifactCard.tsx new file mode 100644 index 000000000..2e4fb3cea --- /dev/null +++ b/src/features/financialOperator/components/ArtifactCard.tsx @@ -0,0 +1,62 @@ +import { FileText, FileSpreadsheet, GitPullRequest, ScrollText, Notebook } from "lucide-react"; +import type { ArtifactPayload, ArtifactKind } from "../types"; + +interface Props { + data: ArtifactPayload; +} + +const KIND_ICON: Record = { + notebook: Notebook, + csv: FileSpreadsheet, + pr_draft: GitPullRequest, + memo: ScrollText, + report: FileText, +}; + +const KIND_LABEL: Record = { + notebook: "Notebook", + csv: "CSV export", + pr_draft: "PR draft", + memo: "Memo", + report: "Report", +}; + +export function ArtifactCard({ data }: Props) { + const Icon = KIND_ICON[data.kind] ?? FileText; + return ( +
+
+
+ {data.description && ( +

{data.description}

+ )} + {data.diffSummary && data.diffSummary.length > 0 && ( +
    + {data.diffSummary.map((d, i) => ( +
  • +
  • + ))} +
+ )} + {data.url && ( + + Open artifact + + )} +
+ ); +} diff --git a/src/features/financialOperator/components/CalculationCard.tsx b/src/features/financialOperator/components/CalculationCard.tsx new file mode 100644 index 000000000..d01542679 --- /dev/null +++ b/src/features/financialOperator/components/CalculationCard.tsx @@ -0,0 +1,89 @@ +import { Calculator, Lock } from "lucide-react"; +import type { CalculationPayload } from "../types"; + +interface Props { + data: CalculationPayload; +} + +/** + * Calculation card. + * + * IMPORTANT: this card MUST display the disclosure that math ran in a + * deterministic sandbox, not in the language model. That distinction is + * the whole reason this surface exists — agents that can't be trusted + * with a calculator should not pretend to do one. + */ +export function CalculationCard({ data }: Props) { + const sandboxLabel = + data.sandboxKind === "js_pure" ? "JS sandbox (pure)" : data.sandboxKind; + + return ( +
+
+
+
+{data.formulaText}
+      
+ + {Object.keys(data.inputs).length > 0 && ( + + )} + + + +

+

+
+ ); +} + +function KVList({ + label, + entries, + formatted, + emphasised, +}: { + label: string; + entries: Record; + formatted?: Record; + emphasised?: boolean; +}) { + const items = Object.entries(entries); + if (items.length === 0) return null; + return ( +
+ {items.map(([k, v]) => { + const display = formatted?.[k] ?? (typeof v === "number" ? v.toLocaleString() : String(v)); + return ( +
+
{k}
+
+ {display} +
+
+ ); + })} +
+ ); +} diff --git a/src/features/financialOperator/components/EvidenceCard.tsx b/src/features/financialOperator/components/EvidenceCard.tsx new file mode 100644 index 000000000..41cc1a47f --- /dev/null +++ b/src/features/financialOperator/components/EvidenceCard.tsx @@ -0,0 +1,47 @@ +import { ExternalLink, Quote } from "lucide-react"; +import type { EvidencePayload } from "../types"; + +interface Props { + data: EvidencePayload; +} + +export function EvidenceCard({ data }: Props) { + return ( +
+

+ {data.totalSources} source{data.totalSources === 1 ? "" : "s"} cited +

+
    + {data.anchors.map((a, i) => ( +
  • +
    + {a.label} + + {a.sourceRef} + +
    + {a.excerpt && ( +
    +
    + )} + {a.url && ( + + Open source + )} +
  • + ))} +
+
+ ); +} diff --git a/src/features/financialOperator/components/ExtractionCard.tsx b/src/features/financialOperator/components/ExtractionCard.tsx new file mode 100644 index 000000000..207c59314 --- /dev/null +++ b/src/features/financialOperator/components/ExtractionCard.tsx @@ -0,0 +1,111 @@ +import { AlertTriangle, Check, HelpCircle } from "lucide-react"; +import type { ExtractionPayload, ExtractedField } from "../types"; + +interface Props { + data: ExtractionPayload; +} + +function formatValue(field: ExtractedField): string { + if (field.value === null || field.value === undefined) return "—"; + if (typeof field.value === "number") { + if (field.unit === "USD_millions") { + return `$${field.value.toLocaleString()}M`; + } + if (field.unit === "decimal") { + return `${(field.value * 100).toFixed(2)}%`; + } + if (field.unit === "percent") { + return `${field.value}%`; + } + return field.value.toLocaleString(); + } + return String(field.value); +} + +function StatusIcon({ status }: { status: ExtractedField["status"] }) { + if (status === "verified") { + return ( + + ); + } + if (status === "needs_review") { + return ( + + ); + } + return ( + + ); +} + +/** + * Extraction card — one row per field with value, source, confidence, status. + * Confidence is shown verbatim (HONEST_SCORES — no rounding to 100%). + */ +export function ExtractionCard({ data }: Props) { + return ( +
+
+ + schema: {data.schemaName} + + {data.totalFound} fields + {data.needsReviewCount > 0 && ( + + {data.needsReviewCount} need review + + )} +
+
    + {data.fields.map((field) => ( +
  • +
    +
    + + + {field.fieldName} + +
    + + {formatValue(field)} + +
    +
    +
    +
    Source:
    +
    {field.sourceRef}
    +
    +
    +
    Confidence:
    +
    + {field.confidence.toFixed(2)} +
    +
    +
    +
    Unit:
    +
    {field.unit ?? "—"}
    +
    +
    + {field.reviewNote && ( +

    + {field.reviewNote} +

    + )} +
  • + ))} +
+
+ ); +} diff --git a/src/features/financialOperator/components/FinancialOperatorTimeline.tsx b/src/features/financialOperator/components/FinancialOperatorTimeline.tsx new file mode 100644 index 000000000..f5cd2a544 --- /dev/null +++ b/src/features/financialOperator/components/FinancialOperatorTimeline.tsx @@ -0,0 +1,112 @@ +import { useQuery } from "convex/react"; +import { api } from "../../../../convex/_generated/api"; +import type { Id } from "../../../../convex/_generated/dataModel"; +import { StepCard, type StepRecord } from "./StepCard"; + +interface Props { + runId: Id<"financialOperatorRuns">; +} + +const RUN_STATUS_LABELS: Record = { + created: "Created", + planning: "Planning", + running: "Running", + awaiting_approval: "Awaiting approval", + completed: "Completed", + rejected: "Rejected", + error: "Error", +}; + +/** + * Live timeline: subscribes to listSteps + getRun, renders steps in + * sequence as they land. The Convex live query auto-updates as the + * orchestrator emits each step, so the UI streams. + */ +export function FinancialOperatorTimeline({ runId }: Props) { + const run = useQuery(api.domains.financialOperator.runOps.getRun, { runId }); + const steps = useQuery(api.domains.financialOperator.runOps.listSteps, { runId }); + + if (run === undefined || steps === undefined) { + return ( +
+ Loading run… +
+ ); + } + if (run === null) { + return ( +
+ Run not found. +
+ ); + } + + const sortedSteps = [...steps].sort((a, b) => a.seq - b.seq); + const totalSteps = run.totalSteps ?? sortedSteps.length; + const isRunning = run.status === "running" || run.status === "planning"; + + return ( +
+
+ + Run + + {run.goal} + + + {sortedSteps.length} + {totalSteps ? `/${totalSteps}` : ""} steps + + + {RUN_STATUS_LABELS[run.status] ?? run.status} + + +
+ + {run.finalSummary && ( +

+ {run.finalSummary} +

+ )} + +
    + {sortedSteps.map((s) => ( +
  1. + +
  2. + ))} + {sortedSteps.length === 0 && ( +
  3. + Run created. Waiting for first step… +
  4. + )} +
+ + {run.errorMessage && ( +

+ Run error: {run.errorMessage} +

+ )} +
+ ); +} diff --git a/src/features/financialOperator/components/ResultCard.tsx b/src/features/financialOperator/components/ResultCard.tsx new file mode 100644 index 000000000..ae2731343 --- /dev/null +++ b/src/features/financialOperator/components/ResultCard.tsx @@ -0,0 +1,65 @@ +import { Sparkles } from "lucide-react"; +import type { ResultPayload } from "../types"; + +interface Props { + data: ResultPayload; +} + +export function ResultCard({ data }: Props) { + return ( +
+
+
+ +

{data.prose}

+ + {data.metrics && Object.keys(data.metrics).length > 0 && ( +
+ {Object.entries(data.metrics).map(([k, v]) => ( +
+
+ {k} +
+
{v}
+
+ ))} +
+ )} + + {data.openIssues && data.openIssues.length > 0 && ( +
+
+ Open issues +
+
    + {data.openIssues.map((issue, i) => ( +
  • + {issue} +
  • + ))} +
+
+ )} + +
+ {data.nextActions.map((a) => ( + + ))} +
+
+ ); +} diff --git a/src/features/financialOperator/components/RunBriefCard.tsx b/src/features/financialOperator/components/RunBriefCard.tsx new file mode 100644 index 000000000..b620e007f --- /dev/null +++ b/src/features/financialOperator/components/RunBriefCard.tsx @@ -0,0 +1,38 @@ +import type { RunBriefPayload } from "../types"; + +interface Props { + data: RunBriefPayload; +} + +export function RunBriefCard({ data }: Props) { + return ( +
+

+ Goal: + {data.goal} +

+
    + {data.numberedSteps.map((step, i) => ( +
  1. + + {step} +
  2. + ))} +
+
+ Output: {data.outputFormat} + {data.estimatedDurationMs !== undefined && ( + Estimated duration: ~{Math.round(data.estimatedDurationMs / 1000)}s + )} +
+
+ ); +} diff --git a/src/features/financialOperator/components/StepCard.tsx b/src/features/financialOperator/components/StepCard.tsx new file mode 100644 index 000000000..19890029b --- /dev/null +++ b/src/features/financialOperator/components/StepCard.tsx @@ -0,0 +1,112 @@ +import type { Id } from "../../../../convex/_generated/dataModel"; +import type { + ApprovalRequestPayload, + ArtifactPayload, + CalculationPayload, + EvidencePayload, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + StepKind, + StepStatus, + ToolCallPayload, + ValidationPayload, +} from "../types"; +import { ApprovalCard } from "./ApprovalCard"; +import { ArtifactCard } from "./ArtifactCard"; +import { CalculationCard } from "./CalculationCard"; +import { EvidenceCard } from "./EvidenceCard"; +import { ExtractionCard } from "./ExtractionCard"; +import { ResultCard } from "./ResultCard"; +import { RunBriefCard } from "./RunBriefCard"; +import { StepShell } from "./StepShell"; +import { ToolCallCard } from "./ToolCallCard"; +import { ValidationCard } from "./ValidationCard"; + +export interface StepRecord { + _id: Id<"financialOperatorSteps">; + runId: Id<"financialOperatorRuns">; + seq: number; + kind: StepKind; + status: StepStatus; + title: string; + payload?: unknown; + durationMs?: number; + errorMessage?: string; + createdAt: number; + updatedAt: number; +} + +interface Props { + step: StepRecord; +} + +/** + * Switch on step.kind, render the right card body inside the common shell. + * Each card receives the typed payload — the shell carries seq + status + + * duration + error. + */ +export function StepCard({ step }: Props) { + const body = renderBody(step); + return ( + + {body} + + ); +} + +function renderBody(step: StepRecord) { + const p = step.payload as Record | undefined; + if (!p) { + return ( +

+ No payload recorded. +

+ ); + } + switch (step.kind) { + case "run_brief": + return ; + case "tool_call": + return ; + case "extraction": + return ; + case "validation": + return ; + case "calculation": + return ; + case "evidence": + return ; + case "artifact": + return ; + case "approval_request": { + const data = p as unknown as ApprovalRequestPayload & { + selectedOptionId?: ApprovalRequestPayload["options"][number]["id"]; + }; + return ( + + ); + } + case "result": + return ; + default: + return ( +

+ Unknown step kind. +

+ ); + } +} diff --git a/src/features/financialOperator/components/StepShell.tsx b/src/features/financialOperator/components/StepShell.tsx new file mode 100644 index 000000000..d6b0ed583 --- /dev/null +++ b/src/features/financialOperator/components/StepShell.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; +import type { StepKind, StepStatus } from "../types"; +import { StepStatusBadge } from "./StepStatusBadge"; + +const KIND_LABEL: Record = { + run_brief: "Plan", + tool_call: "Tool", + extraction: "Extraction", + validation: "Validation", + calculation: "Calculation", + evidence: "Evidence", + artifact: "Artifact", + approval_request: "Approval", + result: "Result", +}; + +const KIND_ACCENT: Record = { + run_brief: "border-l-blue-400/50", + tool_call: "border-l-slate-400/40", + extraction: "border-l-purple-400/50", + validation: "border-l-cyan-400/50", + calculation: "border-l-emerald-400/50", + evidence: "border-l-indigo-400/50", + artifact: "border-l-amber-400/50", + approval_request: "border-l-[#d97757]", + result: "border-l-emerald-400", +}; + +interface StepShellProps { + kind: StepKind; + status: StepStatus; + title: string; + seq: number; + durationMs?: number; + errorMessage?: string; + children: ReactNode; +} + +/** + * Common chrome around every step card: + * - Sequence number (1, 2, 3…) + * - Kind label (Plan / Tool / Extraction / …) + * - Status badge + * - Title + body + * - Optional duration + error footer + * + * Accent stripe on the left differentiates kinds at a glance. + */ +export function StepShell({ + kind, + status, + title, + seq, + durationMs, + errorMessage, + children, +}: StepShellProps) { + return ( +
+
+ + #{(seq + 1).toString().padStart(2, "0")} + + + {KIND_LABEL[kind]} + +

{title}

+ + + +
+
{children}
+ {(durationMs !== undefined || errorMessage) && ( +
+ {durationMs !== undefined && ( + Took {durationMs}ms + )} + {errorMessage && ( + Error: {errorMessage} + )} +
+ )} +
+ ); +} diff --git a/src/features/financialOperator/components/StepStatusBadge.tsx b/src/features/financialOperator/components/StepStatusBadge.tsx new file mode 100644 index 000000000..364239662 --- /dev/null +++ b/src/features/financialOperator/components/StepStatusBadge.tsx @@ -0,0 +1,60 @@ +import { Check, AlertTriangle, Clock, Loader2, X, ShieldCheck, ShieldX } from "lucide-react"; +import type { StepStatus } from "../types"; + +const TONE: Record = { + pending: { + label: "Pending", + classes: "border-edge bg-surface/50 text-content-muted", + Icon: Clock, + }, + running: { + label: "Running", + classes: "border-blue-500/30 bg-blue-500/10 text-blue-200", + Icon: Loader2, + }, + complete: { + label: "Complete", + classes: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", + Icon: Check, + }, + error: { + label: "Error", + classes: "border-red-500/40 bg-red-500/10 text-red-200", + Icon: X, + }, + needs_review: { + label: "Needs review", + classes: "border-amber-500/30 bg-amber-500/10 text-amber-200", + Icon: AlertTriangle, + }, + approved: { + label: "Approved", + classes: "border-[#d97757]/40 bg-[#d97757]/15 text-[#f0c2a8]", + Icon: ShieldCheck, + }, + rejected: { + label: "Rejected", + classes: "border-red-500/40 bg-red-500/15 text-red-200", + Icon: ShieldX, + }, +}; + +export function StepStatusBadge({ status }: { status: StepStatus }) { + const tone = TONE[status]; + const Icon = tone.Icon; + const isSpinning = status === "running"; + + return ( + + + ); +} diff --git a/src/features/financialOperator/components/ToolCallCard.tsx b/src/features/financialOperator/components/ToolCallCard.tsx new file mode 100644 index 000000000..308a3ae20 --- /dev/null +++ b/src/features/financialOperator/components/ToolCallCard.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Wrench } from "lucide-react"; +import type { ToolCallPayload } from "../types"; + +interface Props { + data: ToolCallPayload; +} + +/** + * Generic tool invocation card. + * Shows toolName + 1-line input/output summaries. + * Raw args/result are tucked behind an "Inspect" toggle (progressive disclosure). + */ +export function ToolCallCard({ data }: Props) { + const [open, setOpen] = useState(false); + const hasRaw = data.rawArgs !== undefined || data.rawResult !== undefined; + + return ( +
+
+
+
+
+
Input:
+
{data.inputSummary}
+
+ {data.outputSummary && ( +
+
Output:
+
{data.outputSummary}
+
+ )} +
+ {hasRaw && ( +
+ + {open && ( +
+{JSON.stringify({ args: data.rawArgs ?? null, result: data.rawResult ?? null }, null, 2)}
+            
+ )} +
+ )} +
+ ); +} diff --git a/src/features/financialOperator/components/ValidationCard.tsx b/src/features/financialOperator/components/ValidationCard.tsx new file mode 100644 index 000000000..dcb9d7f92 --- /dev/null +++ b/src/features/financialOperator/components/ValidationCard.tsx @@ -0,0 +1,92 @@ +import { AlertCircle, AlertTriangle, Info } from "lucide-react"; +import type { ValidationPayload, ValidationFinding } from "../types"; + +interface Props { + data: ValidationPayload; +} + +function FindingIcon({ level }: { level: ValidationFinding["level"] }) { + if (level === "error") { + return ; + } + if (level === "warning") { + return ; + } + return ; +} + +/** + * Validation card — schema/units/range checks + findings list. + * Counts shown verbatim (HONEST_SCORES). Findings never swallowed. + */ +export function ValidationCard({ data }: Props) { + return ( +
+
+ + + + +
+ {data.findings.length > 0 ? ( +
    + {data.findings.map((f, i) => ( +
  • + + + + + {f.fieldRef && ( + + [{f.fieldRef}]{" "} + + )} + {f.message} + +
  • + ))} +
+ ) : ( +

+ No findings — all checks passed cleanly. +

+ )} +
+ ); +} + +function Stat({ + label, + value, + ok, +}: { + label: string; + value: string; + ok?: boolean; +}) { + const tone = + ok === undefined + ? "text-content" + : ok + ? "text-emerald-200" + : "text-amber-200"; + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/src/features/financialOperator/index.ts b/src/features/financialOperator/index.ts new file mode 100644 index 000000000..d60f32a4d --- /dev/null +++ b/src/features/financialOperator/index.ts @@ -0,0 +1,22 @@ +export { FinancialOperatorDemo } from "./views/FinancialOperatorDemo"; +export { FinancialOperatorTimeline } from "./components/FinancialOperatorTimeline"; +export { StepCard } from "./components/StepCard"; +export type { + ApprovalRequestPayload, + ArtifactKind, + ArtifactPayload, + CalculationPayload, + EvidenceAnchor, + EvidencePayload, + ExtractedField, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + StepKind, + StepPayload, + StepStatus, + TaskType, + ToolCallPayload, + ValidationFinding, + ValidationPayload, +} from "./types"; diff --git a/src/features/financialOperator/types.ts b/src/features/financialOperator/types.ts new file mode 100644 index 000000000..7168d8768 --- /dev/null +++ b/src/features/financialOperator/types.ts @@ -0,0 +1,24 @@ +/** + * Frontend re-exports of the typed step payloads. + * Single source of truth lives in convex/domains/financialOperator/types.ts. + */ +export type { + ApprovalRequestPayload, + ArtifactKind, + ArtifactPayload, + CalculationPayload, + EvidenceAnchor, + EvidencePayload, + ExtractedField, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + RunStatus, + StepKind, + StepPayload, + StepStatus, + TaskType, + ToolCallPayload, + ValidationFinding, + ValidationPayload, +} from "../../../convex/domains/financialOperator/types"; diff --git a/src/features/financialOperator/views/FinancialOperatorDemo.tsx b/src/features/financialOperator/views/FinancialOperatorDemo.tsx new file mode 100644 index 000000000..48987f0e2 --- /dev/null +++ b/src/features/financialOperator/views/FinancialOperatorDemo.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useAction } from "convex/react"; +import { Play } from "lucide-react"; +import { api } from "../../../../convex/_generated/api"; +import type { Id } from "../../../../convex/_generated/dataModel"; +import { FinancialOperatorTimeline } from "../components/FinancialOperatorTimeline"; + +/** + * Standalone demo surface for the financial operator console. + * + * This is the "tested live" entry point: click "Run AT&T 10-K demo", + * the orchestrator action streams typed steps, and the timeline renders + * a full operator-console chat experience (run brief → tool calls → + * extraction → validation → sandbox calculation → evidence → artifact → + * approval gate). + * + * Long-term, this same timeline component will mount inside the chat + * panel (FastAgentPanel) once the agent's task classifier detects a + * financial workflow. + */ +export function FinancialOperatorDemo() { + const runDemo = useAction( + api.domains.financialOperator.orchestrator.runAttCostOfDebtDemo, + ); + const [runId, setRunId] = useState | null>(null); + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(null); + + async function handleStart() { + setIsStarting(true); + setError(null); + try { + const { runId: newRunId } = await runDemo({}); + setRunId(newRunId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to start run"); + } finally { + setIsStarting(false); + } + } + + return ( +
+
+

+ Financial operator console — demo +

+

+ AT&T 10-K → effective tax rate & after-tax cost of debt +

+

+ Watch the agent locate sources, extract structured values, validate + inputs, run a deterministic sandbox calculation, gather source + anchors, draft a notebook artifact, and pause for reviewer approval — + one observable card per step. Math runs in JS, not in the language + model. +

+
+ +
+ + {isStarting && ( + + Starting orchestrator… + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {runId && } + + {!runId && ( +
+

+ Click Run AT&T 10-K demo{" "} + to start a live run. Each step appears as a typed card. +

+
+ )} +
+ ); +} + +export default FinancialOperatorDemo; diff --git a/src/lib/registry/viewRegistry.ts b/src/lib/registry/viewRegistry.ts index f26e49853..1e656c6af 100644 --- a/src/lib/registry/viewRegistry.ts +++ b/src/lib/registry/viewRegistry.ts @@ -19,6 +19,7 @@ import { lazy, type ComponentType, type LazyExoticComponent } from "react"; export type MainView = | "control-plane" | "developers" + | "financial-operator" | "research" | "product-direction" | "execution-trace" @@ -219,6 +220,24 @@ export const VIEW_REGISTRY: ViewRegistryEntry[] = [ commandPaletteVisible: true, }, + // ── Financial Operator Console (demo) ────────────────────────────────────── + { + id: "financial-operator", + title: "Financial Operator", + subtitle: "Typed-card chat: extract → validate → sandbox compute → approve", + path: "/finance-demo", + aliases: ["/financial-operator", "/finops"], + component: lazyNamed( + () => import("@/features/financialOperator/views/FinancialOperatorDemo"), + "FinancialOperatorDemo", + ), + group: "nested", + navVisible: false, + parentId: "control-plane", + surfaceId: "ask", + commandPaletteVisible: true, + }, + // ── Entity Intelligence ────────────────────────────────────────────────── { id: "research", From f2f17841dd739d5d982f78aaaad294dffb389da9 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 09:34:50 -0700 Subject: [PATCH 2/7] feat(finance): examples B/C/D + real-PDF extractor + chat overlay + deploy hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 4 follow-ups from PR #204: 1. Vercel deploy hook race fix (60s wait + Tier-A verify poll) 2. Edge cache stickiness (no-cache headers on HTML, immutable on assets) 3. Inline chat experience (FinancialOperatorOverlay, no FastAgentPanel surgery) 4. Real PDF reader (Claude PDF input + structured extraction) 5. Examples B/C/D (CRM cleanup, covenant compliance, variance analysis) ## Examples B/C/D — full operator-console workflows - Example B (financial_data_cleanup): inspect → profile spreadsheet → extract entities → dedup → enrich → validate CRM schema → export CSV. Sandbox compute: dedup ratio (387 -> 312, 19.4%). - Example C (covenant_compliance): locate covenant → extract terms + inputs → validate → sandbox leverage + compliance gate → memo. Sandbox: computeLeverageRatio + checkCompliance (3.55x vs 4.25x cap, compliant). - Example D (variance_analysis): inspect → align CoA → per-line variance in sandbox → driver search → CFO memo. Sandbox: computeVariance for 6 P&L lines, signed-percent formatting. All three reuse the same backbone: runOps + sandbox + validators + typed step kinds. Each emits 8-10 cards, picker on /finance-demo lets the user choose which workflow to run. ## Real PDF reader (production path) `runRealCostOfDebtFromPdf` action: - Takes a `_storage` PDF id (any uploader can produce one) - Sends PDF directly to Claude as a document input (no separate parse step) - Constrains output to a strict JSON schema with sourceRef + confidence per field; instructs Claude to return null + add to unresolvedFields rather than fabricate - Validates extraction with the same `validateExtraction()` used by the fixture path; computes ETR + after-tax cost of debt deterministically - Bounded reads (MAX_PDF_BYTES = 20MB), HONEST_STATUS error path that surfaces parse failures verbatim, approval gate when required fields unresolved. ## Inline chat experience (FinancialOperatorOverlay) Surface-agnostic global drawer. Listens for `?finRun=` URL param, mounts `FinancialOperatorTimeline` as a right-side drawer alongside any chat surface. Collapsible to a corner pill. Mounted in App.tsx so it works on /, /?surface=ask, /?surface=workspace, etc. Why a global overlay vs editing FastAgentPanel directly: - FastAgentPanel.tsx is 3700+ lines; surgical message-bubble edits have high blast radius - URL-param-driven means any caller (chip, button, MCP tool) can activate the overlay via `setActiveFinancialRun()` without knowing the chat panel internals - /finance-demo "View in chat" button deep-links to `/?surface=ask&finRun=` — overlay mounts beside the chat ## Deploy hardening vercel-deploy-hook-backup.yml: - 60s wait before firing the deploy hook on push events. Closes the race that bit PR #204: the GitHub→Vercel git mirror takes a few seconds to catch up after a merge, and deploy hooks pass no commit SHA, so immediate-fire deploys can clone the previous HEAD. - Tier-A verification poll: after the hook fires, watch the live URL for up to 7 minutes for the bundle hash to rotate. Non-blocking warning if it doesn't (deploy still in progress, or edge cache stuck). vercel.json headers: - /assets/* → `public, max-age=31536000, immutable` (content-hashed, safe for permanent edge cache) - /(everything else) → `no-cache, no-store, must-revalidate` plus CDN-Cache-Control / Vercel-CDN-Cache-Control no-store. Prevents the stale-HTML landmine that took 15 minutes to clear post-deploy on PR #204. The bundle hashes inside index.html change every deploy, so stale HTML points at JS files the new deploy may have evicted. ## Design alignment doc New `docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md` walks through how the cards build on existing UI kit per surface (web, mobile, workspace, CLI/MCP). Same step-kind enum, same status enum, same sandbox guarantee everywhere. Workspace + CLI/MCP exposure described as concrete next-PR plans. ## Verification - npx convex dev --once --typecheck=enable: clean (3.17m typecheck) - npx tsc --noEmit: 0 errors - npx vitest run convex/domains/financialOperator/__tests__/: 19/19 pass - npx vite build: clean (42.66s, 211 entries precached) - Live browser: 4 demo workflows trigger, each renders 8-10 typed cards; "View in chat" deep-links to /?surface=ask with overlay mounted (8 cards in the drawer next to the chat surface). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/vercel-deploy-hook-backup.yml | 45 +- convex/_generated/api.d.ts | 10 + .../fixtures/covenantFixture.ts | 74 ++ .../financialOperator/fixtures/crmFixture.ts | 65 ++ .../fixtures/varianceFixture.ts | 72 ++ convex/domains/financialOperator/index.ts | 2 + .../financialOperator/orchestratorExamples.ts | 871 ++++++++++++++++++ .../financialOperator/realExtractors.ts | 519 +++++++++++ .../FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md | 157 ++++ src/App.tsx | 2 + .../components/FinancialOperatorOverlay.tsx | 150 +++ src/features/financialOperator/index.ts | 4 + .../views/FinancialOperatorDemo.tsx | 232 ++++- vercel.json | 15 + 14 files changed, 2170 insertions(+), 48 deletions(-) create mode 100644 convex/domains/financialOperator/fixtures/covenantFixture.ts create mode 100644 convex/domains/financialOperator/fixtures/crmFixture.ts create mode 100644 convex/domains/financialOperator/fixtures/varianceFixture.ts create mode 100644 convex/domains/financialOperator/orchestratorExamples.ts create mode 100644 convex/domains/financialOperator/realExtractors.ts create mode 100644 docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md create mode 100644 src/features/financialOperator/components/FinancialOperatorOverlay.tsx diff --git a/.github/workflows/vercel-deploy-hook-backup.yml b/.github/workflows/vercel-deploy-hook-backup.yml index 00e1694d6..7172691bf 100644 --- a/.github/workflows/vercel-deploy-hook-backup.yml +++ b/.github/workflows/vercel-deploy-hook-backup.yml @@ -40,7 +40,7 @@ jobs: ping-vercel-deploy-hook: name: Ping Vercel Deploy Hook runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Sanity-check secret presence run: | @@ -50,6 +50,22 @@ jobs: fi echo "secret-present=true" + # Why this wait: GitHub's main branch HEAD takes a few seconds to + # propagate to Vercel's git-mirror. If we fire the deploy hook + # immediately after a merge, Vercel can clone an OLDER commit and + # ship a build that doesn't contain the just-merged code — exactly + # what bit us on commit 5a4d690 (PR #204): a deploy completed at + # 08:13:43 but built without the merged source, causing 15 minutes + # of stale serving until a manual re-trigger. Native GitHub→Vercel + # webhook integration usually handles this race because it passes + # the commit SHA explicitly; deploy hook URLs do not, so we wait. + - name: Wait for git mirror propagation (race fix) + if: github.event_name == 'push' + run: | + echo "Waiting 60s before firing deploy hook so Vercel's git mirror catches up to GitHub HEAD..." + sleep 60 + echo "Done waiting." + - name: Trigger Vercel deploy hook env: DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_URL }} @@ -67,11 +83,36 @@ jobs: exit 1 fi + # Tier-A live verification per .claude/rules/live_dom_verification.md: + # poll the production URL until the entry-bundle hash changes from + # whatever was previously served. Times out at ~7 minutes + # (35 polls × 12s). Non-blocking — if Vercel takes longer, we still + # fail loudly so engineers know to check. + - name: Verify new bundle is live (Tier A) + if: github.event_name == 'push' + env: + PROD_URL: https://www.nodebenchai.com + run: | + echo "Capturing baseline bundle hash before deploy completes..." + baseline=$(curl -fsS "$PROD_URL/?nc=$RANDOM-$(date +%s)" 2>/dev/null | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1 || echo "none") + echo "Baseline: $baseline" + for i in $(seq 1 35); do + sleep 12 + current=$(curl -fsS "$PROD_URL/?nc=$RANDOM-$(date +%s)" 2>/dev/null | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | head -1 || echo "none") + if [ "$current" != "$baseline" ] && [ "$current" != "none" ]; then + echo "::notice::Live bundle rotated to $current after ~$((i*12))s" + echo "live-verified=true" >> "$GITHUB_OUTPUT" 2>/dev/null || true + exit 0 + fi + echo "Poll $i/35: still $current" + done + echo "::warning::Live bundle did not rotate within 7 minutes. Vercel deploy may still be in progress, or edge cache is stuck. Check https://vercel.com/hshum2018-gmailcoms-projects/nodebench-ai" + - name: Summary if: success() run: | echo "## Vercel deploy hook fired" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "Triggered for commit \`${{ github.sha }}\` on \`main\`." >> $GITHUB_STEP_SUMMARY + echo "Triggered for commit \`${{ github.sha }}\` on \`main\` after 60s mirror-propagation wait." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Vercel deduplicates by commit SHA, so this is a no-op if the native push integration also fired. If the native integration is broken, this is the only thing that ships your code." >> $GITHUB_STEP_SUMMARY diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index c5f0aa67e..8547f6af2 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -547,8 +547,13 @@ import type * as domains_evaluation_validators from "../domains/evaluation/valid import type * as domains_evaluation_workbenchQueries from "../domains/evaluation/workbenchQueries.js"; import type * as domains_financialOperator_attFixture from "../domains/financialOperator/attFixture.js"; import type * as domains_financialOperator_extractors from "../domains/financialOperator/extractors.js"; +import type * as domains_financialOperator_fixtures_covenantFixture from "../domains/financialOperator/fixtures/covenantFixture.js"; +import type * as domains_financialOperator_fixtures_crmFixture from "../domains/financialOperator/fixtures/crmFixture.js"; +import type * as domains_financialOperator_fixtures_varianceFixture from "../domains/financialOperator/fixtures/varianceFixture.js"; import type * as domains_financialOperator_index from "../domains/financialOperator/index.js"; import type * as domains_financialOperator_orchestrator from "../domains/financialOperator/orchestrator.js"; +import type * as domains_financialOperator_orchestratorExamples from "../domains/financialOperator/orchestratorExamples.js"; +import type * as domains_financialOperator_realExtractors from "../domains/financialOperator/realExtractors.js"; import type * as domains_financialOperator_runOps from "../domains/financialOperator/runOps.js"; import type * as domains_financialOperator_sandbox from "../domains/financialOperator/sandbox.js"; import type * as domains_financialOperator_types from "../domains/financialOperator/types.js"; @@ -1994,8 +1999,13 @@ declare const fullApi: ApiFromModules<{ "domains/evaluation/workbenchQueries": typeof domains_evaluation_workbenchQueries; "domains/financialOperator/attFixture": typeof domains_financialOperator_attFixture; "domains/financialOperator/extractors": typeof domains_financialOperator_extractors; + "domains/financialOperator/fixtures/covenantFixture": typeof domains_financialOperator_fixtures_covenantFixture; + "domains/financialOperator/fixtures/crmFixture": typeof domains_financialOperator_fixtures_crmFixture; + "domains/financialOperator/fixtures/varianceFixture": typeof domains_financialOperator_fixtures_varianceFixture; "domains/financialOperator/index": typeof domains_financialOperator_index; "domains/financialOperator/orchestrator": typeof domains_financialOperator_orchestrator; + "domains/financialOperator/orchestratorExamples": typeof domains_financialOperator_orchestratorExamples; + "domains/financialOperator/realExtractors": typeof domains_financialOperator_realExtractors; "domains/financialOperator/runOps": typeof domains_financialOperator_runOps; "domains/financialOperator/sandbox": typeof domains_financialOperator_sandbox; "domains/financialOperator/types": typeof domains_financialOperator_types; diff --git a/convex/domains/financialOperator/fixtures/covenantFixture.ts b/convex/domains/financialOperator/fixtures/covenantFixture.ts new file mode 100644 index 000000000..eec33ecc4 --- /dev/null +++ b/convex/domains/financialOperator/fixtures/covenantFixture.ts @@ -0,0 +1,74 @@ +/** + * Credit-agreement covenant compliance fixture — Example C. + * + * Demo numbers chosen to land inside the leverage covenant (3.55x vs + * 4.25x cap) so the deterministic sandbox produces a clean "compliant" + * verdict. Real implementations should hit a covenant-extractor backed + * by an LLM section reader. + */ + +export const COVENANT_FIXTURE = { + meta: { + borrower: "Demo Borrower Inc.", + creditAgreementFile: "credit_agreement.pdf", + financialsFile: "q4_financials.xlsx", + }, + + sections: [ + { + page: 42, + label: "Financial Covenants", + match: "Maximum Total Net Leverage Ratio", + }, + { + page: 87, + label: "Definitions — Consolidated EBITDA", + match: "Consolidated EBITDA", + }, + { + page: 91, + label: "Definitions — Total Net Debt", + match: "Total Net Debt", + }, + ], + + covenant: { + name: "Maximum Total Net Leverage Ratio", + threshold: 4.25, + ratioType: "Total Net Debt / Consolidated EBITDA", + numeratorDefinition: "Total Net Debt = total debt - unrestricted cash", + denominatorDefinition: "Consolidated EBITDA (with permitted add-backs)", + }, + + // Inputs in dollars (full units, not millions, so the formatter shows $.XM) + inputs: { + totalDebt: { + value: 840_000_000, + sourceRef: "Q4 Financials — Debt Schedule!B14", + confidence: 0.96, + }, + cash: { + value: 95_000_000, + sourceRef: "Q4 Financials — Balance Sheet!B21", + confidence: 0.98, + }, + adjustedEBITDA: { + value: 210_000_000, + sourceRef: "Q4 Financials — Adjusted EBITDA!B37", + confidence: 0.88, + }, + }, + + excerpts: [ + { + sourceRef: "Credit Agreement p.42", + excerpt: + "Borrower shall not permit the Total Net Leverage Ratio at the end of any fiscal quarter to exceed 4.25 to 1.00.", + }, + { + sourceRef: "Credit Agreement p.87", + excerpt: + "\"Consolidated EBITDA\" means, for any period, the sum of net income plus interest expense, taxes, depreciation, amortization, and certain permitted non-recurring add-backs.", + }, + ], +} as const; diff --git a/convex/domains/financialOperator/fixtures/crmFixture.ts b/convex/domains/financialOperator/fixtures/crmFixture.ts new file mode 100644 index 000000000..c9aa5842f --- /dev/null +++ b/convex/domains/financialOperator/fixtures/crmFixture.ts @@ -0,0 +1,65 @@ +/** + * CRM cleanup fixture — Example B in the operator-console spec. + * + * Stand-in for a real spreadsheet+PDF dedup pipeline. Stable, citable, + * deterministic. Replace `extractCrmInputs` with a real reader once the + * spreadsheet/PDF parsing surface is wired. + */ + +export const CRM_FIXTURE = { + meta: { + files: [ + { name: "prospects.xlsx", kind: "xlsx" }, + { name: "investor_packet_1.pdf", kind: "pdf" }, + { name: "investor_packet_2.pdf", kind: "pdf" }, + { name: "investor_packet_3.pdf", kind: "pdf" }, + ], + spreadsheetSheets: ["Raw Leads", "Notes", "Duplicates"], + pdfPages: 86, + }, + + profile: { + columns: ["Company", "Website", "Notes", "Partner", "Status"], + rowCount: 387, + missingFields: ["Sector", "HQ", "Last Round", "Source"], + }, + + entityExtraction: { + companiesFound: 142, + fundingEventsFound: 37, + locationsFound: 91, + }, + + dedup: { + originalRows: 387, + dedupedRows: 312, + mergedExamples: [ + { + canonical: "Acme Bio", + merged: ["AcmeBio", "Acme Bio Inc.", "acmebio.com"], + }, + { + canonical: "Northstar Robotics", + merged: ["Northstar Robotics LLC", "northstar-robotics.io"], + }, + ], + }, + + enrichment: { + recordsUpdated: 241, + lowConfidenceRecords: 31, + unresolvedRecords: 40, + sampleEnriched: [ + { company: "Acme Bio", sector: "Biotech", hq: "Boston, MA", lastRound: "Series B" }, + { company: "Northstar Robotics", sector: "Industrial AI", hq: "Pittsburgh, PA", lastRound: "Series A" }, + { company: "HelioGrid", sector: "Energy", hq: "Austin, TX", lastRound: "Seed" }, + ], + }, + + csvValidation: { + schema: ["Company", "Website", "Sector", "HQ", "Last Round", "Source", "Confidence", "Owner"], + validRows: 295, + warningRows: 17, + failedRows: 0, + }, +} as const; diff --git a/convex/domains/financialOperator/fixtures/varianceFixture.ts b/convex/domains/financialOperator/fixtures/varianceFixture.ts new file mode 100644 index 000000000..e00fb07ab --- /dev/null +++ b/convex/domains/financialOperator/fixtures/varianceFixture.ts @@ -0,0 +1,72 @@ +/** + * Monthly variance-analysis fixture — Example D. + * + * Includes a diverse top/bottom mix so the variance sandbox renders + * both signed-positive and signed-negative formatting paths. + */ + +export interface VarianceLine { + account: string; + category: "revenue" | "cost_of_revenue" | "opex" | "infrastructure" | "other"; + actual: number; + budget: number; + driverNote?: string; +} + +export const VARIANCE_FIXTURE = { + period: "March 2026", + files: [ + { name: "march_actuals.xlsx", kind: "xlsx" }, + { name: "fy_budget.xlsx", kind: "xlsx" }, + ], + alignment: { + matchedAccounts: 124, + unmatchedActuals: 3, + unmatchedBudget: 5, + }, + // Lines in dollars; formatter divides by 1M for display. + lines: [ + { + account: "Subscription Revenue", + category: "revenue", + actual: 4_200_000, + budget: 3_700_000, + driverNote: "Enterprise renewals closed earlier than budgeted.", + }, + { + account: "Services Revenue", + category: "revenue", + actual: 850_000, + budget: 900_000, + driverNote: "One implementation slipped to April.", + }, + { + account: "Cloud Infrastructure", + category: "infrastructure", + actual: 980_000, + budget: 720_000, + driverNote: "GPU inference costs rose with higher agent run volume.", + }, + { + account: "Sales Headcount", + category: "opex", + actual: 1_100_000, + budget: 1_150_000, + driverNote: "Two requisitions delayed by one month.", + }, + { + account: "Engineering Headcount", + category: "opex", + actual: 1_400_000, + budget: 1_350_000, + driverNote: "On plan; small-bonus accrual.", + }, + { + account: "Marketing", + category: "opex", + actual: 320_000, + budget: 400_000, + driverNote: "Demand-gen campaign delayed.", + }, + ] satisfies VarianceLine[], +} as const; diff --git a/convex/domains/financialOperator/index.ts b/convex/domains/financialOperator/index.ts index 3a6ee2153..926f202ed 100644 --- a/convex/domains/financialOperator/index.ts +++ b/convex/domains/financialOperator/index.ts @@ -10,3 +10,5 @@ export * as runOps from "./runOps"; export * as orchestrator from "./orchestrator"; +export * as orchestratorExamples from "./orchestratorExamples"; +export * as realExtractors from "./realExtractors"; diff --git a/convex/domains/financialOperator/orchestratorExamples.ts b/convex/domains/financialOperator/orchestratorExamples.ts new file mode 100644 index 000000000..cca4c84d2 --- /dev/null +++ b/convex/domains/financialOperator/orchestratorExamples.ts @@ -0,0 +1,871 @@ +/** + * Orchestrator actions for Examples B, C, D from the operator-console spec: + * + * B — CRM cleanup (financial_data_cleanup) + * C — Covenant compliance (covenant_compliance) + * D — Variance analysis (variance_analysis) + * + * All three use the same shared backbone (runOps + sandbox + validators) + * established in orchestrator.ts and types.ts. Math runs in JS sandbox, + * sources ride along on every extracted field, validations surface + * findings verbatim — same invariants as the AT&T example. + */ + +import { v } from "convex/values"; +import { action } from "../../_generated/server"; +import { api } from "../../_generated/api"; +import type { Id } from "../../_generated/dataModel"; + +import { + computeLeverageRatio, + checkCompliance, + computeVariance, +} from "./sandbox"; +import { validateExtraction, type FieldSpec } from "./validators"; +import { CRM_FIXTURE } from "./fixtures/crmFixture"; +import { COVENANT_FIXTURE } from "./fixtures/covenantFixture"; +import { VARIANCE_FIXTURE } from "./fixtures/varianceFixture"; +import type { + ApprovalRequestPayload, + ArtifactPayload, + CalculationPayload, + EvidencePayload, + ExtractedField, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + ToolCallPayload, + ValidationPayload, +} from "./types"; + +const STEP_PACING_MS = 350; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function classifyStatus( + confidence: number, +): ExtractedField["status"] { + if (confidence >= 0.9) return "verified"; + if (confidence >= 0.5) return "needs_review"; + return "unresolved"; +} + +/* ================================================================== */ +/* EXAMPLE B — CRM CLEANUP */ +/* ================================================================== */ + +export const runCrmCleanupDemo = action({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ runId: Id<"financialOperatorRuns"> }> => { + const goal = + "Clean and dedupe a 387-row prospect list, enrich with sector/HQ/last round, and export CRM-ready CSV."; + + const runId: Id<"financialOperatorRuns"> = await ctx.runMutation( + api.domains.financialOperator.runOps.createRun, + { + userId: args.userId, + threadId: args.threadId, + taskType: "financial_data_cleanup", + goal, + files: CRM_FIXTURE.meta.files.map((f) => ({ name: f.name, kind: f.kind })), + totalSteps: 9, + }, + ); + + // 1. Plan + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "planning", + }); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "run_brief", + status: "complete", + title: "Plan", + payload: { + goal, + numberedSteps: [ + "Inspect files (1 spreadsheet + 3 PDFs)", + "Profile spreadsheet schema", + "Extract company mentions from PDFs", + "Resolve duplicates", + "Enrich missing fields", + "Validate CRM CSV schema", + "Export CSV", + ], + estimatedDurationMs: 5000, + outputFormat: "CRM-ready CSV + low-confidence review queue", + } satisfies RunBriefPayload, + }); + + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "running", + }); + + // 2. Inspect files + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Inspect uploaded files", + payload: { + toolName: "files.inspect", + inputSummary: "1 spreadsheet (3 sheets) + 3 investor PDFs (86 pages)", + outputSummary: `Sheets: ${CRM_FIXTURE.meta.spreadsheetSheets.join(", ")} · ${CRM_FIXTURE.entityExtraction.companiesFound} company mentions detected`, + } satisfies ToolCallPayload, + }); + + // 3. Profile spreadsheet + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Profile spreadsheet schema", + payload: { + toolName: "spreadsheet.profile", + inputSummary: "prospects.xlsx → Raw Leads sheet", + outputSummary: `${CRM_FIXTURE.profile.rowCount} rows; missing fields: ${CRM_FIXTURE.profile.missingFields.join(", ")}`, + } satisfies ToolCallPayload, + }); + + // 4. Extract entities from PDFs + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Extract company mentions from PDFs", + payload: { + toolName: "document.extract_entities", + inputSummary: "3 investor packets (86 pages)", + outputSummary: `${CRM_FIXTURE.entityExtraction.companiesFound} companies, ${CRM_FIXTURE.entityExtraction.fundingEventsFound} funding events, ${CRM_FIXTURE.entityExtraction.locationsFound} locations`, + } satisfies ToolCallPayload, + }); + + // 5. Dedup → extraction card showing merge groups + await sleep(STEP_PACING_MS); + const dedupFields: ExtractedField[] = CRM_FIXTURE.dedup.mergedExamples.map( + (m) => ({ + fieldName: m.canonical, + value: `${m.merged.length} variants merged`, + unit: "rows", + sourceRef: `dedup pass — ${m.merged.join(" / ")}`, + confidence: 0.93, + status: classifyStatus(0.93), + }), + ); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: "complete", + title: "Dedup merge groups", + payload: { + schemaName: "company_dedup_groups", + fields: dedupFields, + totalFound: CRM_FIXTURE.dedup.mergedExamples.length, + needsReviewCount: 0, + } satisfies ExtractionPayload, + }); + + // 6. Calculation: dedup ratio + await sleep(STEP_PACING_MS); + const dedupRatio = + (CRM_FIXTURE.dedup.originalRows - CRM_FIXTURE.dedup.dedupedRows) / + CRM_FIXTURE.dedup.originalRows; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "calculation", + status: "complete", + title: "Dedup ratio", + payload: { + formulaLabel: "Duplicate-row reduction", + formulaText: + "dedup_ratio = (original_rows - deduped_rows) / original_rows", + inputs: { + originalRows: CRM_FIXTURE.dedup.originalRows, + dedupedRows: CRM_FIXTURE.dedup.dedupedRows, + }, + outputs: { dedupRatio, mergedRows: CRM_FIXTURE.dedup.originalRows - CRM_FIXTURE.dedup.dedupedRows }, + formattedOutputs: { + dedupRatio: `${(dedupRatio * 100).toFixed(1)}%`, + mergedRows: `${CRM_FIXTURE.dedup.originalRows - CRM_FIXTURE.dedup.dedupedRows} rows`, + }, + sandboxKind: "js_pure", + computedAt: Date.now(), + } satisfies CalculationPayload, + }); + + // 7. Enrichment → extraction card + await sleep(STEP_PACING_MS); + const enrichmentFields: ExtractedField[] = + CRM_FIXTURE.enrichment.sampleEnriched.map((e) => ({ + fieldName: e.company, + value: `${e.sector} · ${e.hq} · ${e.lastRound}`, + unit: "company_profile", + sourceRef: "company.enrich_profile", + confidence: 0.91, + status: classifyStatus(0.91), + })); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: "needs_review", + title: "Enriched company profiles", + payload: { + schemaName: "crm_enrichment", + fields: enrichmentFields, + totalFound: CRM_FIXTURE.enrichment.recordsUpdated, + needsReviewCount: + CRM_FIXTURE.enrichment.lowConfidenceRecords + + CRM_FIXTURE.enrichment.unresolvedRecords, + } satisfies ExtractionPayload, + }); + + // 8. Validation + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "validation", + status: "complete", + title: "CRM CSV schema", + payload: { + schemaPassed: CRM_FIXTURE.csvValidation.failedRows === 0, + unitsNormalized: true, + findings: [ + { + level: "info", + message: `${CRM_FIXTURE.csvValidation.validRows} rows validated cleanly`, + }, + { + level: "warning", + message: `${CRM_FIXTURE.csvValidation.warningRows} rows exportable with caveats (low-confidence enrichment)`, + }, + ], + checksRun: + CRM_FIXTURE.csvValidation.validRows + + CRM_FIXTURE.csvValidation.warningRows + + CRM_FIXTURE.csvValidation.failedRows, + checksPassed: + CRM_FIXTURE.csvValidation.validRows + + CRM_FIXTURE.csvValidation.warningRows, + } satisfies ValidationPayload, + }); + + // 9. Artifact (CSV) + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "artifact", + status: "complete", + title: "CRM-ready CSV", + payload: { + kind: "csv", + label: "crm_ready_company_list.csv", + description: `${CRM_FIXTURE.dedup.dedupedRows} unique rows after dedup; ${CRM_FIXTURE.enrichment.recordsUpdated} enriched.`, + diffSummary: [ + `Reduced from ${CRM_FIXTURE.dedup.originalRows} → ${CRM_FIXTURE.dedup.dedupedRows} rows`, + `Enriched: ${CRM_FIXTURE.enrichment.recordsUpdated}`, + `Needs review: ${CRM_FIXTURE.enrichment.lowConfidenceRecords + CRM_FIXTURE.enrichment.unresolvedRecords}`, + ], + } satisfies ArtifactPayload, + }); + + // 10. Result with low-confidence escape hatch + await sleep(STEP_PACING_MS); + const result: ResultPayload = { + headline: `${CRM_FIXTURE.dedup.dedupedRows} CRM-ready rows; ${CRM_FIXTURE.enrichment.lowConfidenceRecords + CRM_FIXTURE.enrichment.unresolvedRecords} flagged for review.`, + prose: + "Dedup merged duplicate company variants by domain + name similarity. Enrichment added sector, HQ, and last funding round per record. The low-confidence subset is exportable but should be sampled before being trusted in outreach.", + metrics: { + "Final rows": String(CRM_FIXTURE.dedup.dedupedRows), + "Dedup reduction": `${(dedupRatio * 100).toFixed(1)}%`, + "Needs review": String( + CRM_FIXTURE.enrichment.lowConfidenceRecords + + CRM_FIXTURE.enrichment.unresolvedRecords, + ), + }, + openIssues: [ + `${CRM_FIXTURE.enrichment.unresolvedRecords} companies could not be enriched and need manual investigation.`, + ], + nextActions: [ + { id: "download_csv", label: "Download CSV", kind: "export" }, + { id: "review_low_conf", label: "Review low-confidence rows", kind: "open" }, + { id: "save_to_crm", label: "Save to CRM", kind: "approve" }, + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "result", + status: "complete", + title: "Result", + payload: result, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "completed", + finalSummary: result.headline, + }); + + return { runId }; + }, +}); + +/* ================================================================== */ +/* EXAMPLE C — COVENANT COMPLIANCE */ +/* ================================================================== */ + +const COVENANT_INPUT_SPEC: FieldSpec[] = [ + { fieldName: "Total Debt", expectedUnit: "USD", required: true, sanityRange: { min: 0, max: 1e12 } }, + { fieldName: "Cash", expectedUnit: "USD", required: true, sanityRange: { min: 0, max: 1e12 } }, + { fieldName: "Adjusted EBITDA", expectedUnit: "USD", required: true, sanityRange: { min: 1, max: 1e12 } }, +]; + +export const runCovenantComplianceDemo = action({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ runId: Id<"financialOperatorRuns"> }> => { + const goal = `Check ${COVENANT_FIXTURE.meta.borrower} against the leverage covenant in the credit agreement.`; + + const runId: Id<"financialOperatorRuns"> = await ctx.runMutation( + api.domains.financialOperator.runOps.createRun, + { + userId: args.userId, + threadId: args.threadId, + taskType: "covenant_compliance", + goal, + files: [ + { name: COVENANT_FIXTURE.meta.creditAgreementFile, kind: "pdf" }, + { name: COVENANT_FIXTURE.meta.financialsFile, kind: "xlsx" }, + ], + totalSteps: 9, + }, + ); + + // 1. Plan + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "planning", + }); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "run_brief", + status: "complete", + title: "Plan", + payload: { + goal, + numberedSteps: [ + "Locate covenant + definition sections in credit agreement", + "Extract covenant threshold and ratio definition", + "Extract financial inputs from Q4 financials", + "Validate inputs against covenant definitions", + "Compute leverage ratio in sandbox", + "Check compliance against threshold", + "Produce reviewer-ready compliance memo", + ], + estimatedDurationMs: 5500, + outputFormat: "Compliance memo with verdict + reviewer notes", + } satisfies RunBriefPayload, + }); + + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "running", + }); + + // 2. Locate sections + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Locate covenant sections", + payload: { + toolName: "document.locate_sections", + inputSummary: `${COVENANT_FIXTURE.meta.creditAgreementFile} → leverage covenant + EBITDA + debt definitions`, + outputSummary: COVENANT_FIXTURE.sections + .map((s) => `${s.label} (p.${s.page})`) + .join("; "), + } satisfies ToolCallPayload, + }); + + // 3. Extract covenant terms + await sleep(STEP_PACING_MS); + const covenantTermsFields: ExtractedField[] = [ + { + fieldName: "Covenant name", + value: COVENANT_FIXTURE.covenant.name, + unit: "string", + sourceRef: "Credit Agreement p.42", + confidence: 0.97, + status: "verified", + }, + { + fieldName: "Threshold", + value: COVENANT_FIXTURE.covenant.threshold, + unit: "ratio_x", + sourceRef: "Credit Agreement p.42", + confidence: 0.97, + status: "verified", + }, + { + fieldName: "Ratio definition", + value: COVENANT_FIXTURE.covenant.ratioType, + unit: "string", + sourceRef: "Credit Agreement p.87 + p.91", + confidence: 0.93, + status: "verified", + }, + ]; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: "complete", + title: "Covenant terms", + payload: { + schemaName: "covenant_terms", + fields: covenantTermsFields, + totalFound: covenantTermsFields.length, + needsReviewCount: 0, + } satisfies ExtractionPayload, + }); + + // 4. Extract financial inputs + await sleep(STEP_PACING_MS); + const inputFields: ExtractedField[] = [ + { + fieldName: "Total Debt", + value: COVENANT_FIXTURE.inputs.totalDebt.value, + unit: "USD", + sourceRef: COVENANT_FIXTURE.inputs.totalDebt.sourceRef, + confidence: COVENANT_FIXTURE.inputs.totalDebt.confidence, + status: classifyStatus(COVENANT_FIXTURE.inputs.totalDebt.confidence), + }, + { + fieldName: "Cash", + value: COVENANT_FIXTURE.inputs.cash.value, + unit: "USD", + sourceRef: COVENANT_FIXTURE.inputs.cash.sourceRef, + confidence: COVENANT_FIXTURE.inputs.cash.confidence, + status: classifyStatus(COVENANT_FIXTURE.inputs.cash.confidence), + }, + { + fieldName: "Adjusted EBITDA", + value: COVENANT_FIXTURE.inputs.adjustedEBITDA.value, + unit: "USD", + sourceRef: COVENANT_FIXTURE.inputs.adjustedEBITDA.sourceRef, + confidence: COVENANT_FIXTURE.inputs.adjustedEBITDA.confidence, + status: classifyStatus(COVENANT_FIXTURE.inputs.adjustedEBITDA.confidence), + reviewNote: + "EBITDA add-backs should be reviewed against the credit agreement's permitted-add-back schedule.", + }, + ]; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: "needs_review", + title: "Financial inputs", + payload: { + schemaName: "covenant_inputs", + fields: inputFields, + totalFound: inputFields.length, + needsReviewCount: inputFields.filter((f) => f.status === "needs_review").length, + } satisfies ExtractionPayload, + }); + + // 5. Validation + await sleep(STEP_PACING_MS); + const validation = validateExtraction({ + fields: inputFields, + spec: COVENANT_INPUT_SPEC, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "validation", + status: validation.schemaPassed ? "complete" : "error", + title: "Validate covenant inputs", + payload: validation satisfies ValidationPayload, + }); + + // 6. Compute leverage + compliance check + await sleep(STEP_PACING_MS); + const lev = computeLeverageRatio({ + totalDebt: COVENANT_FIXTURE.inputs.totalDebt.value, + cash: COVENANT_FIXTURE.inputs.cash.value, + ebitda: COVENANT_FIXTURE.inputs.adjustedEBITDA.value, + }); + const compliance = checkCompliance({ + observedRatio: lev.outputs.ratio, + threshold: COVENANT_FIXTURE.covenant.threshold, + ratioName: "net_leverage", + }); + const calcPayload: CalculationPayload = { + formulaLabel: "Net leverage ratio + compliance gate", + formulaText: [lev.formulaText, compliance.formulaText].join("\n"), + inputs: { + totalDebt: COVENANT_FIXTURE.inputs.totalDebt.value, + cash: COVENANT_FIXTURE.inputs.cash.value, + ebitda: COVENANT_FIXTURE.inputs.adjustedEBITDA.value, + threshold: COVENANT_FIXTURE.covenant.threshold, + }, + outputs: { + netDebt: lev.outputs.netDebt, + ratio: lev.outputs.ratio, + threshold: COVENANT_FIXTURE.covenant.threshold, + compliant: compliance.outputs.compliant, + headroom: compliance.outputs.headroom, + }, + formattedOutputs: { + netDebt: lev.formattedOutputs.netDebt, + ratio: lev.formattedOutputs.ratio, + threshold: `${COVENANT_FIXTURE.covenant.threshold.toFixed(2)}x`, + compliant: compliance.formattedOutputs.compliant, + headroom: compliance.formattedOutputs.headroom, + }, + sandboxKind: "js_pure", + computedAt: Date.now(), + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "calculation", + status: "complete", + title: "Sandbox compute + compliance gate", + payload: calcPayload, + }); + + // 7. Evidence + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "evidence", + status: "complete", + title: "Source anchors", + payload: { + anchors: COVENANT_FIXTURE.excerpts.map((e) => ({ + label: COVENANT_FIXTURE.sections.find((s) => + e.sourceRef.includes(`p.${s.page}`), + )?.label ?? "Source", + sourceRef: e.sourceRef, + excerpt: e.excerpt, + })), + totalSources: COVENANT_FIXTURE.excerpts.length, + } satisfies EvidencePayload, + }); + + // 8. Artifact + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "artifact", + status: "complete", + title: "Compliance memo", + payload: { + kind: "memo", + label: `${COVENANT_FIXTURE.meta.borrower} — Q4 Leverage Covenant Review`, + description: + "Memo summarizes compliance verdict, computed ratio, threshold, and review items.", + diffSummary: [ + `Verdict: ${compliance.outputs.compliant === 1 ? "Compliant" : "Breach"}`, + `Observed ratio: ${lev.formattedOutputs.ratio} vs ${COVENANT_FIXTURE.covenant.threshold.toFixed(2)}x cap`, + "Reviewer note: confirm EBITDA add-backs", + ], + } satisfies ArtifactPayload, + }); + + // 9. Result OR approval + await sleep(STEP_PACING_MS); + const isCompliant = compliance.outputs.compliant === 1; + if (isCompliant) { + const result: ResultPayload = { + headline: `${COVENANT_FIXTURE.meta.borrower} compliant: ${lev.formattedOutputs.ratio} vs ${COVENANT_FIXTURE.covenant.threshold.toFixed(2)}x cap (${compliance.formattedOutputs.headroom} headroom).`, + prose: + "Net leverage was computed deterministically from total debt minus unrestricted cash, divided by adjusted EBITDA. EBITDA add-backs require human confirmation before this verdict is locked into the lender package.", + metrics: { + "Net debt": lev.formattedOutputs.netDebt, + "Adjusted EBITDA": `$${(COVENANT_FIXTURE.inputs.adjustedEBITDA.value / 1_000_000).toFixed(1)}M`, + "Leverage ratio": lev.formattedOutputs.ratio, + "Covenant threshold": `${COVENANT_FIXTURE.covenant.threshold.toFixed(2)}x`, + "Verdict": "Compliant", + }, + openIssues: ["EBITDA add-backs require human confirmation before lender sign-off."], + nextActions: [ + { id: "open_memo", label: "Open memo", kind: "open" }, + { id: "review_addbacks", label: "Review add-backs", kind: "open" }, + { id: "export_lender_pack", label: "Export lender summary", kind: "export" }, + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "result", + status: "complete", + title: "Result — compliant", + payload: result, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "completed", + finalSummary: result.headline, + }); + } else { + // Breach → ask for explicit reviewer approval before sending notice. + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "approval_request", + status: "pending", + title: "Reviewer approval — covenant breach", + payload: { + question: "Approve formal breach notice to lender?", + context: `Net leverage ${lev.formattedOutputs.ratio} exceeds ${COVENANT_FIXTURE.covenant.threshold.toFixed(2)}x cap.`, + options: [ + { id: "approve", label: "Approve breach notice", description: "Generate lender notification." }, + { id: "narrow", label: "Re-extract EBITDA add-backs", description: "Tighter pass over add-back schedule." }, + { id: "reject", label: "Hold", description: "Do not send notice; leave run as needs_review." }, + ], + consequences: { + approve: "Generates lender notification + flags portfolio.", + narrow: "Re-runs EBITDA extraction; recompute may bring ratio under cap.", + reject: "Run held in awaiting-approval state.", + }, + } satisfies ApprovalRequestPayload, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "awaiting_approval", + }); + } + + return { runId }; + }, +}); + +/* ================================================================== */ +/* EXAMPLE D — VARIANCE ANALYSIS */ +/* ================================================================== */ + +export const runVarianceAnalysisDemo = action({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ runId: Id<"financialOperatorRuns"> }> => { + const goal = `Compare ${VARIANCE_FIXTURE.period} actuals vs budget; surface top variances; draft a CFO summary.`; + + const runId: Id<"financialOperatorRuns"> = await ctx.runMutation( + api.domains.financialOperator.runOps.createRun, + { + userId: args.userId, + threadId: args.threadId, + taskType: "variance_analysis", + goal, + files: VARIANCE_FIXTURE.files.map((f) => ({ name: f.name, kind: f.kind })), + totalSteps: 8, + }, + ); + + // 1. Plan + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "planning", + }); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "run_brief", + status: "complete", + title: "Plan", + payload: { + goal, + numberedSteps: [ + "Inspect actuals + budget files", + "Align chart of accounts", + "Compute variance per account in sandbox", + "Surface top favorable and unfavorable lines", + "Pull qualitative driver context", + "Draft CFO summary", + ], + estimatedDurationMs: 4500, + outputFormat: "CFO-style variance memo", + } satisfies RunBriefPayload, + }); + + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "running", + }); + + // 2. Inspect + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Inspect spreadsheets", + payload: { + toolName: "spreadsheet.inspect", + inputSummary: VARIANCE_FIXTURE.files.map((f) => f.name).join(", "), + outputSummary: `Periods: Jan/Feb/${VARIANCE_FIXTURE.period}; currency USD`, + } satisfies ToolCallPayload, + }); + + // 3. Align accounts + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Align chart of accounts", + payload: { + toolName: "finance.align_accounts", + inputSummary: "Match by accountName + accountCode", + outputSummary: `Matched ${VARIANCE_FIXTURE.alignment.matchedAccounts}; ${VARIANCE_FIXTURE.alignment.unmatchedActuals + VARIANCE_FIXTURE.alignment.unmatchedBudget} need mapping review`, + } satisfies ToolCallPayload, + }); + + // 4. Compute variance per line in sandbox + await sleep(STEP_PACING_MS); + const variances = VARIANCE_FIXTURE.lines.map((line) => { + const r = computeVariance({ actual: line.actual, budget: line.budget }); + return { line, variance: r }; + }); + const topFavorable = variances + .filter((v) => v.variance.outputs.variance > 0 && v.line.category === "revenue") + .sort((a, b) => b.variance.outputs.variance - a.variance.outputs.variance)[0]; + const topUnfavorable = variances + .filter((v) => v.variance.outputs.variance > 0 && v.line.category !== "revenue") + .sort((a, b) => b.variance.outputs.variance - a.variance.outputs.variance)[0]; + + const calcPayload: CalculationPayload = { + formulaLabel: "Per-account variance", + formulaText: + "for each account: variance = actual - budget; variance_pct = variance / budget", + inputs: { lineCount: VARIANCE_FIXTURE.lines.length }, + outputs: { + topFavorableAmount: topFavorable?.variance.outputs.variance ?? 0, + topUnfavorableAmount: topUnfavorable?.variance.outputs.variance ?? 0, + }, + formattedOutputs: { + topFavorableAmount: topFavorable?.variance.formattedOutputs.variance ?? "$0", + topUnfavorableAmount: topUnfavorable?.variance.formattedOutputs.variance ?? "$0", + }, + sandboxKind: "js_pure", + computedAt: Date.now(), + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "calculation", + status: "complete", + title: "Variance computation", + payload: calcPayload, + }); + + // 5. Top-variances extraction + await sleep(STEP_PACING_MS); + const topLines = [...variances] + .sort( + (a, b) => + Math.abs(b.variance.outputs.variance) - Math.abs(a.variance.outputs.variance), + ) + .slice(0, 4); + const varianceFields: ExtractedField[] = topLines.map((entry) => ({ + fieldName: entry.line.account, + value: entry.variance.formattedOutputs.variance, + unit: "USD_signed", + sourceRef: `march_actuals.xlsx + fy_budget.xlsx (${entry.line.category})`, + confidence: 0.97, + status: "verified", + reviewNote: entry.line.driverNote, + })); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: "complete", + title: "Top variance lines", + payload: { + schemaName: "top_variance_lines", + fields: varianceFields, + totalFound: varianceFields.length, + needsReviewCount: 0, + } satisfies ExtractionPayload, + }); + + // 6. Driver search (tool_call) + await sleep(STEP_PACING_MS); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Pull driver context from notes", + payload: { + toolName: "notes.search_context", + inputSummary: "Search board_notes + monthly_close_notes for top variance accounts", + outputSummary: topLines + .map((l) => `${l.line.account}: ${l.line.driverNote}`) + .join(" · "), + } satisfies ToolCallPayload, + }); + + // 7. Artifact (CFO summary) + await sleep(STEP_PACING_MS); + const summaryProse = topFavorable && topUnfavorable + ? `${VARIANCE_FIXTURE.period} revenue finished ahead of budget, primarily driven by ${topFavorable.line.driverNote?.toLowerCase() ?? "favorable revenue mix"}. The main expense pressure came from ${topUnfavorable.line.account.toLowerCase()}, where ${topUnfavorable.line.driverNote?.toLowerCase() ?? "spend exceeded plan"}. Net impact remains favorable; infrastructure usage should be monitored before scaling additional workflows.` + : "Variance memo drafted from current actuals."; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "artifact", + status: "complete", + title: "CFO variance memo", + payload: { + kind: "memo", + label: `${VARIANCE_FIXTURE.period} Variance Memo`, + description: summaryProse, + diffSummary: [ + `Top favorable: ${topFavorable?.line.account ?? "—"} ${topFavorable?.variance.formattedOutputs.variance ?? ""}`, + `Top unfavorable: ${topUnfavorable?.line.account ?? "—"} ${topUnfavorable?.variance.formattedOutputs.variance ?? ""}`, + `Unmatched accounts to map: ${VARIANCE_FIXTURE.alignment.unmatchedActuals + VARIANCE_FIXTURE.alignment.unmatchedBudget}`, + ], + } satisfies ArtifactPayload, + }); + + // 8. Result + await sleep(STEP_PACING_MS); + const result: ResultPayload = { + headline: `${VARIANCE_FIXTURE.period} variance: revenue ${topFavorable?.variance.formattedOutputs.variance ?? "+$0"}; biggest cost overrun ${topUnfavorable?.line.account ?? "n/a"} (${topUnfavorable?.variance.formattedOutputs.variance ?? ""}).`, + prose: summaryProse, + metrics: { + "Top favorable": topFavorable?.variance.formattedOutputs.variance ?? "$0", + "Top favorable %": topFavorable?.variance.formattedOutputs.variancePct ?? "0%", + "Top unfavorable": topUnfavorable?.variance.formattedOutputs.variance ?? "$0", + "Top unfavorable %": topUnfavorable?.variance.formattedOutputs.variancePct ?? "0%", + }, + openIssues: + VARIANCE_FIXTURE.alignment.unmatchedActuals + VARIANCE_FIXTURE.alignment.unmatchedBudget > 0 + ? [`${VARIANCE_FIXTURE.alignment.unmatchedActuals + VARIANCE_FIXTURE.alignment.unmatchedBudget} unmatched accounts need mapping review before this memo is finalized.`] + : [], + nextActions: [ + { id: "open_memo", label: "Open variance memo", kind: "open" }, + { id: "review_unmatched", label: "Review unmatched accounts", kind: "open" }, + { id: "export_table", label: "Export variance table", kind: "export" }, + { id: "create_slide", label: "Create board slide", kind: "follow_up" }, + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "result", + status: "complete", + title: "Result", + payload: result, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "completed", + finalSummary: result.headline, + }); + + return { runId }; + }, +}); diff --git a/convex/domains/financialOperator/realExtractors.ts b/convex/domains/financialOperator/realExtractors.ts new file mode 100644 index 000000000..e603fa534 --- /dev/null +++ b/convex/domains/financialOperator/realExtractors.ts @@ -0,0 +1,519 @@ +/** + * Real PDF extractors — production-grade replacements for the demo fixtures. + * + * Pattern: Claude / Gemini accept PDFs as document input directly, so we + * skip the parse-text-then-prompt pipeline entirely. The PDF goes in, + * structured JSON comes out. The model is constrained by an explicit + * field schema and instructed never to fabricate values — if it can't + * find a number, it returns null with confidence 0. + * + * Math runs in JS sandbox after extraction (sandbox.ts), per the + * scratchpad-first invariant in .claude/rules/scratchpad_first.md. + * + * IMPORTANT: this is the production path. Fixtures (attFixture.ts etc) + * remain as the deterministic demo path. The orchestrator can call + * either one without changing its step-emission pattern. + */ + +import { v } from "convex/values"; +import { action } from "../../_generated/server"; +import { api } from "../../_generated/api"; +import type { Id } from "../../_generated/dataModel"; +import Anthropic from "@anthropic-ai/sdk"; + +import { + computeAfterTaxCostOfDebt, + computeETR, +} from "./sandbox"; +import { validateExtraction, type FieldSpec } from "./validators"; +import type { + ApprovalRequestPayload, + ArtifactPayload, + CalculationPayload, + EvidencePayload, + ExtractedField, + ExtractionPayload, + ResultPayload, + RunBriefPayload, + ToolCallPayload, + ValidationPayload, +} from "./types"; + +const STEP_PACING_MS = 250; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const MAX_PDF_BYTES = 20 * 1024 * 1024; // BOUND_READ — 20MB cap + +const TAX_AND_DEBT_SPEC: FieldSpec[] = [ + { + fieldName: "Income before income taxes", + expectedUnit: "USD_millions", + required: true, + sanityRange: { min: 0, max: 5_000_000 }, + }, + { + fieldName: "Income tax expense", + expectedUnit: "USD_millions", + required: true, + sanityRange: { min: 0, max: 1_000_000 }, + }, + { + fieldName: "Weighted average debt rate", + expectedUnit: "decimal", + required: true, + sanityRange: { min: 0, max: 0.5 }, + }, +]; + +interface ClaudeExtractedField { + fieldName: string; + value: number | null; + unit: string; + sourceRef: string; // "10-K p.72" or similar + excerpt: string; // verbatim quote anchoring the value + confidence: number; // 0..1, model-reported + notes?: string; +} + +interface ClaudeExtractionResponse { + fields: ClaudeExtractedField[]; + unresolvedFields: string[]; + modelConfidenceOverall: number; +} + +const SYSTEM_PROMPT = `You extract financial values from SEC filings (10-K, 10-Q) for a deterministic operator console. + +RULES: +1. NEVER fabricate or guess. If a value is not explicitly in the document, return null with confidence 0 and add it to unresolvedFields. +2. NEVER do math. Return raw values only. Effective tax rates and cost of debt are computed downstream in a JS sandbox. +3. Always cite the page number and a short verbatim excerpt for every value. +4. Confidence reflects how clearly the value is stated. 0.95+ = exact match in a labeled row. 0.70-0.94 = present but ambiguous unit/period. <0.70 = uncertain. +5. Units: report income statement values in USD_millions (e.g. 22450 means $22.45B). Report rates as decimal (e.g. 0.0542 for 5.42%). +6. Output JSON ONLY. No prose.`; + +const FIELD_SCHEMA = `{ + "fields": [ + { + "fieldName": string, // Must match one of: ${TAX_AND_DEBT_SPEC.map((s) => JSON.stringify(s.fieldName)).join(", ")} + "value": number | null, + "unit": "USD_millions" | "decimal", + "sourceRef": string, // e.g. "10-K p.72" + "excerpt": string, // verbatim quote, max 200 chars + "confidence": number, // 0..1 + "notes": string | null + } + ], + "unresolvedFields": string[], + "modelConfidenceOverall": number +}`; + +function getAnthropicKey(): string { + const key = process.env.ANTHROPIC_API_KEY; + if (!key) { + throw new Error( + "ANTHROPIC_API_KEY not configured for the Convex deployment. Set it via `npx convex env set ANTHROPIC_API_KEY ...`.", + ); + } + return key; +} + +/** + * Extract tax-and-debt inputs from a PDF stored in Convex `_storage`. + * + * Returns ExtractedField[] in the SAME shape the fixture extractor + * returns, so the orchestrator can call either path without branching. + */ +async function extractTaxAndDebtFromPdf( + pdfBase64: string, +): Promise<{ + fields: ExtractedField[]; + modelConfidenceOverall: number; + unresolvedFields: string[]; +}> { + const client = new Anthropic({ apiKey: getAnthropicKey() }); + + const response = await client.messages.create({ + model: "claude-opus-4-7", + max_tokens: 2000, + system: SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: pdfBase64, + }, + }, + { + type: "text", + text: `Extract these fields and return JSON in exactly this shape:\n${FIELD_SCHEMA}\n\nFields to extract:\n${TAX_AND_DEBT_SPEC.map((s) => `- ${s.fieldName} (${s.expectedUnit})`).join("\n")}`, + }, + ], + }, + ], + }); + + // Parse JSON from the first text block. Claude with system instruction + // "Output JSON ONLY" returns a single text block with the JSON. + const block = response.content.find((b) => b.type === "text"); + if (!block || block.type !== "text") { + throw new Error("Claude response had no text block"); + } + const jsonText = block.text.trim(); + // Defensive: strip code fences if present. + const cleaned = jsonText + .replace(/^```json\s*/i, "") + .replace(/^```\s*/i, "") + .replace(/```\s*$/i, "") + .trim(); + + let parsed: ClaudeExtractionResponse; + try { + parsed = JSON.parse(cleaned) as ClaudeExtractionResponse; + } catch (e) { + throw new Error( + `Claude returned non-JSON (HONEST_STATUS — better to fail than fabricate). First 200 chars: ${cleaned.slice(0, 200)}`, + ); + } + + // Map Claude's response to our ExtractedField shape, classifying status. + const fields: ExtractedField[] = parsed.fields.map((f) => ({ + fieldName: f.fieldName, + value: f.value, + unit: f.unit, + sourceRef: f.sourceRef, + confidence: typeof f.confidence === "number" ? f.confidence : 0, + status: + f.value === null + ? "unresolved" + : f.confidence >= 0.9 + ? "verified" + : f.confidence >= 0.5 + ? "needs_review" + : "unresolved", + reviewNote: f.notes ?? undefined, + })); + + return { + fields, + modelConfidenceOverall: parsed.modelConfidenceOverall ?? 0, + unresolvedFields: parsed.unresolvedFields ?? [], + }; +} + +/** + * End-to-end real run: takes a PDF storageId, runs Claude extraction, + * validates, computes in sandbox, emits the same typed step stream as + * the fixture demo. This is the production path. + * + * Caller flow (typical): user uploads PDF → file goes to Convex + * `_storage` → caller passes the resulting `Id<"_storage">` here. + */ +export const runRealCostOfDebtFromPdf = action({ + args: { + userId: v.optional(v.id("users")), + threadId: v.optional(v.string()), + pdfStorageId: v.id("_storage"), + pdfFileName: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ runId: Id<"financialOperatorRuns"> }> => { + const fileName = args.pdfFileName ?? "uploaded.pdf"; + const goal = `Extract ETR + after-tax cost of debt from ${fileName} (real PDF, Claude extraction, sandbox compute).`; + + // 1. Create run + const runId: Id<"financialOperatorRuns"> = await ctx.runMutation( + api.domains.financialOperator.runOps.createRun, + { + userId: args.userId, + threadId: args.threadId, + taskType: "financial_metric_extraction", + goal, + files: [{ name: fileName, kind: "pdf" }], + totalSteps: 7, + }, + ); + + // 2. Plan + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "planning", + }); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "run_brief", + status: "complete", + title: "Plan", + payload: { + goal, + numberedSteps: [ + "Fetch uploaded PDF from storage", + "Send PDF to Claude with structured extraction schema", + "Validate extracted fields (schema + units + range)", + "Compute ETR + after-tax cost of debt in JS sandbox", + "Surface source excerpts as evidence", + "Emit a notebook artifact + reviewer summary", + ], + outputFormat: "Reviewable notebook + sandbox-locked calculation", + } satisfies RunBriefPayload, + }); + + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "running", + }); + + try { + // 3. Fetch PDF from storage + await sleep(STEP_PACING_MS); + const fetchStart = Date.now(); + const pdfBlob = await ctx.storage.get(args.pdfStorageId); + if (!pdfBlob) { + throw new Error(`PDF not found in storage: ${args.pdfStorageId}`); + } + const pdfBuffer = await pdfBlob.arrayBuffer(); + // BOUND_READ — refuse oversized PDFs to protect the LLM call + memory. + if (pdfBuffer.byteLength > MAX_PDF_BYTES) { + throw new Error( + `PDF too large: ${pdfBuffer.byteLength} bytes (max ${MAX_PDF_BYTES}). Crop to the relevant sections first.`, + ); + } + const pdfBase64 = Buffer.from(pdfBuffer).toString("base64"); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Fetch PDF from storage", + payload: { + toolName: "convex.storage.get", + inputSummary: `storageId=${args.pdfStorageId}`, + outputSummary: `${pdfBuffer.byteLength.toLocaleString()} bytes`, + } satisfies ToolCallPayload, + durationMs: Date.now() - fetchStart, + }); + + // 4. Extraction via Claude + await sleep(STEP_PACING_MS); + const extractStart = Date.now(); + const { fields, modelConfidenceOverall, unresolvedFields } = + await extractTaxAndDebtFromPdf(pdfBase64); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "complete", + title: "Claude PDF extraction", + payload: { + toolName: "anthropic.messages.create (PDF input)", + inputSummary: `${TAX_AND_DEBT_SPEC.length} target fields, structured-JSON schema`, + outputSummary: `${fields.length} fields returned; overall confidence ${modelConfidenceOverall.toFixed(2)}; ${unresolvedFields.length} unresolved`, + } satisfies ToolCallPayload, + durationMs: Date.now() - extractStart, + }); + + const extractionPayload: ExtractionPayload = { + schemaName: "tax_and_debt_inputs", + fields, + totalFound: fields.length, + needsReviewCount: fields.filter((f) => f.status === "needs_review").length, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "extraction", + status: + extractionPayload.needsReviewCount > 0 + ? "needs_review" + : fields.some((f) => f.status === "unresolved") + ? "error" + : "complete", + title: "Extracted values", + payload: extractionPayload, + }); + + // 5. Validation + await sleep(STEP_PACING_MS); + const validation = validateExtraction({ + fields, + spec: TAX_AND_DEBT_SPEC, + }); + const validationPayload: ValidationPayload = { + schemaPassed: validation.schemaPassed, + unitsNormalized: validation.unitsNormalized, + findings: validation.findings, + checksRun: validation.checksRun, + checksPassed: validation.checksPassed, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "validation", + status: validation.schemaPassed ? "complete" : "error", + title: "Validate extraction", + payload: validationPayload, + }); + if (!validation.schemaPassed) { + await ctx.runMutation( + api.domains.financialOperator.runOps.updateRunStatus, + { + runId, + status: "awaiting_approval", + }, + ); + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "approval_request", + status: "pending", + title: "Required fields missing — operator review", + payload: { + question: "Required fields could not be extracted. How should we proceed?", + context: `Unresolved: ${unresolvedFields.join(", ") || "—"}. Sandbox cannot compute without these inputs.`, + options: [ + { id: "narrow", label: "Re-extract with narrower section hints", description: "Re-prompt Claude with explicit page hints." }, + { id: "override", label: "Manual entry", description: "Operator types the missing values; sandbox compute proceeds." }, + { id: "reject", label: "Mark run failed", description: "No artifact saved." }, + ], + } satisfies ApprovalRequestPayload, + }); + return { runId }; + } + + // 6. Sandbox compute + await sleep(STEP_PACING_MS); + const ibt = (fields.find((f) => f.fieldName === "Income before income taxes") + ?.value ?? 0) as number; + const ite = (fields.find((f) => f.fieldName === "Income tax expense") + ?.value ?? 0) as number; + const debtRate = (fields.find((f) => f.fieldName === "Weighted average debt rate") + ?.value ?? 0) as number; + const etrR = computeETR({ incomeBeforeTaxes: ibt, incomeTaxExpense: ite }); + const atR = computeAfterTaxCostOfDebt({ + preTaxDebtRate: debtRate, + effectiveTaxRate: etrR.outputs.etr, + }); + const calcPayload: CalculationPayload = { + formulaLabel: "Effective tax rate + after-tax cost of debt", + formulaText: [etrR.formulaText, atR.formulaText].join("\n"), + inputs: { + incomeBeforeTaxes: ibt, + incomeTaxExpense: ite, + preTaxDebtRate: debtRate, + }, + outputs: { + effectiveTaxRate: etrR.outputs.etr, + afterTaxCostOfDebt: atR.outputs.afterTaxCostOfDebt, + }, + formattedOutputs: { + effectiveTaxRate: etrR.formattedOutputs.etr, + afterTaxCostOfDebt: atR.formattedOutputs.afterTaxCostOfDebt, + }, + sandboxKind: "js_pure", + computedAt: Date.now(), + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "calculation", + status: "complete", + title: "Sandbox calculation", + payload: calcPayload, + }); + + // 7. Evidence (excerpts already on each ExtractedField via reviewNote / sourceRef) + await sleep(STEP_PACING_MS); + const anchors = fields + .filter((f) => f.value !== null) + .map((f) => ({ + label: f.fieldName, + sourceRef: f.sourceRef, + excerpt: f.reviewNote, + })); + const evidencePayload: EvidencePayload = { + anchors, + totalSources: anchors.length, + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "evidence", + status: "complete", + title: "Source anchors", + payload: evidencePayload, + }); + + // 8. Artifact + result + await sleep(STEP_PACING_MS); + const artifactPayload: ArtifactPayload = { + kind: "notebook", + label: `${fileName.replace(/\.pdf$/i, "")} — After-Tax Cost of Debt`, + description: + "Notebook with Claude-extracted inputs, sandbox-locked calculation, source excerpts, and reviewer notes.", + diffSummary: [ + `Source: ${fileName}`, + `Extraction: Claude (${fields.length} fields, ${unresolvedFields.length} unresolved)`, + `Sandbox: deterministic JS (no LLM math)`, + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "artifact", + status: "complete", + title: "Notebook artifact", + payload: artifactPayload, + }); + + const result: ResultPayload = { + headline: `${fileName}: ETR ${etrR.formattedOutputs.etr}; after-tax cost of debt ${atR.formattedOutputs.afterTaxCostOfDebt}.`, + prose: + "Values were extracted by Claude directly from the uploaded PDF (no intermediate parse). Math ran deterministically in JS sandbox. All values cite the source page; reviewer should verify excerpts before sign-off.", + metrics: { + "Effective tax rate": etrR.formattedOutputs.etr, + "After-tax cost of debt": atR.formattedOutputs.afterTaxCostOfDebt, + "Model confidence": modelConfidenceOverall.toFixed(2), + }, + openIssues: + unresolvedFields.length > 0 + ? [`${unresolvedFields.length} field(s) unresolved by extractor: ${unresolvedFields.join(", ")}`] + : [], + nextActions: [ + { id: "open_notebook", label: "Open notebook", kind: "open" }, + { id: "view_sources", label: "View sources", kind: "open" }, + { id: "ask_followup", label: "Ask follow-up", kind: "follow_up" }, + ], + }; + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "result", + status: "complete", + title: "Result", + payload: result, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "completed", + finalSummary: result.headline, + }); + + return { runId }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // HONEST_STATUS — surface the failure verbatim, never silently mark complete. + await ctx.runMutation(api.domains.financialOperator.runOps.appendStep, { + runId, + kind: "tool_call", + status: "error", + title: "Extraction failed", + payload: { + toolName: "anthropic.messages.create (PDF input)", + inputSummary: "PDF + structured-JSON schema", + outputSummary: "—", + } satisfies ToolCallPayload, + errorMessage: msg, + }); + await ctx.runMutation(api.domains.financialOperator.runOps.updateRunStatus, { + runId, + status: "error", + errorMessage: msg, + }); + return { runId }; + } + }, +}); diff --git a/docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md b/docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md new file mode 100644 index 000000000..db62192f9 --- /dev/null +++ b/docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md @@ -0,0 +1,157 @@ +# Financial Operator Console — Design Alignment + +How the new `/finance-demo` route, typed cards, and `FinancialOperatorOverlay` build on top of the existing NodeBench UI kit per surface (web, mobile, workspace, CLI/MCP). + +The shared discipline: **same tokens, same primitives, surface-specific entry**. No new design language; only new content types (typed cards) that compose existing primitives. + +--- + +## 1. Web (desktop, `nodebenchai.com`) + +**Existing kit baseline** +- Glass-DNA card: `border border-edge bg-surface/50` (utility class `.nb-card`) +- Card row: `.nb-card-row` +- Section header: `text-[11px] font-semibold uppercase tracking-[0.18em] text-content-muted` (`.nb-section-title`) +- Pill: `.nb-badge` (border + bg/5 fill) +- Color tokens: `border-edge`, `bg-surface`, `bg-surface-hover`, `text-content`, `text-content-secondary`, `text-content-muted` +- Accent: terracotta `#d97757` for selected/CTA states +- Type: Manrope (UI) + JetBrains Mono (data, formulas, tool names) +- Background: `--bg-primary: #151413` + +**What the financial cards reuse (verbatim)** +- Every card body uses `.nb-card` chrome with a colored left-border accent stripe per `kind` (`border-l-emerald-400` for calculation, `border-l-[#d97757]` for approval, etc.). The accent stripe is a 1-line addition; everything else is the existing card class. +- Badges use `.nb-badge` shape (rounded-full + border + small text). +- Section headers (`Plan`, `Tool`, `Extraction`, …) follow the existing `text-[11px] uppercase tracking-[0.2em]` pattern. +- Mono-font for: tool names (`document.locate_sections`), formula text, sequence numbers, source refs. +- Standard `max-w-3xl mx-auto p-6` layout matches `/developers`, `/changelog`, etc. + +**What's new (purely additive)** +- 9 typed step kinds (`run_brief`, `tool_call`, `extraction`, `validation`, `calculation`, `evidence`, `artifact`, `approval_request`, `result`) — each is a new content shape, but all rendered through `.nb-card` chrome. +- Status pills in 7 states (`pending`, `running`, `complete`, `error`, `needs_review`, `approved`, `rejected`) — color-coded but use existing badge chrome. +- 1 sandbox-disclosure note ("Math executed in JS sandbox, not by the language model.") — emerald accent, single instance per calculation card. + +**Where it lives on web** +- Standalone view: `/finance-demo` (aliases `/financial-operator`, `/finops`) — full-width 4-tile picker + live timeline. +- Global overlay: `` mounted in `App.tsx`. Reads `?finRun=` from URL → renders timeline as a right-side drawer alongside whatever surface the user is on. Collapsible to a corner pill. +- No changes to existing routes' visual language; the overlay docks beside them. + +--- + +## 2. Mobile (375 px viewport) + +**Existing kit baseline** +- Bottom nav (Home/Reports/Chat/Inbox/Me) — `xl:hidden`, fixed bottom +- Agent panel: full-screen slide-over on `xl:hidden` +- `QuickCommandChips`: mobile-only chip strip above input, dispatches `cmd.query` on tap +- Capture FAB +- All grids degrade to `grid-cols-1` below `sm:` + +**What financial cards do at this width** +- All cards stack vertically (cards are `space-y-3` lists by default). +- Approval card grid: `sm:grid-cols-2` → falls back to single column on phones, so 4 options stack. +- Calculation inputs/outputs: `sm:grid-cols-2` → single column on small. Long mono formulas use `whitespace-pre-wrap` so they wrap rather than horizontal-scroll. +- The overlay drawer is `max-w-md` on phones (~448 px) and full-screen-width-with-margin on truly small viewports — matches the existing agent slide-over behavior. + +**Mobile-only entry point** +- Added to `QuickCommandChips`: `{ id: "fin-att-demo", label: "AT&T cost of debt", icon: Calculator, navigate: "/finance-demo" }`. +- The chip type was extended with an optional `navigate?: string` field (single-line schema change). When set, click navigates instead of dispatching the chat query — same fork the existing chip flow already understood, just a new branch. +- Chip placement is identical to all existing mobile chips (above input, scroll-x). + +**What's intentionally NOT mobile** +- The picker grid on `/finance-demo` collapses fine, but reading 9 cards on a phone is dense. Mobile users should generally trigger from the chip → see the run in the right-drawer overlay, not the standalone picker. + +--- + +## 3. Workspace (`workspace.nodebenchai.com`) + +**Existing kit baseline** +- Workspace is a separate deployed surface (its own subdomain alias) with its own layout: ExactChatSurface, ProductTopNav, document-as-canvas pattern. +- Reports/notebooks live as documents in `convex/schema.ts:documents`. +- Workspace tabs (`brief`, `cards`, `notebook`, `sources`, `chat`, `map`) are typed in `productEventWorkspaces.defaultTabs`. + +**Current state of the financial operator on Workspace** +- The `FinancialOperatorOverlay` is mounted in `App.tsx` and is surface-agnostic — it works on workspace.nodebenchai.com just by appending `?finRun=` to any URL. +- The artifact card is shaped to drop a notebook entry — but the `artifactRef` field is currently unused. The wire-up to actually create a `documents` row + back-link the run lives in a follow-up. + +**Planned (next PR)** +- `Artifact.kind === "notebook" | "memo"` will create a `documents` row tagged `kind: "financial_run"` and store the runId on it. Workspace's existing notebook tab can then surface the financial runs as document entries. +- A new workspace tab `financial` would render `FinancialOperatorTimeline` for runs scoped to a given event workspace — same component, new mount point. + +**Token-level fit** +- Workspace already uses the same `nb-card`/`border-edge`/etc tokens, so the cards drop in without restyling. +- The overlay's `bg-[#151413]/95 backdrop-blur-md` matches the workspace dark stage. + +--- + +## 4. CLI / MCP (`packages/mcp-local`, `nodebench-mcp`) + +**Existing kit baseline** +- ~304 MCP tools, gated by preset (`starter`, `founder`, `banker`, `operator`, `researcher`, `web_dev`, `data`, `full`). +- Tool schema: `{ name, description, inputSchema, handler }` (the `McpTool` type). +- CLI subcommands: `discover`, `setup`, `workflow`, `quickref`, `call`, `demo`. +- Progressive discovery: each tool entry has `nextTools` + `relatedTools` for one-hop navigation. + +**Status of the financial operator on CLI** +- Currently the financial actions exist server-side as Convex actions (`runAttCostOfDebtDemo`, `runCrmCleanupDemo`, `runCovenantComplianceDemo`, `runVarianceAnalysisDemo`, `runRealCostOfDebtFromPdf`). +- They are NOT yet exposed as MCP tools — agents (Claude Code, Cursor, Windsurf) cannot drive runs from outside the browser today. + +**Planned exposure (next PR — sketched here for design parity)** + +Surface as 4 demo tools + 1 production tool + 4 helpers: + +| Tool name | Purpose | Returns | +|---|---|---| +| `finance_start_att_demo` | Trigger Example A run | `{ runId }` | +| `finance_start_crm_cleanup` | Trigger Example B run | `{ runId }` | +| `finance_start_covenant_compliance` | Trigger Example C run | `{ runId }` | +| `finance_start_variance_analysis` | Trigger Example D run | `{ runId }` | +| `finance_extract_from_pdf` | Production: take a PDF storageId, return runId | `{ runId }` | +| `finance_get_run` | Inspect run header | `{ runId, status, finalSummary, … }` | +| `finance_list_steps` | Get the typed step stream | `Step[]` | +| `finance_record_decision` | Approve/reject/override an approval gate | `{ stepId, status }` | +| `finance_open_in_chat` | Return a deep-link URL `/?surface=ask&finRun=` | `{ url }` | + +These would live in `packages/mcp-local/src/tools/financialOperatorTools.ts`, register under domain key `finance_ops`, and slot into the `banker` and `operator` presets by default. Each tool lists `relatedTools` pointing to the other tools in the same chain (e.g. `finance_start_att_demo.relatedTools = ["finance_get_run", "finance_list_steps", "finance_record_decision"]`). + +**Token-level fit** +- CLI doesn't have visual tokens — it's text. The "design alignment" for CLI is the **schema language**: same `kind` enum, same status enum, same field names as the frontend cards. So an MCP client can render its own CLI representation of a step stream and the text layout maps 1:1 to what the web cards render. +- Output formatting in `finance_list_steps` should reuse the same labels (`Plan`, `Tool`, `Extraction`, …) so a Claude Code session reads the same vocabulary the web user sees. + +--- + +## Composition matrix + +| Surface | Existing primitive reused | New primitive added | Entry point | +|---|---|---|---| +| Web (desktop) | `.nb-card`, `.nb-section-title`, `.nb-badge`, terracotta accent, Manrope/JetBrains Mono | 9 typed step shapes; sandbox disclosure; overlay drawer | `/finance-demo` route + global overlay | +| Mobile | `QuickCommandChips`, slide-over drawer, `xl:hidden` bottom nav | Chip `navigate` field; overlay max-w-md | Mobile chip → overlay | +| Workspace | Document model, `productEventWorkspaces` tabs, ExactChatSurface | (Pending) `Artifact.kind === "notebook"` writes a documents row; new `financial` workspace tab | Same overlay; future workspace tab | +| CLI / MCP | `McpTool` schema, presets, progressive discovery | (Pending) `finance_*` tool family in `finance_ops` domain | `nodebench-mcp call finance_start_att_demo` | + +The shared invariants across all four surfaces: + +1. **Same step kinds** — `run_brief / tool_call / extraction / validation / calculation / evidence / artifact / approval_request / result` everywhere. +2. **Same status enum** — `pending / running / complete / error / needs_review / approved / rejected`. +3. **Same source-attribution rule** — every value carries `sourceRef` + `confidence`. +4. **Same sandbox guarantee** — math runs in `convex/domains/financialOperator/sandbox.ts`, never in the LLM, regardless of surface. + +The result: a financial workflow that started in Claude Code via MCP can be picked up in the web overlay via deep-link, viewed mid-run on mobile, and ultimately archived as a workspace notebook — without any rendering surface needing to know the others exist. The runId + step stream is the contract. + +--- + +## Anti-patterns this design avoids + +- **Surface-specific styling** — no per-surface tweaks to the cards. If a card needs to change, it changes everywhere. +- **Parallel chat protocols** — the overlay reads URL params, which any surface already supports. No new postMessage bus, no new context provider, no new agent-panel internals. +- **Tokens duplicated as inline values** — every color/spacing reads from the existing token alias (`border-edge`, `text-content-muted`, etc). A future theme change ripples through the new cards automatically. +- **Hidden math** — the calculation card forces the sandbox disclosure on screen; agents reading the same step record see the same `sandboxKind: "js_pure"` guarantee. No surface can lie about who computed what. + +## Pointers + +- Cards: [src/features/financialOperator/components/](../../src/features/financialOperator/components/) +- Overlay: [src/features/financialOperator/components/FinancialOperatorOverlay.tsx](../../src/features/financialOperator/components/FinancialOperatorOverlay.tsx) +- Demo view: [src/features/financialOperator/views/FinancialOperatorDemo.tsx](../../src/features/financialOperator/views/FinancialOperatorDemo.tsx) +- Mobile chip: [src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx](../../src/features/agents/components/FastAgentPanel/QuickCommandChips.tsx) +- Backend orchestrators: [convex/domains/financialOperator/orchestrator.ts](../../convex/domains/financialOperator/orchestrator.ts), [orchestratorExamples.ts](../../convex/domains/financialOperator/orchestratorExamples.ts), [realExtractors.ts](../../convex/domains/financialOperator/realExtractors.ts) +- Sandbox: [convex/domains/financialOperator/sandbox.ts](../../convex/domains/financialOperator/sandbox.ts) +- Token reference: [src/shared/ui/surface-tokens.css](../../src/shared/ui/surface-tokens.css) diff --git a/src/App.tsx b/src/App.tsx index 3b416d1c7..1705ef0f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { getReportWorkspaceRouteFromPath } from "@/features/reports/lib/reportNo import type { MainView } from "@/lib/registry/viewRegistry"; import { buildCockpitPathForView } from "@/lib/registry/viewRegistry"; import { initErrorReporting } from "@/lib/errorReporting"; +import { FinancialOperatorOverlay } from "@/features/financialOperator/components/FinancialOperatorOverlay"; const ShareableMemoView = lazy(() => import("@/features/founder/views/ShareableMemoView")); const PublicEntityShareView = lazy(() => import("@/features/share/views/PublicEntityShareView")); @@ -386,6 +387,7 @@ function App() { data-mcp-compat="webmcp chrome-devtools-mcp" data-webmcp-enabled={webmcpEnabled ? "true" : "false"} > + diff --git a/src/features/financialOperator/components/FinancialOperatorOverlay.tsx b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx new file mode 100644 index 000000000..daee8d5db --- /dev/null +++ b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx @@ -0,0 +1,150 @@ +/** + * FinancialOperatorOverlay — global, surface-agnostic drawer that mounts + * the typed-card timeline next to whatever chat surface the user is on. + * + * Why a global overlay vs editing FastAgentPanel directly: + * - FastAgentPanel.tsx is 3700+ lines; surgical message-bubble edits + * have a high blast radius + * - URL-param driven state means any surface that wants to "host" a + * financial run just needs to set `?finRun=` in the URL + * - Closing/expanding is local to the overlay; chat scroll, agent + * panel state, etc are untouched + * + * Lifecycle: + * 1. User triggers a financial run anywhere (chip, button, MCP tool) + * 2. URL is updated to include `?finRun=` + * 3. This overlay listens, mounts the timeline as a right-side drawer + * 4. User can close (clears the param) or keep it docked while chatting + * + * The fixture demo view (/finance-demo) and this overlay use the SAME + * `FinancialOperatorTimeline` component — single source of truth. + */ + +import { useState, useEffect, useCallback } from "react"; +import { ChevronRight, X, Maximize2, Minimize2 } from "lucide-react"; +import type { Id } from "../../../../convex/_generated/dataModel"; +import { FinancialOperatorTimeline } from "./FinancialOperatorTimeline"; + +const URL_PARAM = "finRun"; +const STORAGE_KEY = "nb-fin-run-collapsed"; + +function readRunIdFromUrl(): Id<"financialOperatorRuns"> | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const v = params.get(URL_PARAM); + return v && v.length > 0 ? (v as Id<"financialOperatorRuns">) : null; +} + +function clearRunFromUrl() { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.delete(URL_PARAM); + window.history.replaceState({}, "", url.toString()); + // Notify listeners (popstate doesn't fire on replaceState). + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function FinancialOperatorOverlay() { + const [runId, setRunId] = useState | null>(null); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } + }); + + // Sync runId with URL (initial mount + popstate) + useEffect(() => { + setRunId(readRunIdFromUrl()); + const onPop = () => setRunId(readRunIdFromUrl()); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + // Persist collapse state + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, collapsed ? "1" : "0"); + } catch { + /* private mode */ + } + }, [collapsed]); + + const handleClose = useCallback(() => { + clearRunFromUrl(); + setRunId(null); + }, []); + + if (!runId) return null; + + // Collapsed pill — small chip in the bottom-right corner. + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +/** + * Helper for callers (chips, buttons, agent dispatchers): set the active + * run in the URL so the overlay mounts. Any caller that has a runId can + * invoke this — no direct dependency on FastAgentPanel internals. + */ +export function setActiveFinancialRun( + runId: Id<"financialOperatorRuns"> | string, +) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.set(URL_PARAM, String(runId)); + window.history.replaceState({}, "", url.toString()); + window.dispatchEvent(new PopStateEvent("popstate")); +} diff --git a/src/features/financialOperator/index.ts b/src/features/financialOperator/index.ts index d60f32a4d..815c4fde5 100644 --- a/src/features/financialOperator/index.ts +++ b/src/features/financialOperator/index.ts @@ -1,6 +1,10 @@ export { FinancialOperatorDemo } from "./views/FinancialOperatorDemo"; export { FinancialOperatorTimeline } from "./components/FinancialOperatorTimeline"; export { StepCard } from "./components/StepCard"; +export { + FinancialOperatorOverlay, + setActiveFinancialRun, +} from "./components/FinancialOperatorOverlay"; export type { ApprovalRequestPayload, ArtifactKind, diff --git a/src/features/financialOperator/views/FinancialOperatorDemo.tsx b/src/features/financialOperator/views/FinancialOperatorDemo.tsx index 48987f0e2..86b93b27e 100644 --- a/src/features/financialOperator/views/FinancialOperatorDemo.tsx +++ b/src/features/financialOperator/views/FinancialOperatorDemo.tsx @@ -1,41 +1,120 @@ import { useState } from "react"; import { useAction } from "convex/react"; -import { Play } from "lucide-react"; +import { Calculator, FileSpreadsheet, ScrollText, TrendingUp, Play, MessageSquare } from "lucide-react"; import { api } from "../../../../convex/_generated/api"; import type { Id } from "../../../../convex/_generated/dataModel"; import { FinancialOperatorTimeline } from "../components/FinancialOperatorTimeline"; +import { setActiveFinancialRun } from "../components/FinancialOperatorOverlay"; /** * Standalone demo surface for the financial operator console. * - * This is the "tested live" entry point: click "Run AT&T 10-K demo", - * the orchestrator action streams typed steps, and the timeline renders - * a full operator-console chat experience (run brief → tool calls → - * extraction → validation → sandbox calculation → evidence → artifact → - * approval gate). + * Four canonical examples surface here: + * A — AT&T 10-K → ETR + after-tax cost of debt + * B — CRM cleanup (spreadsheet + PDF dedup + enrichment + CSV export) + * C — Covenant compliance (credit-agreement leverage gate) + * D — Variance analysis (actuals vs budget + CFO memo) * - * Long-term, this same timeline component will mount inside the chat - * panel (FastAgentPanel) once the agent's task classifier detects a - * financial workflow. + * Each example boots the same `FinancialOperatorTimeline` component, which + * subscribes to the run's append-only step stream and renders typed cards + * (run brief → tool calls → extraction → validation → sandbox compute → + * evidence → artifact → approval → result). + * + * Long-term, the same timeline mounts inline inside the chat panel once + * the agent's task classifier detects a financial workflow. */ + +type DemoId = "att" | "crm" | "covenant" | "variance"; + +interface DemoOption { + id: DemoId; + label: string; + blurb: string; + icon: typeof Calculator; + category: string; +} + +const DEMOS: DemoOption[] = [ + { + id: "att", + label: "AT&T 10-K — ETR & cost of debt", + blurb: + "Locate filing sections, extract structured values, run sandbox math, gather sources, draft a notebook + PR.", + icon: Calculator, + category: "Financial metric extraction", + }, + { + id: "crm", + label: "CRM cleanup", + blurb: + "Profile a 387-row prospect list, dedupe by name + domain, enrich with sector/HQ/last round, export CRM-ready CSV.", + icon: FileSpreadsheet, + category: "Data cleanup", + }, + { + id: "covenant", + label: "Covenant compliance", + blurb: + "Locate the leverage covenant, extract terms + financials, run a sandbox compliance gate, draft a reviewer memo.", + icon: ScrollText, + category: "Credit-agreement review", + }, + { + id: "variance", + label: "Variance analysis", + blurb: + "Align chart of accounts, compute per-line variance in sandbox, surface drivers, draft a CFO summary.", + icon: TrendingUp, + category: "Monthly close", + }, +]; + export function FinancialOperatorDemo() { - const runDemo = useAction( + const runAtt = useAction( api.domains.financialOperator.orchestrator.runAttCostOfDebtDemo, ); - const [runId, setRunId] = useState | null>(null); - const [isStarting, setIsStarting] = useState(false); + const runCrm = useAction( + api.domains.financialOperator.orchestratorExamples.runCrmCleanupDemo, + ); + const runCovenant = useAction( + api.domains.financialOperator.orchestratorExamples.runCovenantComplianceDemo, + ); + const runVariance = useAction( + api.domains.financialOperator.orchestratorExamples.runVarianceAnalysisDemo, + ); + + const [activeRunId, setActiveRunId] = useState | null>( + null, + ); + const [activeDemoId, setActiveDemoId] = useState(null); + const [pendingDemoId, setPendingDemoId] = useState(null); const [error, setError] = useState(null); - async function handleStart() { - setIsStarting(true); + async function handleStart(demo: DemoOption) { + setPendingDemoId(demo.id); setError(null); try { - const { runId: newRunId } = await runDemo({}); - setRunId(newRunId); + let result: { runId: Id<"financialOperatorRuns"> }; + switch (demo.id) { + case "att": + result = await runAtt({}); + break; + case "crm": + result = await runCrm({}); + break; + case "covenant": + result = await runCovenant({}); + break; + case "variance": + result = await runVariance({}); + break; + } + setActiveRunId(result.runId); + setActiveDemoId(demo.id); } catch (e) { setError(e instanceof Error ? e.message : "Failed to start run"); } finally { - setIsStarting(false); + setPendingDemoId(null); } } @@ -46,37 +125,74 @@ export function FinancialOperatorDemo() { Financial operator console — demo

- AT&T 10-K → effective tax rate & after-tax cost of debt + Watch the agent show its work

- Watch the agent locate sources, extract structured values, validate - inputs, run a deterministic sandbox calculation, gather source - anchors, draft a notebook artifact, and pause for reviewer approval — - one observable card per step. Math runs in JS, not in the language + Four canonical financial workflows. Each one streams typed cards + (plan → tool calls → extraction → validation → sandbox calculation → + evidence → artifact → approval) so users see observable work, not + hidden reasoning. Math runs in JavaScript, never in the language model.

-
- - {isStarting && ( - - Starting orchestrator… - - )} -
+
+ {DEMOS.map((demo) => { + const Icon = demo.icon; + const isActive = activeDemoId === demo.id; + const isPending = pendingDemoId === demo.id; + return ( + + ); + })} +
{error && (

)} - {runId && } + {activeRunId && ( +

+
+ + + +
+ +
+ )} - {!runId && ( + {!activeRunId && (

- Click Run AT&T 10-K demo{" "} - to start a live run. Each step appears as a typed card. + Pick a workflow above to start a live run. Each step appears as a + typed card.

)} diff --git a/vercel.json b/vercel.json index 264f68a46..0cbd7586c 100644 --- a/vercel.json +++ b/vercel.json @@ -69,6 +69,21 @@ { "key": "X-Frame-Options", "value": "DENY" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "/((?!assets/).*)", + "headers": [ + { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }, + { "key": "Pragma", "value": "no-cache" }, + { "key": "CDN-Cache-Control", "value": "no-store" }, + { "key": "Vercel-CDN-Cache-Control", "value": "no-store" } + ] } ] } From 95505c5cc1ebaadd21e73cd9b053782c7f70598d Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 09:44:58 -0700 Subject: [PATCH 3/7] refactor(finance): align cards with NodeBench design kit + add ModelCapabilityBadge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the prior PR: ## 1. Design-kit alignment (we build on top of the kit, not next to it) Replaced ad-hoc styling with the kit's canonical utilities (per docs/architecture/FINANCIAL_OPERATOR_DESIGN_ALIGNMENT.md and the NodeBench AI Design System reference): - StepShell: now uses `.nb-panel` (12px radius + hairline border + panel bg, kit canonical) instead of a hand-rolled `.nb-card`-styled box. Left accent stripe via `::before` keeps cards distinguishable without inventing new chrome. - Type: every kicker is `type-label !tracking-[0.18em]` (kit's canonical 11px uppercase 0.18em). Titles are `type-card-title`. Body is `text-[13px] leading-[1.5]`. Mono numerics use `font-mono`. - Color: every raw `#d97757` literal across 7 card files swapped to `var(--accent-primary)` (Tailwind arbitrary-value with CSS var). Status badges now use `.badge-success/-warn/-fail/-accent` tone families with kit-canonical semantic colors (--success, --warning, --destructive) — same tones the kit's component-badges.html ships. - Demo view: page header uses `type-page-title` + `type-label` + `type-body`. Workflow tiles use `.nb-panel` chrome with the kit's 44px icon container (10px radius, terracotta-12% bg, terracotta fg, 20px Lucide stroke icon — exactly the kit's component-panel.html pattern). - Overlay: drawer chrome uses `--bg-primary`, `--border-color`, and `--shadow-xl` instead of inline `#151413` / `border-edge`. Header icon buttons are 16px Lucide (kit pill-icon size), rounded-full to match the kit's icon-button conventions. No new design tokens were introduced. Every utility class on these surfaces already existed in src/index.css before this work shipped. ## 2. ModelCapabilityBadge — surfacing what the active model can do Pattern lifted from open-source projects that route through unified LLM providers (OpenRouter, pi-ai, LibreChat, OpenWebUI): - OpenRouter exposes `architecture.input_modalities` / `output_modalities` per model - LibreChat shows per-model capability chips next to the picker - pi-ai's `getModel().inputModalities` is the same shape NodeBench surfaces them as a compact icon-only row: - 8 modalities: text, image, pdf, audio, video, web_search, code_exec, tools - Each is a 24px round Lucide-icon pill (14px stroke icon — kit's pill icon size) - Supported: terracotta accent (border + bg + fg via accent CSS vars) - Unsupported: 50% opacity + line-through (visible but visually receded — agent users see what's missing without it competing) - Native title tooltip + role=listitem aria-label per icon Hand-curated capability registry (`MODEL_CAPABILITIES`) covers the models NodeBench routes today: Claude Opus/Sonnet/Haiku, GPT-5/4.1/4o, o1/o3, Gemini 3 Pro/Flash + 2.5 Flash, Grok 4, Kimi k2.6, DeepSeek v3.5, GLM 4.6V. Unknown models fall back to text-only with a `(unverified)` tag — HONEST_SCORES, never claim capabilities the model can't deliver. Long-term path: a Convex action that hits OpenRouter's /v1/models and caches the modality matrix daily. Surfaced in two places this PR: - /finance-demo header (active orchestrator model) - FinancialOperatorOverlay header (visible alongside chat surface) Future PRs can drop it next to FastAgentPanel's model selector and any other model-aware surface — it's a self-contained component with one prop (`model: string`). ## Verification - npx tsc --noEmit: 0 errors - npx vite build: clean (7.71s) - Live browser: 4 demo workflows still render 8-10 typed cards each; StepShell now uses .nb-panel + type-label + type-card-title; ModelCapabilityBadge shows 4 supported (text/image/pdf/tools) + 4 unsupported (audio/video/web_search/code) for claude-opus-4-7 with per-icon tooltips and aria-labels Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/ApprovalCard.tsx | 8 +- .../components/ArtifactCard.tsx | 2 +- .../components/CalculationCard.tsx | 2 +- .../components/EvidenceCard.tsx | 2 +- .../components/FinancialOperatorOverlay.tsx | 56 +++--- .../components/ModelCapabilityBadge.tsx | 187 ++++++++++++++++++ .../components/ResultCard.tsx | 2 +- .../components/StepShell.tsx | 80 +++++--- .../components/StepStatusBadge.tsx | 108 ++++++---- .../components/ToolCallCard.tsx | 2 +- src/features/financialOperator/index.ts | 6 + .../views/FinancialOperatorDemo.tsx | 135 +++++++------ 12 files changed, 417 insertions(+), 173 deletions(-) create mode 100644 src/features/financialOperator/components/ModelCapabilityBadge.tsx diff --git a/src/features/financialOperator/components/ApprovalCard.tsx b/src/features/financialOperator/components/ApprovalCard.tsx index 252f42440..cb6a4b301 100644 --- a/src/features/financialOperator/components/ApprovalCard.tsx +++ b/src/features/financialOperator/components/ApprovalCard.tsx @@ -60,11 +60,11 @@ export function ApprovalCard({ runId, stepId, status, data, selectedOptionId }: onClick={() => handleClick(opt.id)} disabled={isLocked || pendingId !== null} aria-pressed={isSelected} - className={`group rounded border px-3 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:ring-[#d97757]/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60 ${ + className={`group rounded border px-3 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60 ${ isPrimary - ? "border-[#d97757]/40 bg-[#d97757]/10 hover:bg-[#d97757]/15 text-[#f5d0b8]" + ? "border-[var(--accent-primary)]/40 bg-[var(--accent-primary)]/10 hover:bg-[var(--accent-primary)]/15 text-[#f5d0b8]" : "border-edge bg-surface/50 hover:bg-surface-hover text-content" - } ${isSelected ? "ring-2 ring-[#d97757]/40" : ""}`} + } ${isSelected ? "ring-2 ring-[var(--accent-primary)]/40" : ""}`} >
{opt.label} @@ -74,7 +74,7 @@ export function ApprovalCard({ runId, stepId, status, data, selectedOptionId }: )} {isSelected && !isPending && ( - + chosen )} diff --git a/src/features/financialOperator/components/ArtifactCard.tsx b/src/features/financialOperator/components/ArtifactCard.tsx index 2e4fb3cea..68bf2ec42 100644 --- a/src/features/financialOperator/components/ArtifactCard.tsx +++ b/src/features/financialOperator/components/ArtifactCard.tsx @@ -52,7 +52,7 @@ export function ArtifactCard({ data }: Props) { href={data.url} target="_blank" rel="noreferrer noopener" - className="inline-flex items-center gap-1 rounded border border-edge bg-surface/50 px-2.5 py-1 text-[12px] text-content hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-[#d97757]/50 focus-visible:outline-none" + className="inline-flex items-center gap-1 rounded border border-edge bg-surface/50 px-2.5 py-1 text-[12px] text-content hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]/50 focus-visible:outline-none" > Open artifact diff --git a/src/features/financialOperator/components/CalculationCard.tsx b/src/features/financialOperator/components/CalculationCard.tsx index d01542679..005bae4e7 100644 --- a/src/features/financialOperator/components/CalculationCard.tsx +++ b/src/features/financialOperator/components/CalculationCard.tsx @@ -73,7 +73,7 @@ function KVList({ return (
{k}
Open source
+ )} {!activeRunId && (
-

+

Pick a workflow above to start a live run. Each step appears as a typed card.

From 4f11411e8d3c0a773663d7f70a955356d0548b08 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 09:50:22 -0700 Subject: [PATCH 4/7] =?UTF-8?q?feat(finance):=20workspace=20mode=20?= =?UTF-8?q?=E2=80=94=20operator=20console=20built=20into=20the=20chat=20su?= =?UTF-8?q?rface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: "it should actually be built into the existing chat page or chat agent sidebar, add all the new components to the chat, wired live and used under a toggle called workspace mode." ## What changed A new `WorkspaceModeToggle` floats on the chat surface (top-right on desktop, bottom-right above the mobile bottom nav). Clicking it sets `?ws=1` in the URL; clicking again clears it. When `?ws=1` is active, the new `WorkspaceModePane`: - Mounts inside the chat content area (fixed, z-55, padded around the bottom nav + agent panel so the chat composer below stays live) - Renders the 4-workflow picker (AT&T 10-K · CRM cleanup · Covenant compliance · Variance analysis) when no run is active - Streams the FinancialOperatorTimeline live when a run is active - Surfaces ModelCapabilityBadge in its header so the user sees what the active model can/can't do - Defers to the existing right-side drawer (`FinancialOperatorOverlay`) when ws=0 — both modes coexist for users who want a side dock URL state drives everything (`?ws=1`, `?finRun=`) so deep links work and the chat composer below stays interactive. ## Why not edit FastAgentPanel.tsx FastAgentPanel.tsx is 3700+ lines. The toggle + pane sit on top of it via fixed positioning; no surgery on its render tree. Surface coupling is via URL params only — the same pattern any future caller (chip, button, MCP tool) can use to drive workspace mode. ## Visibility rule Toggle hidden on: - /finance-demo (the page IS workspace already) - /cli, /pricing, /changelog, /legal, /about, /api-docs (info pages) - /share/*, /report/*, /embed/* (public/embedded views) Toggle shown on the root chat surface and ?surface=ask|home variants. ## Verification - npx tsc --noEmit: 0 errors - npx vite build: clean (210 PWA entries) - Live browser: - Toggle visible on /?surface=home with aria-label "Enter workspace mode" - Click → URL gets ?ws=1, pane mounts (role=region "Workspace mode") - Pane shows 4 demo tiles + model capability badge + Exit button - Click "Covenant compliance" → 9 typed cards stream inline (Plan → Tool → Extraction×2 → Validation → Calculation → Evidence → Artifact → Result) with the run id in the URL - "Back to picker" returns to the 4-tile state - "Close" / "Exit workspace" returns to plain chat Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 4 + .../components/FinancialOperatorOverlay.tsx | 23 +- .../components/WorkspaceModePane.tsx | 276 ++++++++++++++++++ .../components/WorkspaceModeToggle.tsx | 107 +++++++ src/features/financialOperator/index.ts | 2 + 5 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 src/features/financialOperator/components/WorkspaceModePane.tsx create mode 100644 src/features/financialOperator/components/WorkspaceModeToggle.tsx diff --git a/src/App.tsx b/src/App.tsx index 1705ef0f3..049033cfe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,8 @@ import type { MainView } from "@/lib/registry/viewRegistry"; import { buildCockpitPathForView } from "@/lib/registry/viewRegistry"; import { initErrorReporting } from "@/lib/errorReporting"; import { FinancialOperatorOverlay } from "@/features/financialOperator/components/FinancialOperatorOverlay"; +import { WorkspaceModeToggle } from "@/features/financialOperator/components/WorkspaceModeToggle"; +import { WorkspaceModePane } from "@/features/financialOperator/components/WorkspaceModePane"; const ShareableMemoView = lazy(() => import("@/features/founder/views/ShareableMemoView")); const PublicEntityShareView = lazy(() => import("@/features/share/views/PublicEntityShareView")); @@ -388,6 +390,8 @@ function App() { data-webmcp-enabled={webmcpEnabled ? "true" : "false"} > + + diff --git a/src/features/financialOperator/components/FinancialOperatorOverlay.tsx b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx index 42e171448..856de4de4 100644 --- a/src/features/financialOperator/components/FinancialOperatorOverlay.tsx +++ b/src/features/financialOperator/components/FinancialOperatorOverlay.tsx @@ -49,6 +49,7 @@ function clearRunFromUrl() { export function FinancialOperatorOverlay() { const [runId, setRunId] = useState | null>(null); + const [workspaceMode, setWorkspaceMode] = useState(false); const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(STORAGE_KEY) === "1"; @@ -57,12 +58,21 @@ export function FinancialOperatorOverlay() { } }); - // Sync runId with URL (initial mount + popstate) + // Sync runId + workspace mode with URL (initial mount + popstate). + // When workspace mode is active, WorkspaceModePane handles the timeline + // — the side-drawer overlay should defer to it to avoid double-render. useEffect(() => { - setRunId(readRunIdFromUrl()); - const onPop = () => setRunId(readRunIdFromUrl()); - window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); + const sync = () => { + setRunId(readRunIdFromUrl()); + if (typeof window !== "undefined") { + setWorkspaceMode( + new URLSearchParams(window.location.search).get("ws") === "1", + ); + } + }; + sync(); + window.addEventListener("popstate", sync); + return () => window.removeEventListener("popstate", sync); }, []); // Persist collapse state @@ -79,6 +89,9 @@ export function FinancialOperatorOverlay() { setRunId(null); }, []); + // Defer to WorkspaceModePane when workspace mode is active — that + // surface owns the run rendering inline. + if (workspaceMode) return null; if (!runId) return null; // Collapsed pill — small chip in the bottom-right corner. diff --git a/src/features/financialOperator/components/WorkspaceModePane.tsx b/src/features/financialOperator/components/WorkspaceModePane.tsx new file mode 100644 index 000000000..0d151dcbb --- /dev/null +++ b/src/features/financialOperator/components/WorkspaceModePane.tsx @@ -0,0 +1,276 @@ +/** + * WorkspaceModePane — operator-console takeover of the chat reading area. + * + * Mounts when `?ws=1` is in the URL and we're on a chat surface. Renders + * full-width inside the chat-content rectangle (between top nav, bottom + * nav, and any agent panel) without reaching into FastAgentPanel + * internals. + * + * Composition (top → bottom): + * 1. Header: workspace label + ModelCapabilityBadge + exit button + * 2. If no active run: 4-workflow demo picker + * 3. If active run: FinancialOperatorTimeline (live-streaming cards) + * 4. Quick switch back to picker if a run is in progress + * + * Key invariants: + * - Chat composer below stays live — user can ask follow-ups while + * the workspace pane runs in the background + * - URL params (?ws=1, ?finRun=) drive everything; deep-links + * work + * - Reuses ALL existing components (StepCard, ModelCapabilityBadge, + * FinancialOperatorTimeline, demo orchestrators) + * - No new design tokens — every utility class is from the kit + */ + +import { useState, useEffect, useCallback } from "react"; +import { useAction } from "convex/react"; +import { + Calculator, + FileSpreadsheet, + ScrollText, + TrendingUp, + X, + ArrowLeft, +} from "lucide-react"; +import { api } from "../../../../convex/_generated/api"; +import type { Id } from "../../../../convex/_generated/dataModel"; +import { FinancialOperatorTimeline } from "./FinancialOperatorTimeline"; +import { ModelCapabilityBadge } from "./ModelCapabilityBadge"; +import { setActiveFinancialRun } from "./FinancialOperatorOverlay"; +import { + isWorkspaceModeActive, + setWorkspaceMode, +} from "./WorkspaceModeToggle"; + +type DemoId = "att" | "crm" | "covenant" | "variance"; + +interface DemoOption { + id: DemoId; + label: string; + blurb: string; + icon: typeof Calculator; + category: string; +} + +const DEMOS: DemoOption[] = [ + { + id: "att", + label: "AT&T 10-K — ETR & cost of debt", + blurb: "Locate filing sections, extract structured values, run sandbox math, gather sources, draft a notebook + PR.", + icon: Calculator, + category: "Financial metric extraction", + }, + { + id: "crm", + label: "CRM cleanup", + blurb: "Profile a 387-row prospect list, dedupe, enrich, export CRM-ready CSV.", + icon: FileSpreadsheet, + category: "Data cleanup", + }, + { + id: "covenant", + label: "Covenant compliance", + blurb: "Locate the leverage covenant, extract terms + financials, sandbox compliance gate, draft memo.", + icon: ScrollText, + category: "Credit-agreement review", + }, + { + id: "variance", + label: "Variance analysis", + blurb: "Align CoA, compute per-line variance in sandbox, surface drivers, draft CFO summary.", + icon: TrendingUp, + category: "Monthly close", + }, +]; + +const ACTIVE_MODEL = "claude-opus-4-7"; +const FIN_RUN_PARAM = "finRun"; + +function readActiveRunId(): Id<"financialOperatorRuns"> | null { + if (typeof window === "undefined") return null; + const v = new URLSearchParams(window.location.search).get(FIN_RUN_PARAM); + return v && v.length > 0 ? (v as Id<"financialOperatorRuns">) : null; +} + +export function WorkspaceModePane() { + const [active, setActive] = useState(() => isWorkspaceModeActive()); + const [activeRunId, setActiveRunId] = useState | null>( + () => readActiveRunId(), + ); + const [pendingDemoId, setPendingDemoId] = useState(null); + const [error, setError] = useState(null); + + // Sync with URL on mount + popstate + useEffect(() => { + const onPop = () => { + setActive(isWorkspaceModeActive()); + setActiveRunId(readActiveRunId()); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + const runAtt = useAction( + api.domains.financialOperator.orchestrator.runAttCostOfDebtDemo, + ); + const runCrm = useAction( + api.domains.financialOperator.orchestratorExamples.runCrmCleanupDemo, + ); + const runCovenant = useAction( + api.domains.financialOperator.orchestratorExamples.runCovenantComplianceDemo, + ); + const runVariance = useAction( + api.domains.financialOperator.orchestratorExamples.runVarianceAnalysisDemo, + ); + + const handleStart = useCallback( + async (id: DemoId) => { + setPendingDemoId(id); + setError(null); + try { + let result: { runId: Id<"financialOperatorRuns"> }; + switch (id) { + case "att": result = await runAtt({}); break; + case "crm": result = await runCrm({}); break; + case "covenant": result = await runCovenant({}); break; + case "variance": result = await runVariance({}); break; + } + setActiveFinancialRun(result.runId); + setActiveRunId(result.runId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to start run"); + } finally { + setPendingDemoId(null); + } + }, + [runAtt, runCrm, runCovenant, runVariance], + ); + + const handleClearRun = useCallback(() => { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.delete(FIN_RUN_PARAM); + window.history.replaceState({}, "", url.toString()); + window.dispatchEvent(new PopStateEvent("popstate")); + }, []); + + if (!active) return null; + + return ( +
+
+
+
+

Workspace mode

+

+ {activeRunId ? "Live operator-console run" : "Pick a workflow"} +

+ +
+ +
+ + {error && ( +

+ {error} +

+ )} + + {/* No active run → demo picker */} + {!activeRunId && ( +
+ {DEMOS.map((demo) => { + const Icon = demo.icon; + const isPending = pendingDemoId === demo.id; + return ( + + ); + })} +
+ )} + + {/* Active run → live timeline + back-to-picker affordance */} + {activeRunId && ( + <> + + + + )} + +

+ Composer below stays live — ask follow-ups while the workspace runs. + Math is sandboxed, sources are cited per field, and approvals lock + the artifact. +

+
+
+ ); +} diff --git a/src/features/financialOperator/components/WorkspaceModeToggle.tsx b/src/features/financialOperator/components/WorkspaceModeToggle.tsx new file mode 100644 index 000000000..1fb1dad0d --- /dev/null +++ b/src/features/financialOperator/components/WorkspaceModeToggle.tsx @@ -0,0 +1,107 @@ +/** + * WorkspaceModeToggle — floating control that switches the chat surface + * into "workspace mode": the operator-console takes over the chat + * content area while the composer + agent panel remain live. + * + * Why a floating toggle vs editing FastAgentPanel directly: + * - FastAgentPanel.tsx is 3700+ lines; reaching into its render tree + * for one button is high blast radius + * - URL-param-driven state (`?ws=1`) means the toggle works on every + * surface that wants to host workspace mode without coupling + * + * Visibility rule: only renders on chat surfaces (the `?surface=ask` + * landing, plain `/`, or any path the cockpit treats as a chat + * surface). Stays hidden on /finance-demo (where the experience is + * already a workspace) and on info pages (/cli, /pricing, etc). + * + * Persistence: toggle state lives in `?ws=1` (shareable). When the + * user reloads or shares the URL, workspace mode rehydrates. + */ + +import { useState, useEffect } from "react"; +import { LayoutDashboard, MessageSquare } from "lucide-react"; + +const URL_PARAM = "ws"; +const SHOW_ON_PATHS = ["/", "/?", ""]; // root + chat surface + +export function isWorkspaceModeActive(): boolean { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).get(URL_PARAM) === "1"; +} + +export function setWorkspaceMode(active: boolean) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (active) { + url.searchParams.set(URL_PARAM, "1"); + } else { + url.searchParams.delete(URL_PARAM); + } + window.history.replaceState({}, "", url.toString()); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +function shouldRenderToggle(): boolean { + if (typeof window === "undefined") return false; + const path = window.location.pathname; + // Hide on the standalone demo route — the workspace IS the page there. + if (path.startsWith("/finance-demo") || path.startsWith("/financial-operator") || path.startsWith("/finops")) { + return false; + } + // Hide on canonical info pages where workspace mode would be confusing. + const HIDDEN_PREFIXES = ["/cli", "/pricing", "/changelog", "/legal", "/about", "/api-docs", "/share/", "/report/", "/embed/"]; + if (HIDDEN_PREFIXES.some((p) => path.startsWith(p))) return false; + return SHOW_ON_PATHS.some((p) => path === p || path.startsWith(p)); +} + +export function WorkspaceModeToggle() { + const [active, setActive] = useState(() => isWorkspaceModeActive()); + const [visible, setVisible] = useState(() => shouldRenderToggle()); + + useEffect(() => { + const onPop = () => { + setActive(isWorkspaceModeActive()); + setVisible(shouldRenderToggle()); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + if (!visible) return null; + + return ( + + ); +} diff --git a/src/features/financialOperator/index.ts b/src/features/financialOperator/index.ts index ed7ba4b1b..e3e1f0c35 100644 --- a/src/features/financialOperator/index.ts +++ b/src/features/financialOperator/index.ts @@ -11,6 +11,8 @@ export { MODEL_CAPABILITIES, } from "./components/ModelCapabilityBadge"; export type { ModelCapability } from "./components/ModelCapabilityBadge"; +export { WorkspaceModeToggle, isWorkspaceModeActive, setWorkspaceMode } from "./components/WorkspaceModeToggle"; +export { WorkspaceModePane } from "./components/WorkspaceModePane"; export type { ApprovalRequestPayload, ArtifactKind, From 63fcb3ca859aaf64ec4ef1df2ed149d3d3c82113 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 09:52:55 -0700 Subject: [PATCH 5/7] tmp: CRLF noise (will revert) --- tests/linkupSmoke.js | 54 +++++++++++++++++++++---------------------- tests/linkupSmoke.mjs | 54 +++++++++++++++++++++---------------------- tsconfig.node.json | 44 +++++++++++++++++------------------ 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/tests/linkupSmoke.js b/tests/linkupSmoke.js index 5bc41c140..e25ac4843 100644 --- a/tests/linkupSmoke.js +++ b/tests/linkupSmoke.js @@ -1,27 +1,27 @@ -// tests/linkupSmoke.js -const { LinkupClient } = require('linkup-sdk'); - -const apiKey = process.env.LINKUP_API_KEY || process.env.NEXT_PUBLIC_LINKUP_API_KEY; -if (!apiKey) { - console.error('Missing LINKUP_API_KEY'); - process.exit(1); -} - -const client = new LinkupClient({ apiKey }); - -(async () => { - const res = await client.search({ - query: "What is Microsoft's 2024 revenue?", - depth: 'deep', - outputType: 'sourcedAnswer', - }); - console.log(JSON.stringify({ - answer: res?.answer || null, - nSources: Array.isArray(res?.sources) ? res.sources.length : 0, - first: Array.isArray(res?.sources) ? res.sources[0] : null, - }, null, 2)); -})().catch((e) => { - console.error('Linkup error:', e?.message || e); - process.exit(2); -}); - +// tests/linkupSmoke.js +const { LinkupClient } = require('linkup-sdk'); + +const apiKey = process.env.LINKUP_API_KEY || process.env.NEXT_PUBLIC_LINKUP_API_KEY; +if (!apiKey) { + console.error('Missing LINKUP_API_KEY'); + process.exit(1); +} + +const client = new LinkupClient({ apiKey }); + +(async () => { + const res = await client.search({ + query: "What is Microsoft's 2024 revenue?", + depth: 'deep', + outputType: 'sourcedAnswer', + }); + console.log(JSON.stringify({ + answer: res?.answer || null, + nSources: Array.isArray(res?.sources) ? res.sources.length : 0, + first: Array.isArray(res?.sources) ? res.sources[0] : null, + }, null, 2)); +})().catch((e) => { + console.error('Linkup error:', e?.message || e); + process.exit(2); +}); + diff --git a/tests/linkupSmoke.mjs b/tests/linkupSmoke.mjs index 436f2be6e..571cdd735 100644 --- a/tests/linkupSmoke.mjs +++ b/tests/linkupSmoke.mjs @@ -1,27 +1,27 @@ -// tests/linkupSmoke.mjs -import { LinkupClient } from 'linkup-sdk'; - -const apiKey = process.env.LINKUP_API_KEY || process.env.NEXT_PUBLIC_LINKUP_API_KEY; -if (!apiKey) { - console.error('Missing LINKUP_API_KEY'); - process.exit(1); -} - -const client = new LinkupClient({ apiKey }); - -try { - const res = await client.search({ - query: "What is Microsoft's 2024 revenue?", - depth: 'deep', - outputType: 'sourcedAnswer', - }); - console.log(JSON.stringify({ - answer: res?.answer || null, - nSources: Array.isArray(res?.sources) ? res.sources.length : 0, - first: Array.isArray(res?.sources) ? res.sources[0] : null, - }, null, 2)); -} catch (e) { - console.error('Linkup error:', e?.message || e); - process.exit(2); -} - +// tests/linkupSmoke.mjs +import { LinkupClient } from 'linkup-sdk'; + +const apiKey = process.env.LINKUP_API_KEY || process.env.NEXT_PUBLIC_LINKUP_API_KEY; +if (!apiKey) { + console.error('Missing LINKUP_API_KEY'); + process.exit(1); +} + +const client = new LinkupClient({ apiKey }); + +try { + const res = await client.search({ + query: "What is Microsoft's 2024 revenue?", + depth: 'deep', + outputType: 'sourcedAnswer', + }); + console.log(JSON.stringify({ + answer: res?.answer || null, + nSources: Array.isArray(res?.sources) ? res.sources.length : 0, + first: Array.isArray(res?.sources) ? res.sources[0] : null, + }, null, 2)); +} catch (e) { + console.error('Linkup error:', e?.message || e); + process.exit(2); +} + diff --git a/tsconfig.node.json b/tsconfig.node.json index e1091df18..85b71dc03 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,22 +1,22 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} From 7622cd5c9bb523e178ba655f126bf9338075f897 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 28 Apr 2026 10:05:35 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(finance):=20workspace=20mode=20pane=20w?= =?UTF-8?q?as=20transparent=20=E2=80=94=20home=20surface=20bled=20through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User QA caught a broken UI: workspace mode rendered with the home surface visible behind it (greeting, sidebar, watchlist, search input all stacking with the operator-console pane). ## Root cause Tailwind's `/95` opacity modifier does NOT work on CSS-var arbitrary values without the `color:` prefix. The class `bg-[var(--bg-app)]/95` resolved to `rgba(0,0,0,0)` — fully transparent. A second issue compounded it: `--bg-app` is in the kit reference (colors_and_type.css) but is NOT defined in the live repo's src/index.css. The repo has `--bg-primary` / `--bg-secondary` only. So even the unmodified `var(--bg-app)` would have resolved to nothing. ## Fix - Use `--bg-primary` (defined: #FFFFFF light, dark variant in dark mode) as the pane base color, set via inline `style` to bypass any Tailwind quirks with CSS-var opacity arbitrary values. - Bump pane to `z-[80]` (above modals at z-50, toasts at z-60). The toggle bumped to `z-[85]` so users can dismiss mid-run without hunting inside the pane. - Add `isolate` for a clean stacking context — prevents any future z-leak from the home surface beneath. - Inline-comment the var-opacity gotcha so the next developer doesn't re-introduce it. ## Verification (per dogfood_verification.md) - npx tsc --noEmit: 0 errors - Live browser screenshot: clean opaque pane, header readable, 4 demo tiles in 2x2 grid, model capability badge with 4 supported + 4 unsupported icons, no home-surface bleed-through - Run flow: clicked AT&T 10-K → 9 typed cards stream inline (Plan → Tool×2 → Extraction → Validation → Calculation → Evidence → Artifact → Result), all using .nb-panel chrome with proper status badges and source-cited fields Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/WorkspaceModePane.tsx | 22 ++++++++++++------- .../components/WorkspaceModeToggle.tsx | 4 +++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/features/financialOperator/components/WorkspaceModePane.tsx b/src/features/financialOperator/components/WorkspaceModePane.tsx index 0d151dcbb..86e6b9ca1 100644 --- a/src/features/financialOperator/components/WorkspaceModePane.tsx +++ b/src/features/financialOperator/components/WorkspaceModePane.tsx @@ -160,15 +160,21 @@ export function WorkspaceModePane() {
Date: Tue, 28 Apr 2026 10:15:09 -0700 Subject: [PATCH 7/7] fix(finance): workspace mode follows kit's chat-shell shape (header / scroll / composer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User QA caught: workspace mode was overlaying the chat surface instead of building on top of the existing chat layout. The kit's canonical chat shell (ui_kits/nodebench-web/ChatThread.jsx) is: header (sticky top, entity icon + title + meta + actions) ↓ scrollable thread (turns / operator console cards) ↓ composer (pinned bottom: pins · field · model + caps · suggested chips) The model selector + capability indicators belong IN the composer (per the design board reference + the kit's Composer.jsx), not floating in the header. ## What changed WorkspaceModePane now renders as a 3-row CSS grid mirroring the kit: - Row 1 (header): kit's .nb-chat-header pattern — entity icon (terracotta squircle with sparkle), kicker, title, meta in mono font, Picker + Close actions - Row 2 (scroll): demo picker (no run) OR FinancialOperatorTimeline (active run) inside a max-w-3xl container - Row 3 (composer): new WorkspaceComposer component WorkspaceComposer follows the kit's composer shape exactly: - Pin row: "EVENT Ship Demo Day ×" + "+ Add context" (matches design board reference) - Field row: paperclip + link + mic icons (15px stroke) | textarea "Ask, capture, paste, upload, or record…" | terracotta send button - Below field: MODEL claude-opus-4-7 + 8 capability icons (text / image / pdf / audio / video / web_search / code_exec / tools with supported vs muted variants and per-icon tooltips) | Memory-first · 0 paid calls in mono - Suggested chips: Run AT&T 10-K demo · Run CRM cleanup · Run covenant compliance · Run variance analysis The composer is interactive: typing a prompt that matches a known workflow regex starts that demo (e.g. "AT&T 10-K cost of debt" → runAttCostOfDebtDemo). Send falls back to dispatching a custom `nb:workspace:compose` event for any other panel listening (so future FastAgentPanel integration can hook in without surgery). ## Verification - npx tsc --noEmit: 0 errors - npx vite build: clean (210 PWA entries) - Live browser screenshot (kit-aligned at mobile width): - Empty state: header with WORKSPACE MODE / Pick a workflow / "4 canonical workflows · math sandboxed · approval-gated" meta + Close button; scrollable middle with 4 demo tiles in 2x2; composer pinned bottom with all canonical pieces (pins, attach, textarea, send, model badge with capabilities, Memory-first hint, 4 suggested chips) - Active run: header switches to "Live operator-console run", scroll area renders the typed-card timeline (RUN header → Plan → 2x Tool → Extraction → Validation → Calculation → Evidence → Artifact), composer stays pinned and never overlaps content; capability badge sits inside the composer where the kit puts it Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/WorkspaceComposer.tsx | 273 ++++++++++++++++++ .../components/WorkspaceModePane.tsx | 258 +++++++++-------- src/features/financialOperator/index.ts | 1 + 3 files changed, 406 insertions(+), 126 deletions(-) create mode 100644 src/features/financialOperator/components/WorkspaceComposer.tsx diff --git a/src/features/financialOperator/components/WorkspaceComposer.tsx b/src/features/financialOperator/components/WorkspaceComposer.tsx new file mode 100644 index 000000000..66ad55100 --- /dev/null +++ b/src/features/financialOperator/components/WorkspaceComposer.tsx @@ -0,0 +1,273 @@ +/** + * WorkspaceComposer — pinned chat composer for workspace mode. + * + * Built to follow the kit's canonical chat-composer shape (per + * ui_kits/nodebench-web/ChatThread.jsx and ui_kits/nodebench-web/Composer.jsx + * in the design packet): + * + * ┌─ pins / context pills ─────────────────────────────────┐ + * │ EVENT Ship Demo Day × + Add context │ + * ├─ field ────────────────────────────────────────────────┤ + * │ 📎 🔗 🎤 Ask, capture, paste, upload, or record… ⏎│ + * ├─ row ──────────────────────────────────────────────────┤ + * │ MODEL claude-opus-4-7 [T][I][F][·][·][·][·][T] ↗ │ + * ├─ suggested ────────────────────────────────────────────┤ + * │ Run AT&T 10-K · Run CRM cleanup · Covenant · Variance │ + * └────────────────────────────────────────────────────────┘ + * + * The kit puts the model selector + capability indicators ON the composer, + * not floating above. This component honors that. + * + * Functional behavior: typing + send tries to match the input against the + * known demo workflows. If no match, it falls back to opening the existing + * FastAgentPanel via a custom event (panel listeners can catch it). This + * keeps the composer interactive without surgical edits to FastAgentPanel. + */ + +import { useState, useRef, useEffect } from "react"; +import { useAction } from "convex/react"; +import { ArrowUp, FileText, Link as LinkIcon, Mic, X, Plus } from "lucide-react"; +import { api } from "../../../../convex/_generated/api"; +import type { Id } from "../../../../convex/_generated/dataModel"; +import { ModelCapabilityBadge } from "./ModelCapabilityBadge"; +import { setActiveFinancialRun } from "./FinancialOperatorOverlay"; + +const ACTIVE_MODEL = "claude-opus-4-7"; + +type DemoId = "att" | "crm" | "covenant" | "variance"; + +interface DemoChip { + id: DemoId; + label: string; + match: RegExp; +} + +// Lightweight intent matcher — keep deterministic, never LLM-routed. +const DEMOS: DemoChip[] = [ + { id: "att", label: "Run AT&T 10-K demo", match: /\b(at\s*&?\s*t|att|10-?k|effective\s*tax|cost\s*of\s*debt|etr)\b/i }, + { id: "crm", label: "Run CRM cleanup", match: /\b(crm|prospect|dedupe|enrich|cleanup|cleanse)\b/i }, + { id: "covenant", label: "Run covenant compliance", match: /\b(covenant|leverage|credit\s*agreement|breach|debt)\b/i }, + { id: "variance", label: "Run variance analysis", match: /\b(variance|actual.*budget|cfo|monthly\s*close)\b/i }, +]; + +interface Props { + /** Current financial run, if any — surfaces context as a pin. */ + activeRunId?: Id<"financialOperatorRuns"> | null; + /** Optional: notify the parent when a workflow starts. */ + onRunStarted?: (runId: Id<"financialOperatorRuns">) => void; +} + +export function WorkspaceComposer({ activeRunId, onRunStarted }: Props) { + const [value, setValue] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [pins, setPins] = useState<{ kind: string; label: string }[]>( + activeRunId + ? [{ kind: "RUN", label: "Active financial run" }] + : [{ kind: "EVENT", label: "Ship Demo Day" }], + ); + const taRef = useRef(null); + + const runAtt = useAction( + api.domains.financialOperator.orchestrator.runAttCostOfDebtDemo, + ); + const runCrm = useAction( + api.domains.financialOperator.orchestratorExamples.runCrmCleanupDemo, + ); + const runCovenant = useAction( + api.domains.financialOperator.orchestratorExamples.runCovenantComplianceDemo, + ); + const runVariance = useAction( + api.domains.financialOperator.orchestratorExamples.runVarianceAnalysisDemo, + ); + + // Autosize textarea up to 120px + useEffect(() => { + if (!taRef.current) return; + taRef.current.style.height = "auto"; + taRef.current.style.height = Math.min(taRef.current.scrollHeight, 120) + "px"; + }, [value]); + + function classify(text: string): DemoId | null { + for (const d of DEMOS) { + if (d.match.test(text)) return d.id; + } + return null; + } + + async function handleSend() { + const text = value.trim(); + if (!text || pending) return; + setError(null); + const demoId = classify(text); + if (demoId) { + setPending(true); + try { + let result: { runId: Id<"financialOperatorRuns"> }; + switch (demoId) { + case "att": result = await runAtt({}); break; + case "crm": result = await runCrm({}); break; + case "covenant": result = await runCovenant({}); break; + case "variance": result = await runVariance({}); break; + } + setActiveFinancialRun(result.runId); + onRunStarted?.(result.runId); + setValue(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to start run"); + } finally { + setPending(false); + } + } else { + // No matching demo — surface a hint and dispatch an event so other + // chat panels can pick it up if they're listening. + setError( + "I can route a workflow from your prompt. Try: AT&T 10-K · CRM cleanup · covenant compliance · variance analysis.", + ); + window.dispatchEvent( + new CustomEvent("nb:workspace:compose", { detail: { text } }), + ); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + + function startDemo(id: DemoId) { + setValue(DEMOS.find((d) => d.id === id)?.label ?? ""); + setTimeout(() => handleSend(), 0); + } + + return ( +
+ {/* Pins */} + {pins.length > 0 && ( +
+ {pins.map((p, i) => ( + + + {p.kind} + + {p.label} + + + ))} + +
+ )} + + {/* Field row */} +
+
+ + + +
+