kalshi-trading-bot-cli 2.1.0
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 +21 -0
- package/README.md +360 -0
- package/assets/kalshi-flow-light.png +0 -0
- package/assets/screenshot.png +0 -0
- package/env.example +43 -0
- package/kalshi-flow-light.png +0 -0
- package/package.json +66 -0
- package/src/agent/agent.ts +249 -0
- package/src/agent/channels.ts +53 -0
- package/src/agent/index.ts +29 -0
- package/src/agent/prompts.ts +171 -0
- package/src/agent/run-context.ts +23 -0
- package/src/agent/scratchpad.ts +465 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/tool-executor.ts +166 -0
- package/src/agent/types.ts +221 -0
- package/src/audit/index.ts +25 -0
- package/src/audit/reader.ts +43 -0
- package/src/audit/trail.ts +29 -0
- package/src/audit/types.ts +133 -0
- package/src/backtest/discovery.ts +170 -0
- package/src/backtest/fetcher.ts +247 -0
- package/src/backtest/metrics.ts +165 -0
- package/src/backtest/renderer.ts +196 -0
- package/src/backtest/types.ts +45 -0
- package/src/cli.ts +943 -0
- package/src/commands/alerts.ts +48 -0
- package/src/commands/analyze.ts +662 -0
- package/src/commands/backtest.ts +276 -0
- package/src/commands/clear-cache.ts +24 -0
- package/src/commands/config.ts +107 -0
- package/src/commands/dispatch.ts +473 -0
- package/src/commands/edge.ts +62 -0
- package/src/commands/formatters.ts +339 -0
- package/src/commands/help.ts +263 -0
- package/src/commands/helpers.ts +48 -0
- package/src/commands/index.ts +287 -0
- package/src/commands/json.ts +43 -0
- package/src/commands/parse-args.ts +229 -0
- package/src/commands/portfolio.ts +236 -0
- package/src/commands/review.ts +176 -0
- package/src/commands/scan-formatters.ts +98 -0
- package/src/commands/scan.ts +38 -0
- package/src/commands/search-edge.ts +139 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/themes.ts +117 -0
- package/src/commands/watch.ts +295 -0
- package/src/components/answer-box.ts +57 -0
- package/src/components/approval-prompt.ts +34 -0
- package/src/components/browse-list.ts +134 -0
- package/src/components/chat-log.ts +291 -0
- package/src/components/custom-editor.ts +18 -0
- package/src/components/debug-panel.ts +52 -0
- package/src/components/index.ts +17 -0
- package/src/components/intro.ts +92 -0
- package/src/components/select-list.ts +155 -0
- package/src/components/tool-event.ts +127 -0
- package/src/components/user-query.ts +18 -0
- package/src/components/working-indicator.ts +87 -0
- package/src/controllers/agent-runner.ts +283 -0
- package/src/controllers/browse.ts +1013 -0
- package/src/controllers/index.ts +7 -0
- package/src/controllers/input-history.ts +76 -0
- package/src/controllers/model-selection.ts +244 -0
- package/src/db/alerts.ts +77 -0
- package/src/db/edge.ts +105 -0
- package/src/db/event-index.ts +323 -0
- package/src/db/events.ts +41 -0
- package/src/db/index.ts +60 -0
- package/src/db/octagon-cache.ts +118 -0
- package/src/db/positions.ts +71 -0
- package/src/db/risk.ts +51 -0
- package/src/db/schema.ts +227 -0
- package/src/db/themes.ts +34 -0
- package/src/db/trades.ts +50 -0
- package/src/eval/brier.ts +90 -0
- package/src/eval/index.ts +4 -0
- package/src/eval/performance.ts +87 -0
- package/src/gateway/access-control.ts +253 -0
- package/src/gateway/agent-runner.ts +75 -0
- package/src/gateway/alerts/formatter.ts +90 -0
- package/src/gateway/alerts/index.ts +4 -0
- package/src/gateway/alerts/router.ts +32 -0
- package/src/gateway/alerts/terminal.ts +16 -0
- package/src/gateway/alerts/types.ts +13 -0
- package/src/gateway/channels/index.ts +9 -0
- package/src/gateway/channels/manager.ts +153 -0
- package/src/gateway/channels/types.ts +48 -0
- package/src/gateway/channels/whatsapp/README.md +234 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
- package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
- package/src/gateway/channels/whatsapp/error.ts +122 -0
- package/src/gateway/channels/whatsapp/inbound.ts +326 -0
- package/src/gateway/channels/whatsapp/index.ts +5 -0
- package/src/gateway/channels/whatsapp/lid.ts +56 -0
- package/src/gateway/channels/whatsapp/logger.ts +25 -0
- package/src/gateway/channels/whatsapp/login.ts +94 -0
- package/src/gateway/channels/whatsapp/outbound.ts +119 -0
- package/src/gateway/channels/whatsapp/plugin.ts +54 -0
- package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
- package/src/gateway/channels/whatsapp/runtime.ts +122 -0
- package/src/gateway/channels/whatsapp/session.ts +89 -0
- package/src/gateway/channels/whatsapp/types.ts +32 -0
- package/src/gateway/commands/handler.ts +64 -0
- package/src/gateway/commands/index.ts +7 -0
- package/src/gateway/commands/parser.ts +29 -0
- package/src/gateway/commands/wa-formatters.ts +92 -0
- package/src/gateway/config.ts +244 -0
- package/src/gateway/extension-points.ts +17 -0
- package/src/gateway/gateway.ts +301 -0
- package/src/gateway/group/history-buffer.ts +75 -0
- package/src/gateway/group/index.ts +8 -0
- package/src/gateway/group/member-tracker.ts +60 -0
- package/src/gateway/group/mention-detection.ts +42 -0
- package/src/gateway/heartbeat/index.ts +8 -0
- package/src/gateway/heartbeat/prompt.ts +73 -0
- package/src/gateway/heartbeat/runner.ts +200 -0
- package/src/gateway/heartbeat/suppression.ts +74 -0
- package/src/gateway/index.ts +138 -0
- package/src/gateway/routing/resolve-route.ts +119 -0
- package/src/gateway/sessions/store.ts +65 -0
- package/src/gateway/types.ts +11 -0
- package/src/gateway/utils.ts +82 -0
- package/src/index.tsx +30 -0
- package/src/model/llm.ts +247 -0
- package/src/providers.ts +94 -0
- package/src/risk/circuit-breaker.ts +113 -0
- package/src/risk/correlation.ts +40 -0
- package/src/risk/gate.ts +125 -0
- package/src/risk/index.ts +10 -0
- package/src/risk/kelly.ts +230 -0
- package/src/scan/alerter.ts +64 -0
- package/src/scan/edge-computer.ts +164 -0
- package/src/scan/invoker.ts +199 -0
- package/src/scan/loop.ts +184 -0
- package/src/scan/octagon-client.ts +627 -0
- package/src/scan/octagon-events-api.ts +105 -0
- package/src/scan/octagon-prefetch.ts +172 -0
- package/src/scan/theme-resolver.ts +179 -0
- package/src/scan/types.ts +62 -0
- package/src/scan/watchdog.ts +126 -0
- package/src/setup/wizard.ts +659 -0
- package/src/theme.ts +67 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +419 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/kalshi/api.ts +251 -0
- package/src/tools/kalshi/dlq.ts +35 -0
- package/src/tools/kalshi/events.ts +84 -0
- package/src/tools/kalshi/exchange.ts +24 -0
- package/src/tools/kalshi/historical.ts +89 -0
- package/src/tools/kalshi/index.ts +11 -0
- package/src/tools/kalshi/kalshi-search.ts +437 -0
- package/src/tools/kalshi/kalshi-trade.ts +102 -0
- package/src/tools/kalshi/markets.ts +76 -0
- package/src/tools/kalshi/portfolio.ts +100 -0
- package/src/tools/kalshi/search-index.ts +198 -0
- package/src/tools/kalshi/series.ts +16 -0
- package/src/tools/kalshi/trading.ts +115 -0
- package/src/tools/kalshi/types.ts +199 -0
- package/src/tools/registry.ts +160 -0
- package/src/tools/search/index.ts +25 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/types.ts +53 -0
- package/src/tools/v2/edge-query.ts +135 -0
- package/src/tools/v2/octagon-report.ts +112 -0
- package/src/tools/v2/portfolio-query.ts +79 -0
- package/src/tools/v2/portfolio-review.ts +59 -0
- package/src/tools/v2/risk-status.ts +94 -0
- package/src/tools/v2/scan.ts +78 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/types/whiskeysockets-baileys.d.ts +41 -0
- package/src/types.ts +22 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/bot-config.ts +219 -0
- package/src/utils/cache.ts +195 -0
- package/src/utils/config.ts +113 -0
- package/src/utils/env.ts +111 -0
- package/src/utils/errors.ts +313 -0
- package/src/utils/history-context.ts +32 -0
- package/src/utils/in-memory-chat-history.ts +268 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/model.ts +70 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/paths.ts +12 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/telemetry.ts +103 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +18 -0
- package/src/utils/tokens.ts +36 -0
- package/src/utils/tool-description.ts +61 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import { appPath } from './paths.js';
|
|
4
|
+
|
|
5
|
+
export interface BotConfig {
|
|
6
|
+
scan: { interval: number; theme: string };
|
|
7
|
+
risk: {
|
|
8
|
+
kelly_multiplier: number;
|
|
9
|
+
min_edge_threshold: number;
|
|
10
|
+
max_position_pct: number;
|
|
11
|
+
max_spread_cents: number;
|
|
12
|
+
min_volume_24h: number;
|
|
13
|
+
liquidity_haircut: number;
|
|
14
|
+
liquidity_spread_threshold: number;
|
|
15
|
+
liquidity_volume_threshold: number;
|
|
16
|
+
max_drawdown: number;
|
|
17
|
+
max_positions: number;
|
|
18
|
+
max_per_category: number;
|
|
19
|
+
daily_loss_limit: number;
|
|
20
|
+
};
|
|
21
|
+
octagon: { daily_credit_ceiling: number; price_move_threshold: number };
|
|
22
|
+
alerts: { min_edge: number; channels: string[] };
|
|
23
|
+
watch: { min_interval_minutes: number; ticker_interval_seconds: number };
|
|
24
|
+
gateway: { whatsapp: { enabled: boolean } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULTS: BotConfig = {
|
|
28
|
+
scan: { interval: 60, theme: 'top50' },
|
|
29
|
+
risk: { kelly_multiplier: 0.5, min_edge_threshold: 0.05, max_position_pct: 0.10, max_spread_cents: 5, min_volume_24h: 500, liquidity_haircut: 0.50, liquidity_spread_threshold: 3, liquidity_volume_threshold: 1000, max_drawdown: 0.20, max_positions: 10, max_per_category: 3, daily_loss_limit: 200 },
|
|
30
|
+
octagon: { daily_credit_ceiling: 100, price_move_threshold: 0.05 },
|
|
31
|
+
alerts: { min_edge: 0.05, channels: ['terminal'] },
|
|
32
|
+
watch: { min_interval_minutes: 15, ticker_interval_seconds: 5 },
|
|
33
|
+
gateway: { whatsapp: { enabled: false } },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const CONFIG_PATH = appPath('config.json');
|
|
37
|
+
|
|
38
|
+
// In-memory cache to avoid re-reading config.json on every getBotSetting call
|
|
39
|
+
let _cachedConfig: BotConfig | null = null;
|
|
40
|
+
|
|
41
|
+
function deepMerge(defaults: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
42
|
+
const result: Record<string, unknown> = { ...defaults };
|
|
43
|
+
for (const key of Object.keys(overrides)) {
|
|
44
|
+
if (
|
|
45
|
+
result[key] !== null &&
|
|
46
|
+
typeof result[key] === 'object' &&
|
|
47
|
+
!Array.isArray(result[key]) &&
|
|
48
|
+
typeof overrides[key] === 'object' &&
|
|
49
|
+
overrides[key] !== null &&
|
|
50
|
+
!Array.isArray(overrides[key])
|
|
51
|
+
) {
|
|
52
|
+
result[key] = deepMerge(result[key] as Record<string, unknown>, overrides[key] as Record<string, unknown>);
|
|
53
|
+
} else {
|
|
54
|
+
result[key] = overrides[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadBotConfig(): BotConfig {
|
|
61
|
+
if (_cachedConfig) return _cachedConfig;
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
_cachedConfig = deepMerge(DEFAULTS as unknown as Record<string, unknown>, parsed) as unknown as BotConfig;
|
|
66
|
+
return _cachedConfig;
|
|
67
|
+
} catch {
|
|
68
|
+
_cachedConfig = structuredClone(DEFAULTS);
|
|
69
|
+
return _cachedConfig;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveBotConfig(config: BotConfig): boolean {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
76
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
77
|
+
_cachedConfig = config;
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walkGet(obj: Record<string, unknown>, keys: string[]): unknown {
|
|
85
|
+
let current: unknown = obj;
|
|
86
|
+
for (const k of keys) {
|
|
87
|
+
if (current === null || typeof current !== 'object') return undefined;
|
|
88
|
+
current = (current as Record<string, unknown>)[k];
|
|
89
|
+
}
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function walkSet(obj: Record<string, unknown>, keys: string[], value: unknown): void {
|
|
94
|
+
let current: Record<string, unknown> = obj;
|
|
95
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
96
|
+
if (typeof current[keys[i]] !== 'object' || current[keys[i]] === null) {
|
|
97
|
+
current[keys[i]] = {};
|
|
98
|
+
}
|
|
99
|
+
current = current[keys[i]] as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
current[keys[keys.length - 1]] = value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getBotSetting(dotKey: string): unknown {
|
|
105
|
+
const config = loadBotConfig();
|
|
106
|
+
const keys = dotKey.split('.');
|
|
107
|
+
return walkGet(config as unknown as Record<string, unknown>, keys);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const NUMERIC_VALIDATORS: Record<string, (v: number) => string | null> = {
|
|
111
|
+
'watch.min_interval_minutes': (v) => v > 0 ? null : 'must be > 0',
|
|
112
|
+
'watch.ticker_interval_seconds': (v) => v > 0 ? null : 'must be > 0',
|
|
113
|
+
'scan.interval': (v) => v > 0 ? null : 'must be > 0',
|
|
114
|
+
'risk.min_edge_threshold': (v) => v >= 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
115
|
+
'risk.max_position_pct': (v) => v > 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
116
|
+
'risk.liquidity_haircut': (v) => v >= 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
117
|
+
'risk.max_drawdown': (v) => v > 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
118
|
+
'risk.kelly_multiplier': (v) => v > 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
119
|
+
'risk.daily_loss_limit': (v) => v > 0 ? null : 'must be > 0',
|
|
120
|
+
'risk.max_spread_cents': (v) => v >= 0 ? null : 'must be >= 0',
|
|
121
|
+
'risk.min_volume_24h': (v) => v >= 0 ? null : 'must be >= 0',
|
|
122
|
+
'risk.max_positions': (v) => v > 0 && Number.isInteger(v) ? null : 'must be a positive integer',
|
|
123
|
+
'risk.max_per_category': (v) => v > 0 && Number.isInteger(v) ? null : 'must be a positive integer',
|
|
124
|
+
'octagon.price_move_threshold': (v) => v >= 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
125
|
+
'octagon.daily_credit_ceiling': (v) => v >= 0 ? null : 'must be >= 0',
|
|
126
|
+
'alerts.min_edge': (v) => v >= 0 && v <= 1 ? null : 'must be between 0 and 1',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function setBotSetting(dotKey: string, rawValue: string): { oldValue: unknown; newValue: unknown } {
|
|
130
|
+
const keys = dotKey.split('.');
|
|
131
|
+
const defaultValue = walkGet(DEFAULTS as unknown as Record<string, unknown>, keys);
|
|
132
|
+
if (defaultValue === undefined) {
|
|
133
|
+
throw new Error(`Unknown config key: ${dotKey}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let newValue: unknown;
|
|
137
|
+
const defaultType = typeof defaultValue;
|
|
138
|
+
if (defaultType === 'number') {
|
|
139
|
+
newValue = Number(rawValue);
|
|
140
|
+
if (isNaN(newValue as number)) throw new Error(`Invalid number for ${dotKey}: ${rawValue}`);
|
|
141
|
+
const validator = NUMERIC_VALIDATORS[dotKey];
|
|
142
|
+
if (validator) {
|
|
143
|
+
const err = validator(newValue as number);
|
|
144
|
+
if (err) throw new Error(`Invalid value for ${dotKey}: ${rawValue} (${err})`);
|
|
145
|
+
}
|
|
146
|
+
} else if (defaultType === 'boolean') {
|
|
147
|
+
const lower = rawValue.toLowerCase();
|
|
148
|
+
if (lower !== 'true' && lower !== 'false') {
|
|
149
|
+
throw new Error(`Invalid boolean for ${dotKey}: ${rawValue} (expected 'true' or 'false')`);
|
|
150
|
+
}
|
|
151
|
+
newValue = lower === 'true';
|
|
152
|
+
} else if (Array.isArray(defaultValue)) {
|
|
153
|
+
try {
|
|
154
|
+
newValue = JSON.parse(rawValue);
|
|
155
|
+
} catch {
|
|
156
|
+
throw new Error(`Invalid JSON for ${dotKey}: ${rawValue}`);
|
|
157
|
+
}
|
|
158
|
+
if (!Array.isArray(newValue)) {
|
|
159
|
+
throw new Error(`Expected JSON array for ${dotKey}: ${rawValue}`);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
newValue = rawValue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const config = loadBotConfig();
|
|
166
|
+
const oldValue = walkGet(config as unknown as Record<string, unknown>, keys);
|
|
167
|
+
walkSet(config as unknown as Record<string, unknown>, keys, newValue);
|
|
168
|
+
saveBotConfig(config);
|
|
169
|
+
|
|
170
|
+
return { oldValue, newValue };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface FlatSetting {
|
|
174
|
+
key: string;
|
|
175
|
+
value: unknown;
|
|
176
|
+
default: unknown;
|
|
177
|
+
isDefault: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function flatten(
|
|
181
|
+
obj: Record<string, unknown>,
|
|
182
|
+
defaults: Record<string, unknown>,
|
|
183
|
+
prefix: string,
|
|
184
|
+
result: FlatSetting[],
|
|
185
|
+
): void {
|
|
186
|
+
for (const key of Object.keys(defaults)) {
|
|
187
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
188
|
+
const val = obj[key];
|
|
189
|
+
const def = defaults[key];
|
|
190
|
+
|
|
191
|
+
if (def !== null && typeof def === 'object' && !Array.isArray(def)) {
|
|
192
|
+
flatten(
|
|
193
|
+
(val ?? {}) as Record<string, unknown>,
|
|
194
|
+
def as Record<string, unknown>,
|
|
195
|
+
fullKey,
|
|
196
|
+
result,
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
result.push({
|
|
200
|
+
key: fullKey,
|
|
201
|
+
value: val ?? def,
|
|
202
|
+
default: def,
|
|
203
|
+
isDefault: JSON.stringify(val ?? def) === JSON.stringify(def),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getAllSettings(): FlatSetting[] {
|
|
210
|
+
const config = loadBotConfig();
|
|
211
|
+
const result: FlatSetting[] = [];
|
|
212
|
+
flatten(
|
|
213
|
+
config as unknown as Record<string, unknown>,
|
|
214
|
+
DEFAULTS as unknown as Record<string, unknown>,
|
|
215
|
+
'',
|
|
216
|
+
result,
|
|
217
|
+
);
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local file cache for API responses.
|
|
3
|
+
*
|
|
4
|
+
* Pure storage layer — knows HOW to cache, not WHAT to cache.
|
|
5
|
+
* Callers opt in by passing `{ cacheable: true }` to API calls;
|
|
6
|
+
* the cache module unconditionally stores and retrieves keyed JSON.
|
|
7
|
+
*
|
|
8
|
+
* Cache files live in the app cache directory (gitignored).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
import { appPath } from './paths.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A persisted cache entry.
|
|
22
|
+
* Stores enough context to validate freshness and aid debugging.
|
|
23
|
+
*/
|
|
24
|
+
interface CacheEntry {
|
|
25
|
+
endpoint: string;
|
|
26
|
+
params: Record<string, unknown>;
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
url: string;
|
|
29
|
+
cachedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CACHE_DIR = appPath('cache');
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Helpers
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a human-readable label for log messages.
|
|
40
|
+
* If params contains a 'ticker' field, includes it for readability.
|
|
41
|
+
* Also appends all other defined params as key=value pairs.
|
|
42
|
+
* Example: "/prices/ (AAPL) interval=day limit=30" or "/search/ query=earnings"
|
|
43
|
+
*/
|
|
44
|
+
export function describeRequest(
|
|
45
|
+
endpoint: string,
|
|
46
|
+
params: Record<string, string | number | string[] | undefined>
|
|
47
|
+
): string {
|
|
48
|
+
const ticker = typeof params.ticker === 'string' ? params.ticker.toUpperCase() : null;
|
|
49
|
+
const base = ticker ? `${endpoint} (${ticker})` : endpoint;
|
|
50
|
+
const extraParams = Object.entries(params)
|
|
51
|
+
.filter(([key, value]) => key !== 'ticker' && value !== undefined && value !== null)
|
|
52
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
53
|
+
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : String(value)}`);
|
|
54
|
+
return extraParams.length > 0 ? `${base} ${extraParams.join(' ')}` : base;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a deterministic cache key from endpoint + params.
|
|
59
|
+
* Params are sorted alphabetically so insertion order doesn't matter.
|
|
60
|
+
*
|
|
61
|
+
* If params contains a 'ticker' field, it's used as a prefix for human-readable filenames.
|
|
62
|
+
* Resulting path: {clean_endpoint}/{TICKER_}{hash}.json (if ticker present)
|
|
63
|
+
* {clean_endpoint}/{hash}.json (otherwise)
|
|
64
|
+
* Example: prices/AAPL_a1b2c3d4e5f6.json
|
|
65
|
+
*/
|
|
66
|
+
export function buildCacheKey(
|
|
67
|
+
endpoint: string,
|
|
68
|
+
params: Record<string, string | number | string[] | undefined>
|
|
69
|
+
): string {
|
|
70
|
+
// Build a canonical string from sorted, non-empty params
|
|
71
|
+
const sortedParams = Object.entries(params)
|
|
72
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
73
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
74
|
+
.map(([k, v]) => `${k}=${Array.isArray(v) ? [...v].sort().join(',') : v}`)
|
|
75
|
+
.join('&');
|
|
76
|
+
|
|
77
|
+
const raw = `${endpoint}?${sortedParams}`;
|
|
78
|
+
const hash = createHash('md5').update(raw).digest('hex').slice(0, 12);
|
|
79
|
+
|
|
80
|
+
// Turn "/prices/" → "prices"
|
|
81
|
+
const cleanEndpoint = endpoint
|
|
82
|
+
.replace(/^\//, '')
|
|
83
|
+
.replace(/\/$/, '')
|
|
84
|
+
.replace(/\//g, '_');
|
|
85
|
+
|
|
86
|
+
// Prefix with ticker when available for human-readable filenames (optional)
|
|
87
|
+
const ticker = typeof params.ticker === 'string' ? params.ticker.toUpperCase() : null;
|
|
88
|
+
const prefix = ticker ? `${ticker}_` : '';
|
|
89
|
+
|
|
90
|
+
return `${cleanEndpoint}/${prefix}${hash}.json`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate that a parsed object has the shape of a CacheEntry.
|
|
95
|
+
* Guards against truncated writes, schema changes, or manual edits.
|
|
96
|
+
*/
|
|
97
|
+
function isValidCacheEntry(value: unknown): value is CacheEntry {
|
|
98
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
99
|
+
const obj = value as Record<string, unknown>;
|
|
100
|
+
return (
|
|
101
|
+
typeof obj.endpoint === 'string' &&
|
|
102
|
+
typeof obj.url === 'string' &&
|
|
103
|
+
typeof obj.cachedAt === 'string' &&
|
|
104
|
+
typeof obj.data === 'object' &&
|
|
105
|
+
obj.data !== null
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Safely remove a cache file (e.g. when it's corrupted).
|
|
111
|
+
* Logs on failure but never throws.
|
|
112
|
+
*/
|
|
113
|
+
function removeCacheFile(filepath: string): void {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(filepath);
|
|
116
|
+
} catch {
|
|
117
|
+
// Best-effort cleanup — not critical
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// Public API
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read a cached API response if it exists.
|
|
127
|
+
* Returns null on cache miss or any read/parse error.
|
|
128
|
+
*/
|
|
129
|
+
export function readCache(
|
|
130
|
+
endpoint: string,
|
|
131
|
+
params: Record<string, string | number | string[] | undefined>
|
|
132
|
+
): { data: Record<string, unknown>; url: string } | null {
|
|
133
|
+
const cacheKey = buildCacheKey(endpoint, params);
|
|
134
|
+
const filepath = join(CACHE_DIR, cacheKey);
|
|
135
|
+
const label = describeRequest(endpoint, params);
|
|
136
|
+
|
|
137
|
+
if (!existsSync(filepath)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
143
|
+
const parsed: unknown = JSON.parse(content);
|
|
144
|
+
|
|
145
|
+
// Validate entry structure
|
|
146
|
+
if (!isValidCacheEntry(parsed)) {
|
|
147
|
+
logger.warn(`Cache corrupted (invalid structure): ${label}`, { filepath });
|
|
148
|
+
removeCacheFile(filepath);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { data: parsed.data, url: parsed.url };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
logger.warn(`Cache read error: ${label} — ${message}`, { filepath });
|
|
156
|
+
// Remove corrupted file so subsequent calls don't hit the same error
|
|
157
|
+
removeCacheFile(filepath);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Write an API response to the cache.
|
|
164
|
+
* Logs on I/O errors but never throws — cache writes must not
|
|
165
|
+
* break the application.
|
|
166
|
+
*/
|
|
167
|
+
export function writeCache(
|
|
168
|
+
endpoint: string,
|
|
169
|
+
params: Record<string, string | number | string[] | undefined>,
|
|
170
|
+
data: Record<string, unknown>,
|
|
171
|
+
url: string
|
|
172
|
+
): void {
|
|
173
|
+
const cacheKey = buildCacheKey(endpoint, params);
|
|
174
|
+
const filepath = join(CACHE_DIR, cacheKey);
|
|
175
|
+
const label = describeRequest(endpoint, params);
|
|
176
|
+
|
|
177
|
+
const entry: CacheEntry = {
|
|
178
|
+
endpoint,
|
|
179
|
+
params,
|
|
180
|
+
data,
|
|
181
|
+
url,
|
|
182
|
+
cachedAt: new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const dir = dirname(filepath);
|
|
187
|
+
if (!existsSync(dir)) {
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
writeFileSync(filepath, JSON.stringify(entry, null, 2));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
logger.warn(`Cache write error: ${label} — ${message}`, { filepath });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import { appPath } from './paths.js';
|
|
4
|
+
|
|
5
|
+
const SETTINGS_FILE = appPath('settings.json');
|
|
6
|
+
|
|
7
|
+
// Map legacy model IDs to provider IDs for migration
|
|
8
|
+
const MODEL_TO_PROVIDER_MAP: Record<string, string> = {
|
|
9
|
+
'gpt-5.4': 'openai',
|
|
10
|
+
'gpt-5.2': 'openai',
|
|
11
|
+
'claude-sonnet-4-5': 'anthropic',
|
|
12
|
+
'gemini-3': 'google',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Deprecated model IDs to upgrade on load
|
|
16
|
+
const DEPRECATED_MODEL_UPGRADES: Record<string, string> = {
|
|
17
|
+
'gpt-5.2': 'gpt-5.4',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface Config {
|
|
21
|
+
provider?: string;
|
|
22
|
+
modelId?: string; // Selected model ID (e.g., "gpt-5.4", "ollama:llama3.1")
|
|
23
|
+
model?: string; // Legacy key, kept for migration
|
|
24
|
+
anonymousId?: string; // Persistent anonymous ID for telemetry
|
|
25
|
+
memory?: {
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
embeddingProvider?: 'openai' | 'gemini' | 'ollama' | 'auto';
|
|
28
|
+
embeddingModel?: string;
|
|
29
|
+
maxSessionContextTokens?: number;
|
|
30
|
+
};
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadConfig(): Config {
|
|
35
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(SETTINGS_FILE, 'utf-8');
|
|
41
|
+
let config = JSON.parse(content) as Config;
|
|
42
|
+
|
|
43
|
+
// Upgrade deprecated model IDs (e.g. gpt-5.2 -> gpt-5.4)
|
|
44
|
+
if (config.modelId && DEPRECATED_MODEL_UPGRADES[config.modelId]) {
|
|
45
|
+
config.modelId = DEPRECATED_MODEL_UPGRADES[config.modelId];
|
|
46
|
+
saveConfig(config);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return config;
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveConfig(config: Config): boolean {
|
|
56
|
+
try {
|
|
57
|
+
const dir = dirname(SETTINGS_FILE);
|
|
58
|
+
if (!existsSync(dir)) {
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(config, null, 2));
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Migrates legacy `model` setting to `provider` setting.
|
|
70
|
+
* Called once on config load to ensure backwards compatibility.
|
|
71
|
+
*/
|
|
72
|
+
function migrateModelToProvider(config: Config): Config {
|
|
73
|
+
// If already has provider, no migration needed
|
|
74
|
+
if (config.provider) {
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If has legacy model setting, convert to provider
|
|
79
|
+
if (config.model) {
|
|
80
|
+
const providerId = MODEL_TO_PROVIDER_MAP[config.model];
|
|
81
|
+
if (providerId) {
|
|
82
|
+
config.provider = providerId;
|
|
83
|
+
delete config.model;
|
|
84
|
+
// Save the migrated config
|
|
85
|
+
saveConfig(config);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getSetting<T>(key: string, defaultValue: T): T {
|
|
93
|
+
let config = loadConfig();
|
|
94
|
+
|
|
95
|
+
// Run migration if accessing provider setting
|
|
96
|
+
if (key === 'provider') {
|
|
97
|
+
config = migrateModelToProvider(config);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (config[key] as T) ?? defaultValue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setSetting(key: string, value: unknown): boolean {
|
|
104
|
+
const config = loadConfig();
|
|
105
|
+
config[key] = value;
|
|
106
|
+
|
|
107
|
+
// If setting provider, remove legacy model key
|
|
108
|
+
if (key === 'provider' && config.model) {
|
|
109
|
+
delete config.model;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return saveConfig(config);
|
|
113
|
+
}
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { config } from 'dotenv';
|
|
4
|
+
import { getProviderById } from '@/providers';
|
|
5
|
+
import { appPath, getAppDir } from './paths.js';
|
|
6
|
+
|
|
7
|
+
// Resolve .env from a CWD override (dev workflow) or the home config dir
|
|
8
|
+
// (default for `bunx` / global installs). The home path is also where
|
|
9
|
+
// `saveApiKeyToEnv` writes new keys.
|
|
10
|
+
const HOME_ENV_PATH = appPath('.env');
|
|
11
|
+
const CWD_ENV_PATH = resolve(process.cwd(), '.env');
|
|
12
|
+
export const ENV_PATH = existsSync(CWD_ENV_PATH) ? CWD_ENV_PATH : HOME_ENV_PATH;
|
|
13
|
+
|
|
14
|
+
// Load .env on module import
|
|
15
|
+
config({ path: ENV_PATH, quiet: true });
|
|
16
|
+
|
|
17
|
+
export function getApiKeyNameForProvider(providerId: string): string | undefined {
|
|
18
|
+
return getProviderById(providerId)?.apiKeyEnvVar;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getProviderDisplayName(providerId: string): string {
|
|
22
|
+
return getProviderById(providerId)?.displayName ?? providerId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkApiKeyExistsForProvider(providerId: string): boolean {
|
|
26
|
+
const apiKeyName = getApiKeyNameForProvider(providerId);
|
|
27
|
+
if (!apiKeyName) return true;
|
|
28
|
+
return checkApiKeyExists(apiKeyName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function checkApiKeyExists(apiKeyName: string): boolean {
|
|
32
|
+
const value = process.env[apiKeyName];
|
|
33
|
+
if (value && value.trim() && !value.trim().startsWith('your-')) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Also check .env file directly
|
|
38
|
+
if (existsSync(ENV_PATH)) {
|
|
39
|
+
const envContent = readFileSync(ENV_PATH, 'utf-8');
|
|
40
|
+
const lines = envContent.split('\n');
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
|
|
44
|
+
const [key, ...valueParts] = trimmed.split('=');
|
|
45
|
+
if (key.trim() === apiKeyName) {
|
|
46
|
+
const val = valueParts.join('=').trim();
|
|
47
|
+
if (val && !val.startsWith('your-')) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveApiKeyToEnv(apiKeyName: string, apiKeyValue: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
let lines: string[] = [];
|
|
61
|
+
let keyUpdated = false;
|
|
62
|
+
|
|
63
|
+
if (existsSync(ENV_PATH)) {
|
|
64
|
+
const existingContent = readFileSync(ENV_PATH, 'utf-8');
|
|
65
|
+
const existingLines = existingContent.split('\n');
|
|
66
|
+
|
|
67
|
+
for (const line of existingLines) {
|
|
68
|
+
const stripped = line.trim();
|
|
69
|
+
if (!stripped || stripped.startsWith('#')) {
|
|
70
|
+
lines.push(line);
|
|
71
|
+
} else if (stripped.includes('=')) {
|
|
72
|
+
const key = stripped.split('=')[0].trim();
|
|
73
|
+
if (key === apiKeyName) {
|
|
74
|
+
lines.push(`${apiKeyName}=${apiKeyValue}`);
|
|
75
|
+
keyUpdated = true;
|
|
76
|
+
} else {
|
|
77
|
+
lines.push(line);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!keyUpdated) {
|
|
85
|
+
if (lines.length > 0 && !lines[lines.length - 1].endsWith('\n')) {
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
lines.push(`${apiKeyName}=${apiKeyValue}`);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
lines.push('# LLM API Keys');
|
|
92
|
+
lines.push(`${apiKeyName}=${apiKeyValue}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mkdirSync(getAppDir(), { recursive: true });
|
|
96
|
+
writeFileSync(ENV_PATH, lines.join('\n'));
|
|
97
|
+
|
|
98
|
+
// Reload environment variables
|
|
99
|
+
config({ path: ENV_PATH, override: true, quiet: true });
|
|
100
|
+
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function saveApiKeyForProvider(providerId: string, apiKey: string): boolean {
|
|
108
|
+
const apiKeyName = getApiKeyNameForProvider(providerId);
|
|
109
|
+
if (!apiKeyName) return false;
|
|
110
|
+
return saveApiKeyToEnv(apiKeyName, apiKey);
|
|
111
|
+
}
|