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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. 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
+ }
@@ -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
+ }