gloomberb 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +68 -121
- package/bin/gloomberb +3 -0
- package/package.json +13 -4
- package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
- package/src/app.tsx +1330 -0
- package/src/brokers/sync-broker-instance.test.ts +100 -0
- package/src/brokers/sync-broker-instance.ts +341 -0
- package/src/cli/commands/plugins.ts +173 -0
- package/src/cli/commands/portfolio.ts +210 -0
- package/src/cli/commands/search.ts +120 -0
- package/src/cli/commands/ticker.ts +375 -0
- package/src/cli/commands/watchlist.ts +210 -0
- package/src/cli/context.ts +62 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/helpers.ts +85 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/ticker-resolution.ts +39 -0
- package/src/cli/types.ts +15 -0
- package/src/cli.test.ts +361 -0
- package/src/components/chart/chart-data.ts +239 -0
- package/src/components/chart/chart-renderer.ts +1199 -0
- package/src/components/chart/chart-types.ts +82 -0
- package/src/components/chart/chart-viewport.ts +21 -0
- package/src/components/chart/chart.test.ts +439 -0
- package/src/components/chart/comparison-chart-data.test.ts +109 -0
- package/src/components/chart/comparison-chart-data.ts +316 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
- package/src/components/chart/comparison-chart-renderer.ts +354 -0
- package/src/components/chart/comparison-stock-chart.tsx +1486 -0
- package/src/components/chart/cursor-motion.ts +48 -0
- package/src/components/chart/native/chart-rasterizer.ts +732 -0
- package/src/components/chart/native/kitty-adapter.ts +12 -0
- package/src/components/chart/native/kitty-manager.ts +142 -0
- package/src/components/chart/native/kitty-protocol.ts +110 -0
- package/src/components/chart/native/kitty-support.ts +71 -0
- package/src/components/chart/native/kitty.test.ts +620 -0
- package/src/components/chart/native/renderer-selection.ts +24 -0
- package/src/components/chart/native/surface-manager.ts +225 -0
- package/src/components/chart/native/surface-sync.ts +34 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
- package/src/components/chart/stock-chart.test.ts +107 -0
- package/src/components/chart/stock-chart.tsx +1556 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
- package/src/components/command-bar/command-bar.test.tsx +1524 -0
- package/src/components/command-bar/command-bar.tsx +4823 -0
- package/src/components/command-bar/command-registry.ts +261 -0
- package/src/components/command-bar/helpers.test.ts +224 -0
- package/src/components/command-bar/helpers.ts +371 -0
- package/src/components/command-bar/root-shortcuts.ts +158 -0
- package/src/components/command-bar/view-model.test.ts +152 -0
- package/src/components/command-bar/view-model.ts +185 -0
- package/src/components/command-bar/workflow-ops.test.ts +70 -0
- package/src/components/command-bar/workflow-ops.ts +433 -0
- package/src/components/command-bar/workflow-types.ts +127 -0
- package/src/components/detail-feed-view.test.tsx +105 -0
- package/src/components/detail-feed-view.tsx +295 -0
- package/src/components/index.ts +32 -0
- package/src/components/layout/floating-pane.tsx +49 -0
- package/src/components/layout/header.tsx +129 -0
- package/src/components/layout/pane-header.tsx +44 -0
- package/src/components/layout/pane.tsx +49 -0
- package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
- package/src/components/layout/pixel-mouse.test.ts +40 -0
- package/src/components/layout/shell.test.tsx +625 -0
- package/src/components/layout/shell.tsx +1200 -0
- package/src/components/layout/status-bar.test.tsx +173 -0
- package/src/components/layout/status-bar.tsx +138 -0
- package/src/components/markdown-editor.tsx +134 -0
- package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
- package/src/components/pane-settings-dialog.tsx +468 -0
- package/src/components/pane-template-wizard.test.tsx +52 -0
- package/src/components/pane-template-wizard.tsx +199 -0
- package/src/components/price-selector-dialog.tsx +195 -0
- package/src/components/spinner.tsx +2 -0
- package/src/components/tab-bar.tsx +17 -0
- package/src/components/ticker-badge-text.test.tsx +160 -0
- package/src/components/ticker-badge-text.tsx +58 -0
- package/src/components/ticker-badge.tsx +88 -0
- package/src/components/ticker-list-table.tsx +188 -0
- package/src/components/toggle-list.tsx +74 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/data-table.tsx +210 -0
- package/src/components/ui/external-link.tsx +22 -0
- package/src/components/ui/fields.tsx +187 -0
- package/src/components/ui/frame.tsx +73 -0
- package/src/components/ui/index.ts +53 -0
- package/src/components/ui/list-view.tsx +166 -0
- package/src/components/ui/loading.tsx +70 -0
- package/src/components/ui/status.tsx +83 -0
- package/src/components/ui/tabs.tsx +47 -0
- package/src/components/ui/toggle.tsx +164 -0
- package/src/components/ui/ui.test.tsx +134 -0
- package/src/data/app-persistence.ts +25 -0
- package/src/data/config-store.test.ts +310 -0
- package/src/data/config-store.ts +469 -0
- package/src/data/plugin-state-store.ts +102 -0
- package/src/data/resource-store.ts +323 -0
- package/src/data/session-store.ts +52 -0
- package/src/data/sqlite-cache.test.ts +103 -0
- package/src/data/sqlite-database.ts +62 -0
- package/src/data/sqlite-json.ts +19 -0
- package/src/data/ticker-repository.ts +45 -0
- package/src/data/ticker-store.ts +46 -0
- package/src/index.tsx +67 -0
- package/src/market-data/coordinator.test.ts +369 -0
- package/src/market-data/coordinator.ts +580 -0
- package/src/market-data/hooks.tsx +234 -0
- package/src/market-data/query-store.ts +28 -0
- package/src/market-data/request-types.test.ts +64 -0
- package/src/market-data/request-types.ts +70 -0
- package/src/market-data/result-types.ts +56 -0
- package/src/market-data/selectors.ts +166 -0
- package/src/market-data/trace.ts +7 -0
- package/src/pane-settings.test.ts +71 -0
- package/src/pane-settings.ts +73 -0
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
- package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
- package/src/plugins/builtin/ai/index.test.tsx +139 -0
- package/src/plugins/builtin/ai/index.tsx +86 -0
- package/src/plugins/builtin/ai/providers.ts +99 -0
- package/src/plugins/builtin/ai/runner.ts +82 -0
- package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
- package/src/plugins/builtin/ai/screener-contract.ts +174 -0
- package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
- package/src/plugins/builtin/ai/settings.ts +79 -0
- package/src/plugins/builtin/ai/ticker-context.ts +64 -0
- package/src/plugins/builtin/ai/utils.ts +71 -0
- package/src/plugins/builtin/ask-ai.test.tsx +258 -0
- package/src/plugins/builtin/ask-ai.tsx +9 -0
- package/src/plugins/builtin/chat-controller.test.ts +217 -0
- package/src/plugins/builtin/chat-controller.ts +340 -0
- package/src/plugins/builtin/chat.test.tsx +451 -0
- package/src/plugins/builtin/chat.tsx +620 -0
- package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
- package/src/plugins/builtin/comparison-chart.tsx +169 -0
- package/src/plugins/builtin/data-providers.ts +16 -0
- package/src/plugins/builtin/debug.tsx +351 -0
- package/src/plugins/builtin/help.test.tsx +110 -0
- package/src/plugins/builtin/help.tsx +281 -0
- package/src/plugins/builtin/layout-manager.tsx +264 -0
- package/src/plugins/builtin/manual-entry.tsx +25 -0
- package/src/plugins/builtin/news.tsx +143 -0
- package/src/plugins/builtin/notes-files.ts +58 -0
- package/src/plugins/builtin/notes.tsx +396 -0
- package/src/plugins/builtin/options-availability.test.tsx +179 -0
- package/src/plugins/builtin/options-availability.ts +158 -0
- package/src/plugins/builtin/options.tsx +364 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
- package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
- package/src/plugins/builtin/portfolio-list.test.ts +74 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
- package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
- package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
- package/src/plugins/builtin/sec.tsx +250 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
- package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
- package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
- package/src/plugins/builtin/yahoo.ts +18 -0
- package/src/plugins/event-bus.ts +37 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
- package/src/plugins/ibkr/account-cache.test.ts +101 -0
- package/src/plugins/ibkr/account-cache.ts +137 -0
- package/src/plugins/ibkr/broker-adapter.ts +168 -0
- package/src/plugins/ibkr/config.test.ts +68 -0
- package/src/plugins/ibkr/config.ts +262 -0
- package/src/plugins/ibkr/dialogs.tsx +83 -0
- package/src/plugins/ibkr/flex.test.ts +107 -0
- package/src/plugins/ibkr/flex.ts +267 -0
- package/src/plugins/ibkr/fundamental-parser.ts +149 -0
- package/src/plugins/ibkr/gateway-helpers.ts +28 -0
- package/src/plugins/ibkr/gateway-selection.ts +51 -0
- package/src/plugins/ibkr/gateway-service.test.ts +450 -0
- package/src/plugins/ibkr/gateway-service.ts +2228 -0
- package/src/plugins/ibkr/index.test.tsx +183 -0
- package/src/plugins/ibkr/index.tsx +153 -0
- package/src/plugins/ibkr/instance-selection.test.ts +121 -0
- package/src/plugins/ibkr/instance-selection.ts +36 -0
- package/src/plugins/ibkr/price-normalization.test.ts +38 -0
- package/src/plugins/ibkr/price-normalization.ts +59 -0
- package/src/plugins/ibkr/trade-badge.tsx +27 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
- package/src/plugins/ibkr/trade-tab.tsx +1050 -0
- package/src/plugins/ibkr/trade-utils.test.ts +175 -0
- package/src/plugins/ibkr/trade-utils.ts +149 -0
- package/src/plugins/ibkr/trading-pane.tsx +438 -0
- package/src/plugins/ibkr/trading-state.test.ts +109 -0
- package/src/plugins/ibkr/trading-state.ts +339 -0
- package/src/plugins/loader.ts +71 -0
- package/src/plugins/pane-manager.test.ts +103 -0
- package/src/plugins/pane-manager.ts +974 -0
- package/src/plugins/plugin-persistence.ts +85 -0
- package/src/plugins/plugin-runtime.test.tsx +185 -0
- package/src/plugins/plugin-runtime.tsx +175 -0
- package/src/plugins/prediction-markets/cache.ts +114 -0
- package/src/plugins/prediction-markets/categories.ts +223 -0
- package/src/plugins/prediction-markets/chart.tsx +512 -0
- package/src/plugins/prediction-markets/columns.ts +133 -0
- package/src/plugins/prediction-markets/controller-data.ts +614 -0
- package/src/plugins/prediction-markets/controller.ts +725 -0
- package/src/plugins/prediction-markets/detail/book.tsx +118 -0
- package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
- package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
- package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
- package/src/plugins/prediction-markets/detail.test.tsx +47 -0
- package/src/plugins/prediction-markets/index.tsx +102 -0
- package/src/plugins/prediction-markets/keyboard.ts +64 -0
- package/src/plugins/prediction-markets/launch.test.ts +126 -0
- package/src/plugins/prediction-markets/launch.ts +240 -0
- package/src/plugins/prediction-markets/metrics.ts +311 -0
- package/src/plugins/prediction-markets/navigation.ts +90 -0
- package/src/plugins/prediction-markets/outcome-order.ts +19 -0
- package/src/plugins/prediction-markets/pane.test.tsx +425 -0
- package/src/plugins/prediction-markets/pane.tsx +294 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
- package/src/plugins/prediction-markets/rows.ts +229 -0
- package/src/plugins/prediction-markets/services/fetch.ts +97 -0
- package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
- package/src/plugins/prediction-markets/settings.ts +164 -0
- package/src/plugins/prediction-markets/status.ts +24 -0
- package/src/plugins/prediction-markets/table.tsx +84 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
- package/src/plugins/prediction-markets/types.ts +186 -0
- package/src/plugins/registry.ts +633 -0
- package/src/sources/exchange-rates.ts +18 -0
- package/src/sources/gloomberb-cloud.test.ts +199 -0
- package/src/sources/gloomberb-cloud.ts +326 -0
- package/src/sources/provider-errors.ts +19 -0
- package/src/sources/provider-router.test.ts +1476 -0
- package/src/sources/provider-router.ts +1405 -0
- package/src/sources/sec-edgar.test.ts +286 -0
- package/src/sources/sec-edgar.ts +526 -0
- package/src/sources/yahoo-finance.test.ts +14 -0
- package/src/sources/yahoo-finance.ts +1017 -0
- package/src/state/app-activity.test.ts +41 -0
- package/src/state/app-activity.ts +106 -0
- package/src/state/app-bootstrap.test.ts +471 -0
- package/src/state/app-bootstrap.ts +308 -0
- package/src/state/app-context.test.ts +406 -0
- package/src/state/app-context.test.tsx +132 -0
- package/src/state/app-context.tsx +969 -0
- package/src/state/selectors.ts +67 -0
- package/src/state/session-persistence.test.ts +128 -0
- package/src/state/session-persistence.ts +161 -0
- package/src/state/ticker-refresh-queue.test.ts +46 -0
- package/src/state/ticker-refresh-queue.ts +40 -0
- package/src/state/use-inline-tickers.ts +174 -0
- package/src/state/use-quote-streaming.test.ts +37 -0
- package/src/state/use-quote-streaming.test.tsx +70 -0
- package/src/state/use-quote-streaming.ts +80 -0
- package/src/state/use-session-snapshot.ts +84 -0
- package/src/test-support/data-provider.ts +22 -0
- package/src/theme/color-utils.ts +67 -0
- package/src/theme/colors.ts +128 -0
- package/src/theme/themes.test.ts +45 -0
- package/src/theme/themes.ts +491 -0
- package/src/types/broker.ts +105 -0
- package/src/types/config.ts +434 -0
- package/src/types/data-provider.ts +81 -0
- package/src/types/financials.ts +142 -0
- package/src/types/instrument.ts +29 -0
- package/src/types/persistence.ts +21 -0
- package/src/types/plugin.ts +354 -0
- package/src/types/ticker.ts +58 -0
- package/src/types/trading.ts +85 -0
- package/src/updater.test.ts +162 -0
- package/src/updater.ts +247 -0
- package/src/utils/api-client.ts +666 -0
- package/src/utils/ascii-chart.ts +172 -0
- package/src/utils/broker-instances.ts +47 -0
- package/src/utils/cli-output.ts +83 -0
- package/src/utils/currency-units.test.ts +49 -0
- package/src/utils/currency-units.ts +82 -0
- package/src/utils/debug-log.ts +133 -0
- package/src/utils/exchange-rate-map.ts +8 -0
- package/src/utils/format.ts +158 -0
- package/src/utils/fuzzy-search.ts +34 -0
- package/src/utils/market-status.ts +95 -0
- package/src/utils/options.ts +86 -0
- package/src/utils/price-history.ts +39 -0
- package/src/utils/quote-time.test.ts +22 -0
- package/src/utils/quote-time.ts +50 -0
- package/src/utils/sec.test.ts +55 -0
- package/src/utils/sec.ts +45 -0
- package/src/utils/ticker-list.test.ts +22 -0
- package/src/utils/ticker-list.ts +35 -0
- package/src/utils/ticker-metadata.ts +51 -0
- package/src/utils/ticker-search.test.ts +227 -0
- package/src/utils/ticker-search.ts +764 -0
- package/src/utils/ticker-tokenizer.test.ts +48 -0
- package/src/utils/ticker-tokenizer.ts +72 -0
- package/src/version.ts +1 -0
- package/install.mjs +0 -69
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { GloomPlugin } from "../../../types/plugin";
|
|
2
|
+
import { detectProviders } from "./providers";
|
|
3
|
+
import { AskAiDetailTab } from "./ask-ai-detail-tab";
|
|
4
|
+
import { AiScreenerPane } from "./screener-pane";
|
|
5
|
+
import { buildAiScreenerPaneSettingsDef, getAiScreenerPaneSettings } from "./settings";
|
|
6
|
+
|
|
7
|
+
export const aiPlugin: GloomPlugin = {
|
|
8
|
+
id: "ai",
|
|
9
|
+
name: "AI",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
description: "Use local AI CLI with your financial data.",
|
|
12
|
+
toggleable: true,
|
|
13
|
+
|
|
14
|
+
setup(ctx) {
|
|
15
|
+
const detectedProviders = detectProviders();
|
|
16
|
+
const selectableProviders = detectedProviders.filter((provider) => provider.available);
|
|
17
|
+
const providerOptions = (selectableProviders.length > 0 ? selectableProviders : detectedProviders)
|
|
18
|
+
.map((provider) => ({
|
|
19
|
+
label: provider.name,
|
|
20
|
+
value: provider.id,
|
|
21
|
+
}));
|
|
22
|
+
const defaultProviderId = providerOptions[0]?.value ?? "claude";
|
|
23
|
+
|
|
24
|
+
ctx.registerDetailTab({
|
|
25
|
+
id: "ai-chat",
|
|
26
|
+
name: "Ask AI",
|
|
27
|
+
order: 60,
|
|
28
|
+
component: AskAiDetailTab,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
ctx.registerPane({
|
|
32
|
+
id: "ai-screener",
|
|
33
|
+
name: "AI Screener",
|
|
34
|
+
icon: "A",
|
|
35
|
+
component: AiScreenerPane,
|
|
36
|
+
defaultPosition: "right",
|
|
37
|
+
defaultMode: "floating",
|
|
38
|
+
defaultFloatingSize: { width: 76, height: 24 },
|
|
39
|
+
settings: (context) => buildAiScreenerPaneSettingsDef(
|
|
40
|
+
getAiScreenerPaneSettings(context.settings),
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
ctx.registerPaneTemplate({
|
|
45
|
+
id: "new-ai-screener-pane",
|
|
46
|
+
paneId: "ai-screener",
|
|
47
|
+
label: "AI Screener",
|
|
48
|
+
description: "Create a prompt-driven AI screener pane with reusable screening tabs.",
|
|
49
|
+
keywords: ["ai", "screener", "screen", "watchlist", "prompt"],
|
|
50
|
+
shortcut: { prefix: "AI", argPlaceholder: "prompt", argKind: "text" },
|
|
51
|
+
wizard: [
|
|
52
|
+
{
|
|
53
|
+
key: "providerId",
|
|
54
|
+
label: "AI Provider",
|
|
55
|
+
type: "select",
|
|
56
|
+
defaultValue: defaultProviderId,
|
|
57
|
+
options: providerOptions,
|
|
58
|
+
body: ["Choose which local AI CLI should run the initial screener."],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "prompt",
|
|
62
|
+
label: "Screener Prompt",
|
|
63
|
+
type: "textarea",
|
|
64
|
+
placeholder: "Examples: humanoid robot suppliers, defense software compounders, EM payment rails, obesity-drug picks-and-shovels...",
|
|
65
|
+
body: [
|
|
66
|
+
"Describe the screening idea in plain English.",
|
|
67
|
+
"The AI will return validated ticker ideas with a short reason for each one.",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
createInstance: (_context, options) => {
|
|
72
|
+
const prompt = options?.values?.prompt?.trim() || options?.arg?.trim() || "";
|
|
73
|
+
if (!prompt) return null;
|
|
74
|
+
const providerId = options?.values?.providerId || defaultProviderId;
|
|
75
|
+
return {
|
|
76
|
+
title: "AI Screener",
|
|
77
|
+
placement: "floating",
|
|
78
|
+
params: {
|
|
79
|
+
prompt,
|
|
80
|
+
providerId,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
|
|
3
|
+
export interface AiProvider {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
command: string;
|
|
7
|
+
available: boolean;
|
|
8
|
+
buildArgs: (prompt: string) => string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AiProviderDefinition extends Omit<AiProvider, "available"> {}
|
|
12
|
+
|
|
13
|
+
const PROVIDER_DEFS: AiProviderDefinition[] = [
|
|
14
|
+
{
|
|
15
|
+
id: "claude",
|
|
16
|
+
name: "Claude",
|
|
17
|
+
command: "claude",
|
|
18
|
+
buildArgs: (prompt) => ["-p", prompt],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "gemini",
|
|
22
|
+
name: "Gemini",
|
|
23
|
+
command: "gemini",
|
|
24
|
+
buildArgs: (prompt) => ["-p", prompt],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "codex",
|
|
28
|
+
name: "Codex",
|
|
29
|
+
command: "codex",
|
|
30
|
+
buildArgs: (prompt) => ["exec", prompt],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let detectedProviders: AiProvider[] | null = null;
|
|
35
|
+
|
|
36
|
+
function commandExists(command: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
if (typeof Bun !== "undefined" && typeof Bun.which === "function") {
|
|
39
|
+
return !!Bun.which(command);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Fall back to shell lookup when Bun.which is unavailable.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectProviders(): AiProvider[] {
|
|
54
|
+
if (detectedProviders) return detectedProviders;
|
|
55
|
+
|
|
56
|
+
detectedProviders = PROVIDER_DEFS.map((definition) => ({
|
|
57
|
+
...definition,
|
|
58
|
+
available: commandExists(definition.command),
|
|
59
|
+
}));
|
|
60
|
+
return detectedProviders;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getAvailableProviders(providers = detectProviders()): AiProvider[] {
|
|
64
|
+
return providers.filter((provider) => provider.available);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getAiProvider(providerId: string | null | undefined, providers = detectProviders()): AiProvider | null {
|
|
68
|
+
if (!providerId) return null;
|
|
69
|
+
return providers.find((provider) => provider.id === providerId) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveDefaultAiProviderId(providers = detectProviders()): string {
|
|
73
|
+
return getAvailableProviders(providers)[0]?.id ?? providers[0]?.id ?? "claude";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listAiProviderOptions(
|
|
77
|
+
preferredId?: string | null,
|
|
78
|
+
providers = detectProviders(),
|
|
79
|
+
): Array<{ label: string; value: string }> {
|
|
80
|
+
const ordered = [...providers].sort((left, right) => {
|
|
81
|
+
if (left.id === preferredId) return -1;
|
|
82
|
+
if (right.id === preferredId) return 1;
|
|
83
|
+
if (left.available !== right.available) return left.available ? -1 : 1;
|
|
84
|
+
return left.name.localeCompare(right.name);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return ordered.map((provider) => ({
|
|
88
|
+
value: provider.id,
|
|
89
|
+
label: provider.available ? provider.name : `${provider.name} (missing)`,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function __setDetectedProvidersForTests(providers: AiProvider[] | null): void {
|
|
94
|
+
detectedProviders = providers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getAiProviderDefinitions(): AiProviderDefinition[] {
|
|
98
|
+
return PROVIDER_DEFS.map((definition) => ({ ...definition }));
|
|
99
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import type { AiProvider } from "./providers";
|
|
3
|
+
|
|
4
|
+
export class AiRunCancelledError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("AI run cancelled");
|
|
7
|
+
this.name = "AiRunCancelledError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AiRunController {
|
|
12
|
+
done: Promise<string>;
|
|
13
|
+
cancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isAiRunCancelled(error: unknown): boolean {
|
|
17
|
+
return error instanceof AiRunCancelledError;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function runAiPrompt({
|
|
21
|
+
provider,
|
|
22
|
+
prompt,
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
onChunk,
|
|
25
|
+
}: {
|
|
26
|
+
provider: AiProvider;
|
|
27
|
+
prompt: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
onChunk?: (output: string) => void;
|
|
30
|
+
}): AiRunController {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
let processRef: Subprocess | null = null;
|
|
33
|
+
|
|
34
|
+
const done = (async () => {
|
|
35
|
+
const proc = spawn([provider.command, ...provider.buildArgs(prompt)], {
|
|
36
|
+
cwd,
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
});
|
|
40
|
+
processRef = proc;
|
|
41
|
+
|
|
42
|
+
const stderrPromise = new Response(proc.stderr).text().catch(() => "");
|
|
43
|
+
const stdoutReader = proc.stdout.getReader();
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let fullOutput = "";
|
|
46
|
+
|
|
47
|
+
while (true) {
|
|
48
|
+
const { done: streamDone, value } = await stdoutReader.read();
|
|
49
|
+
if (streamDone) break;
|
|
50
|
+
if (cancelled) throw new AiRunCancelledError();
|
|
51
|
+
fullOutput += decoder.decode(value, { stream: true });
|
|
52
|
+
onChunk?.(fullOutput);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const exitCode = await proc.exited;
|
|
56
|
+
const stderr = (await stderrPromise).trim();
|
|
57
|
+
|
|
58
|
+
if (cancelled) throw new AiRunCancelledError();
|
|
59
|
+
if (exitCode !== 0) {
|
|
60
|
+
throw new Error(stderr || fullOutput.trim() || `${provider.name} exited with status ${exitCode}.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const finalOutput = fullOutput.trim();
|
|
64
|
+
if (!finalOutput) {
|
|
65
|
+
throw new Error(stderr || `${provider.name} returned an empty response.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return finalOutput;
|
|
69
|
+
})();
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
done,
|
|
73
|
+
cancel: () => {
|
|
74
|
+
cancelled = true;
|
|
75
|
+
try {
|
|
76
|
+
processRef?.kill();
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore cleanup failures
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildGloomberbCliInstructions, resolveGloomberbCliCommand } from "./gloomberb-cli";
|
|
3
|
+
import {
|
|
4
|
+
buildScreenerPrompt,
|
|
5
|
+
mergeScreenerResults,
|
|
6
|
+
parseScreenerResponse,
|
|
7
|
+
} from "./screener-contract";
|
|
8
|
+
import type { AiProvider } from "./providers";
|
|
9
|
+
|
|
10
|
+
const TEST_PROVIDER: AiProvider = {
|
|
11
|
+
id: "claude",
|
|
12
|
+
name: "Claude",
|
|
13
|
+
command: "claude",
|
|
14
|
+
available: true,
|
|
15
|
+
buildArgs: () => [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("AI screener helpers", () => {
|
|
19
|
+
test("prefers the installed gloomberb command when it exists", () => {
|
|
20
|
+
const command = resolveGloomberbCliCommand({
|
|
21
|
+
cwd: "/tmp/project",
|
|
22
|
+
hasCommand: (value) => value === "gloomberb",
|
|
23
|
+
fileExists: () => true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(command).toEqual({
|
|
27
|
+
argv: ["gloomberb"],
|
|
28
|
+
display: "gloomberb",
|
|
29
|
+
mode: "installed",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("falls back to bun source mode while developing locally", () => {
|
|
34
|
+
const command = resolveGloomberbCliCommand({
|
|
35
|
+
cwd: "/tmp/project",
|
|
36
|
+
hasCommand: () => false,
|
|
37
|
+
fileExists: (path) => path === "/tmp/project/src/index.tsx",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(command).toEqual({
|
|
41
|
+
argv: ["bun", "/tmp/project/src/index.tsx"],
|
|
42
|
+
display: "bun /tmp/project/src/index.tsx",
|
|
43
|
+
mode: "bun-source",
|
|
44
|
+
});
|
|
45
|
+
expect(buildGloomberbCliInstructions(command)).toContain("Inspect a portfolio or watchlist with: bun /tmp/project/src/index.tsx portfolio [name]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("builds a screener prompt with the current date and prior results", () => {
|
|
49
|
+
const prompt = buildScreenerPrompt({
|
|
50
|
+
currentDate: "2026-04-01",
|
|
51
|
+
prompt: "Find profitable serial acquirers.",
|
|
52
|
+
provider: TEST_PROVIDER,
|
|
53
|
+
cliInstructions: [
|
|
54
|
+
"Useful commands: gloomberb help",
|
|
55
|
+
"Inspect a ticker with: gloomberb ticker <symbol>",
|
|
56
|
+
],
|
|
57
|
+
previousResults: [
|
|
58
|
+
{
|
|
59
|
+
symbol: "CSU",
|
|
60
|
+
exchange: "TSX",
|
|
61
|
+
reason: "Recurring software roll-up.",
|
|
62
|
+
resolvedName: "Constellation Software",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
includePreviousResults: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(prompt).toContain("Today is 2026-04-01.");
|
|
69
|
+
expect(prompt).toContain("You are running in the Claude CLI.");
|
|
70
|
+
expect(prompt).toContain("Useful commands: gloomberb help");
|
|
71
|
+
expect(prompt).toContain("CSU (TSX): Recurring software roll-up.");
|
|
72
|
+
expect(prompt).toContain("Return raw JSON only.");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("parses fenced JSON responses and normalizes ticker fields", () => {
|
|
76
|
+
const parsed = parseScreenerResponse(`
|
|
77
|
+
\`\`\`json
|
|
78
|
+
{
|
|
79
|
+
"title": "Compounders",
|
|
80
|
+
"summary": "High quality names",
|
|
81
|
+
"tickers": [
|
|
82
|
+
{ "symbol": " msft ", "exchange": " nasdaq ", "reason": "Recurring software cash flow" }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
\`\`\`
|
|
86
|
+
`);
|
|
87
|
+
|
|
88
|
+
expect(parsed).toEqual({
|
|
89
|
+
title: "Compounders",
|
|
90
|
+
summary: "High quality names",
|
|
91
|
+
tickers: [
|
|
92
|
+
{
|
|
93
|
+
symbol: "MSFT",
|
|
94
|
+
exchange: "NASDAQ",
|
|
95
|
+
reason: "Recurring software cash flow",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("merges reruns by updating duplicates and appending new symbols", () => {
|
|
102
|
+
const merged = mergeScreenerResults(
|
|
103
|
+
[
|
|
104
|
+
{ symbol: "AAPL", exchange: "NASDAQ", reason: "Old reason", resolvedName: "Apple Inc." },
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
{ symbol: "AAPL", exchange: "NASDAQ", reason: "Updated reason", resolvedName: "Apple Inc." },
|
|
108
|
+
{ symbol: "MSFT", exchange: "NASDAQ", reason: "New result", resolvedName: "Microsoft Corp." },
|
|
109
|
+
],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(merged).toEqual([
|
|
113
|
+
{ symbol: "AAPL", exchange: "NASDAQ", reason: "Updated reason", resolvedName: "Apple Inc." },
|
|
114
|
+
{ symbol: "MSFT", exchange: "NASDAQ", reason: "New result", resolvedName: "Microsoft Corp." },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { AiProvider } from "./providers";
|
|
2
|
+
import { truncateWithEllipsis } from "./utils";
|
|
3
|
+
|
|
4
|
+
export interface ScreenerCandidate {
|
|
5
|
+
symbol: string;
|
|
6
|
+
exchange: string;
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParsedScreenerResponse {
|
|
11
|
+
title: string | null;
|
|
12
|
+
summary: string | null;
|
|
13
|
+
tickers: ScreenerCandidate[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValidatedScreenerResult {
|
|
17
|
+
symbol: string;
|
|
18
|
+
exchange: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
resolvedName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tryParseJson(raw: string): unknown | null {
|
|
24
|
+
const trimmed = raw.trim();
|
|
25
|
+
if (!trimmed) return null;
|
|
26
|
+
|
|
27
|
+
const candidates = [
|
|
28
|
+
trimmed,
|
|
29
|
+
trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() ?? null,
|
|
30
|
+
].filter((candidate): candidate is string => !!candidate);
|
|
31
|
+
|
|
32
|
+
const firstBrace = trimmed.indexOf("{");
|
|
33
|
+
const lastBrace = trimmed.lastIndexOf("}");
|
|
34
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
35
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(candidate);
|
|
41
|
+
} catch {
|
|
42
|
+
// try next parse candidate
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeString(value: unknown): string {
|
|
49
|
+
return typeof value === "string" ? value.trim() : "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deriveScreenerTitle(prompt: string): string {
|
|
53
|
+
const normalized = prompt
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.join(" ")
|
|
56
|
+
.trim();
|
|
57
|
+
return truncateWithEllipsis(normalized || "New Screener", 22);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getScreenerPromptSignature(prompt: string, providerId: string): string {
|
|
61
|
+
return JSON.stringify([providerId.trim(), prompt.trim()]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildScreenerPrompt({
|
|
65
|
+
currentDate,
|
|
66
|
+
prompt,
|
|
67
|
+
provider,
|
|
68
|
+
cliInstructions,
|
|
69
|
+
previousResults,
|
|
70
|
+
includePreviousResults,
|
|
71
|
+
}: {
|
|
72
|
+
currentDate: string;
|
|
73
|
+
prompt: string;
|
|
74
|
+
provider: AiProvider;
|
|
75
|
+
cliInstructions: string[];
|
|
76
|
+
previousResults: ValidatedScreenerResult[];
|
|
77
|
+
includePreviousResults: boolean;
|
|
78
|
+
}): string {
|
|
79
|
+
const lines = [
|
|
80
|
+
`Today is ${currentDate}.`,
|
|
81
|
+
`You are running in the ${provider.name} CLI.`,
|
|
82
|
+
"",
|
|
83
|
+
"Find public-market tickers that match this screening prompt:",
|
|
84
|
+
prompt.trim(),
|
|
85
|
+
"",
|
|
86
|
+
"You may use the local Gloomberb CLI to validate companies before returning them:",
|
|
87
|
+
...cliInstructions.map((instruction) => `- ${instruction}`),
|
|
88
|
+
"",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
if (includePreviousResults && previousResults.length > 0) {
|
|
92
|
+
lines.push("These tickers were already found for this exact screener. Prefer new names if you can validate them:");
|
|
93
|
+
for (const result of previousResults) {
|
|
94
|
+
lines.push(`- ${result.symbol} (${result.exchange}): ${result.reason}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
lines.push(
|
|
100
|
+
"Return raw JSON only. Do not wrap it in markdown or add commentary.",
|
|
101
|
+
"Schema:",
|
|
102
|
+
'{ "title": "optional short title", "summary": "optional one-line summary", "tickers": [{ "symbol": "AAPL", "exchange": "NASDAQ", "reason": "concise reason" }] }',
|
|
103
|
+
"Rules:",
|
|
104
|
+
"- Return at most 25 unique ticker candidates.",
|
|
105
|
+
"- Use uppercase symbols.",
|
|
106
|
+
"- `reason` must be concise and specific.",
|
|
107
|
+
"- Omit any company you cannot validate with confidence.",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseScreenerResponse(raw: string): ParsedScreenerResponse {
|
|
114
|
+
const parsed = tryParseJson(raw);
|
|
115
|
+
if (!parsed || typeof parsed !== "object") {
|
|
116
|
+
throw new Error("AI screener returned invalid JSON.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const payload = parsed as Record<string, unknown>;
|
|
120
|
+
const tickersRaw = Array.isArray(payload.tickers) ? payload.tickers : null;
|
|
121
|
+
if (!tickersRaw) {
|
|
122
|
+
throw new Error("AI screener JSON did not include a `tickers` array.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tickers = tickersRaw
|
|
126
|
+
.map((entry) => {
|
|
127
|
+
if (!entry || typeof entry !== "object") return null;
|
|
128
|
+
const candidate = entry as Record<string, unknown>;
|
|
129
|
+
const symbol = normalizeString(candidate.symbol).toUpperCase();
|
|
130
|
+
const exchange = normalizeString(candidate.exchange).toUpperCase();
|
|
131
|
+
const reason = normalizeString(candidate.reason);
|
|
132
|
+
if (!symbol) return null;
|
|
133
|
+
return {
|
|
134
|
+
symbol,
|
|
135
|
+
exchange,
|
|
136
|
+
reason: reason || "No reason provided.",
|
|
137
|
+
} satisfies ScreenerCandidate;
|
|
138
|
+
})
|
|
139
|
+
.filter((entry): entry is ScreenerCandidate => entry != null)
|
|
140
|
+
.slice(0, 25);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
title: normalizeString(payload.title) || null,
|
|
144
|
+
summary: normalizeString(payload.summary) || null,
|
|
145
|
+
tickers,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function mergeScreenerResults(
|
|
150
|
+
previous: ValidatedScreenerResult[],
|
|
151
|
+
next: ValidatedScreenerResult[],
|
|
152
|
+
): ValidatedScreenerResult[] {
|
|
153
|
+
const nextBySymbol = new Map(next.map((result) => [result.symbol, result] as const));
|
|
154
|
+
const merged: ValidatedScreenerResult[] = [];
|
|
155
|
+
|
|
156
|
+
for (const result of previous) {
|
|
157
|
+
const replacement = nextBySymbol.get(result.symbol);
|
|
158
|
+
if (replacement) {
|
|
159
|
+
merged.push(replacement);
|
|
160
|
+
nextBySymbol.delete(result.symbol);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
merged.push(result);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const result of next) {
|
|
167
|
+
if (nextBySymbol.has(result.symbol)) {
|
|
168
|
+
merged.push(result);
|
|
169
|
+
nextBySymbol.delete(result.symbol);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return merged;
|
|
174
|
+
}
|