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,419 @@
1
+ /**
2
+ * web_fetch tool — lightweight one-shot page reader with caching.
3
+ *
4
+ * Core extraction logic ported from OpenClaw's src/agents/tools/web-fetch.ts (MIT license).
5
+ * Adapted for the app's LangChain DynamicStructuredTool + Zod framework.
6
+ *
7
+ * Differences from OpenClaw:
8
+ * - fetchWithSsrFGuard replaced with plain fetch + manual redirect handling
9
+ * - Firecrawl fallback removed (falls back to htmlToMarkdown instead)
10
+ * - Config resolution replaced with hardcoded defaults
11
+ * - Tool wrapper uses LangChain DynamicStructuredTool + Zod (not AnyAgentTool + TypeBox)
12
+ */
13
+ import { DynamicStructuredTool } from '@langchain/core/tools';
14
+ import { z } from 'zod';
15
+ import { formatToolResult } from '../types.js';
16
+ import { wrapExternalContent, wrapWebContent } from './external-content.js';
17
+ import {
18
+ extractReadableContent,
19
+ htmlToMarkdown,
20
+ markdownToText,
21
+ truncateText,
22
+ type ExtractMode,
23
+ } from './web-fetch-utils.js';
24
+ import {
25
+ type CacheEntry,
26
+ DEFAULT_CACHE_TTL_MINUTES,
27
+ DEFAULT_TIMEOUT_SECONDS,
28
+ normalizeCacheKey,
29
+ readCache,
30
+ readResponseText,
31
+ resolveCacheTtlMs,
32
+ resolveTimeoutSeconds,
33
+ withTimeout,
34
+ writeCache,
35
+ } from './cache.js';
36
+
37
+ /**
38
+ * Rich description for the web_fetch tool.
39
+ * Used in the system prompt to guide the LLM on when and how to use this tool.
40
+ */
41
+ export const WEB_FETCH_DESCRIPTION = `
42
+ Fetch and extract readable content from a URL (HTML -> markdown/text). Returns the page content directly in a single call.
43
+
44
+ ## This is the DEFAULT tool for reading web pages
45
+
46
+ Use web_fetch as your FIRST choice whenever you need to read the content of a web page. It is faster and simpler than the browser tool.
47
+
48
+ ## When to Use
49
+
50
+ - Reading earnings reports, press releases, or investor relations pages
51
+ - Reading articles from news sites (CNBC, Bloomberg, Reuters, etc.)
52
+ - Accessing any URL discovered via web_search
53
+ - Reading documentation, blog posts, or any static web content
54
+ - When you need the full text content of a known URL
55
+
56
+ ## When NOT to Use
57
+
58
+ - Interactive pages that require JavaScript rendering, clicking, or form filling (use browser instead)
59
+ - Structured financial data like prices, metrics, or estimates (use financial_search instead)
60
+ - SEC filings content (use read_filings instead)
61
+ - When you need to navigate through multiple pages by clicking links (use browser instead)
62
+
63
+ ## Schema
64
+
65
+ - **url** (required): The HTTP or HTTPS URL to fetch
66
+ - **extractMode** (optional): "markdown" (default) or "text" - controls output format
67
+ - **maxChars** (optional): Maximum characters to return (default 20,000)
68
+
69
+ ## Returns
70
+
71
+ Returns the page content directly as markdown or text. No multi-step workflow needed - one call gets you the full content.
72
+
73
+ Response includes: url, finalUrl, title, text, extractMode, extractor, truncated, tookMs
74
+
75
+ ## Usage Notes
76
+
77
+ - Returns content in a single call - no need for navigate/snapshot/read steps
78
+ - Results are cached for 15 minutes - repeated fetches of the same URL are instant
79
+ - Handles redirects automatically (up to 3 hops)
80
+ - Extracts readable content using Mozilla Readability (same as Firefox Reader View)
81
+ - Falls back to raw HTML-to-markdown conversion if Readability extraction fails
82
+ - Works with HTML pages, JSON responses, and plain text
83
+ `.trim();
84
+
85
+ // ============================================================================
86
+ // Constants (identical to OpenClaw)
87
+ // ============================================================================
88
+
89
+ const DEFAULT_FETCH_MAX_CHARS = 20_000;
90
+ const DEFAULT_FETCH_MAX_REDIRECTS = 3;
91
+ const DEFAULT_ERROR_MAX_CHARS = 4_000;
92
+ const DEFAULT_FETCH_USER_AGENT =
93
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
94
+
95
+ // ============================================================================
96
+ // Cache (identical to OpenClaw)
97
+ // ============================================================================
98
+
99
+ const FETCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
100
+
101
+ // ============================================================================
102
+ // Content wrapping (identical to OpenClaw)
103
+ // ============================================================================
104
+
105
+ const WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD = wrapWebContent("", "web_fetch").length;
106
+ const WEB_FETCH_WRAPPER_NO_WARNING_OVERHEAD = wrapExternalContent("", {
107
+ source: "web_fetch",
108
+ includeWarning: false,
109
+ }).length;
110
+
111
+ function wrapWebFetchContent(
112
+ value: string,
113
+ maxChars: number,
114
+ ): {
115
+ text: string;
116
+ truncated: boolean;
117
+ rawLength: number;
118
+ wrappedLength: number;
119
+ } {
120
+ if (maxChars <= 0) {
121
+ return { text: "", truncated: true, rawLength: 0, wrappedLength: 0 };
122
+ }
123
+ const includeWarning = maxChars >= WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD;
124
+ const wrapperOverhead = includeWarning
125
+ ? WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD
126
+ : WEB_FETCH_WRAPPER_NO_WARNING_OVERHEAD;
127
+ if (wrapperOverhead > maxChars) {
128
+ const minimal = includeWarning
129
+ ? wrapWebContent("", "web_fetch")
130
+ : wrapExternalContent("", { source: "web_fetch", includeWarning: false });
131
+ const truncatedWrapper = truncateText(minimal, maxChars);
132
+ return {
133
+ text: truncatedWrapper.text,
134
+ truncated: true,
135
+ rawLength: 0,
136
+ wrappedLength: truncatedWrapper.text.length,
137
+ };
138
+ }
139
+ const maxInner = Math.max(0, maxChars - wrapperOverhead);
140
+ let truncated = truncateText(value, maxInner);
141
+ let wrappedText = includeWarning
142
+ ? wrapWebContent(truncated.text, "web_fetch")
143
+ : wrapExternalContent(truncated.text, { source: "web_fetch", includeWarning: false });
144
+
145
+ if (wrappedText.length > maxChars) {
146
+ const excess = wrappedText.length - maxChars;
147
+ const adjustedMaxInner = Math.max(0, maxInner - excess);
148
+ truncated = truncateText(value, adjustedMaxInner);
149
+ wrappedText = includeWarning
150
+ ? wrapWebContent(truncated.text, "web_fetch")
151
+ : wrapExternalContent(truncated.text, { source: "web_fetch", includeWarning: false });
152
+ }
153
+
154
+ return {
155
+ text: wrappedText,
156
+ truncated: truncated.truncated,
157
+ rawLength: truncated.text.length,
158
+ wrappedLength: wrappedText.length,
159
+ };
160
+ }
161
+
162
+ function wrapWebFetchField(value: string | undefined): string | undefined {
163
+ if (!value) {
164
+ return value;
165
+ }
166
+ return wrapExternalContent(value, { source: "web_fetch", includeWarning: false });
167
+ }
168
+
169
+ // ============================================================================
170
+ // Helpers (identical to OpenClaw)
171
+ // ============================================================================
172
+
173
+ function normalizeContentType(value: string | null | undefined): string | undefined {
174
+ if (!value) {
175
+ return undefined;
176
+ }
177
+ const [raw] = value.split(";");
178
+ const trimmed = raw?.trim();
179
+ return trimmed || undefined;
180
+ }
181
+
182
+ function looksLikeHtml(value: string): boolean {
183
+ const trimmed = value.trimStart();
184
+ if (!trimmed) {
185
+ return false;
186
+ }
187
+ const head = trimmed.slice(0, 256).toLowerCase();
188
+ return head.startsWith("<!doctype html") || head.startsWith("<html");
189
+ }
190
+
191
+ function formatWebFetchErrorDetail(params: {
192
+ detail: string;
193
+ contentType?: string | null;
194
+ maxChars: number;
195
+ }): string {
196
+ const { detail, contentType, maxChars } = params;
197
+ if (!detail) {
198
+ return "";
199
+ }
200
+ let text = detail;
201
+ const contentTypeLower = contentType?.toLowerCase();
202
+ if (contentTypeLower?.includes("text/html") || looksLikeHtml(detail)) {
203
+ const rendered = htmlToMarkdown(detail);
204
+ const withTitle = rendered.title ? `${rendered.title}\n${rendered.text}` : rendered.text;
205
+ text = markdownToText(withTitle);
206
+ }
207
+ const truncatedResult = truncateText(text.trim(), maxChars);
208
+ return truncatedResult.text;
209
+ }
210
+
211
+ function resolveMaxChars(value: unknown, fallback: number, cap: number): number {
212
+ const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
213
+ const clamped = Math.max(100, Math.floor(parsed));
214
+ return Math.min(clamped, cap);
215
+ }
216
+
217
+ // ============================================================================
218
+ // HTTP fetch with manual redirect handling (replaces OpenClaw's fetchWithSsrFGuard)
219
+ // ============================================================================
220
+
221
+ function isRedirectStatus(status: number): boolean {
222
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
223
+ }
224
+
225
+ async function fetchWithRedirects(params: {
226
+ url: string;
227
+ maxRedirects: number;
228
+ timeoutMs: number;
229
+ headers: Record<string, string>;
230
+ }): Promise<{ response: Response; finalUrl: string }> {
231
+ const signal = withTimeout(undefined, params.timeoutMs);
232
+ const visited = new Set<string>();
233
+ let currentUrl = params.url;
234
+ let redirectCount = 0;
235
+
236
+ while (true) {
237
+ let parsedUrl: URL;
238
+ try {
239
+ parsedUrl = new URL(currentUrl);
240
+ } catch {
241
+ throw new Error("[Web Fetch] Invalid URL: must be http or https");
242
+ }
243
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
244
+ throw new Error("[Web Fetch] Invalid URL: must be http or https");
245
+ }
246
+
247
+ const response = await fetch(parsedUrl.toString(), {
248
+ redirect: "manual",
249
+ headers: params.headers,
250
+ signal,
251
+ });
252
+
253
+ if (isRedirectStatus(response.status)) {
254
+ const location = response.headers.get("location");
255
+ if (!location) {
256
+ throw new Error(`[Web Fetch] Redirect missing location header (${response.status})`);
257
+ }
258
+ redirectCount += 1;
259
+ if (redirectCount > params.maxRedirects) {
260
+ throw new Error(`[Web Fetch] Too many redirects (limit: ${params.maxRedirects})`);
261
+ }
262
+ const nextUrl = new URL(location, parsedUrl).toString();
263
+ if (visited.has(nextUrl)) {
264
+ throw new Error("[Web Fetch] Redirect loop detected");
265
+ }
266
+ visited.add(nextUrl);
267
+ currentUrl = nextUrl;
268
+ continue;
269
+ }
270
+
271
+ return { response, finalUrl: currentUrl };
272
+ }
273
+ }
274
+
275
+ // ============================================================================
276
+ // Core fetch logic (ported from OpenClaw's runWebFetch, Firecrawl branches removed)
277
+ // ============================================================================
278
+
279
+ async function runWebFetch(params: {
280
+ url: string;
281
+ extractMode: ExtractMode;
282
+ maxChars: number;
283
+ maxRedirects: number;
284
+ timeoutSeconds: number;
285
+ cacheTtlMs: number;
286
+ userAgent: string;
287
+ }): Promise<Record<string, unknown>> {
288
+ const cacheKey = normalizeCacheKey(
289
+ `fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
290
+ );
291
+ const cached = readCache(FETCH_CACHE, cacheKey);
292
+ if (cached) {
293
+ return { ...cached.value, cached: true };
294
+ }
295
+
296
+ let parsedUrl: URL;
297
+ try {
298
+ parsedUrl = new URL(params.url);
299
+ } catch {
300
+ throw new Error("[Web Fetch] Invalid URL: must be http or https");
301
+ }
302
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
303
+ throw new Error("[Web Fetch] Invalid URL: must be http or https");
304
+ }
305
+
306
+ const start = Date.now();
307
+ const { response: res, finalUrl } = await fetchWithRedirects({
308
+ url: params.url,
309
+ maxRedirects: params.maxRedirects,
310
+ timeoutMs: params.timeoutSeconds * 1000,
311
+ headers: {
312
+ Accept: "*/*",
313
+ "User-Agent": params.userAgent,
314
+ "Accept-Language": "en-US,en;q=0.9",
315
+ },
316
+ });
317
+
318
+ if (!res.ok) {
319
+ const rawDetail = await readResponseText(res);
320
+ const detail = formatWebFetchErrorDetail({
321
+ detail: rawDetail,
322
+ contentType: res.headers.get("content-type"),
323
+ maxChars: DEFAULT_ERROR_MAX_CHARS,
324
+ });
325
+ const wrappedDetail = wrapWebFetchContent(detail || res.statusText, DEFAULT_ERROR_MAX_CHARS);
326
+ throw new Error(`[Web Fetch] failed (${res.status}): ${wrappedDetail.text}`);
327
+ }
328
+
329
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
330
+ const normalizedContentType = normalizeContentType(contentType) ?? "application/octet-stream";
331
+ const body = await readResponseText(res);
332
+
333
+ let title: string | undefined;
334
+ let extractor = "raw";
335
+ let text = body;
336
+ if (contentType.includes("text/html")) {
337
+ const readable = await extractReadableContent({
338
+ html: body,
339
+ url: finalUrl,
340
+ extractMode: params.extractMode,
341
+ });
342
+ if (readable?.text) {
343
+ text = readable.text;
344
+ title = readable.title;
345
+ extractor = "readability";
346
+ } else {
347
+ // Fallback to htmlToMarkdown (OpenClaw falls to Firecrawl here)
348
+ const rendered = htmlToMarkdown(body);
349
+ text = params.extractMode === "text" ? markdownToText(rendered.text) : rendered.text;
350
+ title = rendered.title;
351
+ extractor = "htmlToMarkdown";
352
+ }
353
+ } else if (contentType.includes("application/json")) {
354
+ try {
355
+ text = JSON.stringify(JSON.parse(body), null, 2);
356
+ extractor = "json";
357
+ } catch {
358
+ text = body;
359
+ extractor = "raw";
360
+ }
361
+ }
362
+
363
+ const wrapped = wrapWebFetchContent(text, params.maxChars);
364
+ const wrappedTitle = title ? wrapWebFetchField(title) : undefined;
365
+ const payload = {
366
+ url: params.url,
367
+ finalUrl,
368
+ status: res.status,
369
+ contentType: normalizedContentType,
370
+ title: wrappedTitle,
371
+ extractMode: params.extractMode,
372
+ extractor,
373
+ truncated: wrapped.truncated,
374
+ length: wrapped.wrappedLength,
375
+ rawLength: wrapped.rawLength,
376
+ wrappedLength: wrapped.wrappedLength,
377
+ fetchedAt: new Date().toISOString(),
378
+ tookMs: Date.now() - start,
379
+ text: wrapped.text,
380
+ };
381
+ writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
382
+ return payload;
383
+ }
384
+
385
+ // ============================================================================
386
+ // Tool definition (adapted for the app's LangChain + Zod framework)
387
+ // ============================================================================
388
+
389
+ export const webFetchTool = new DynamicStructuredTool({
390
+ name: 'web_fetch',
391
+ description:
392
+ 'Fetch and extract readable content from a URL (HTML → markdown/text). Use for lightweight page access without browser automation.',
393
+ schema: z.object({
394
+ url: z.string().describe('HTTP or HTTPS URL to fetch.'),
395
+ extractMode: z
396
+ .enum(['markdown', 'text'])
397
+ .optional()
398
+ .describe('Extraction mode ("markdown" or "text"). Defaults to "markdown".'),
399
+ maxChars: z
400
+ .number()
401
+ .min(100)
402
+ .optional()
403
+ .describe('Maximum characters to return (truncates when exceeded).'),
404
+ }),
405
+ func: async (input) => {
406
+ const extractMode: ExtractMode = input.extractMode === 'text' ? 'text' : 'markdown';
407
+ const maxChars = resolveMaxChars(input.maxChars, DEFAULT_FETCH_MAX_CHARS, DEFAULT_FETCH_MAX_CHARS);
408
+ const result = await runWebFetch({
409
+ url: input.url,
410
+ extractMode,
411
+ maxChars,
412
+ maxRedirects: DEFAULT_FETCH_MAX_REDIRECTS,
413
+ timeoutSeconds: resolveTimeoutSeconds(undefined, DEFAULT_TIMEOUT_SECONDS),
414
+ cacheTtlMs: resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
415
+ userAgent: DEFAULT_FETCH_USER_AGENT,
416
+ });
417
+ return formatToolResult(result, [input.url]);
418
+ },
419
+ });
@@ -0,0 +1,10 @@
1
+ // Tool registry - the primary way to access tools and their descriptions
2
+ export { getToolRegistry, getTools, buildToolDescriptions } from './registry.js';
3
+ export type { RegisteredTool } from './registry.js';
4
+
5
+ // Kalshi tools
6
+ export * from './kalshi/index.js';
7
+
8
+ // Search
9
+ export { tavilySearch } from './search/index.js';
10
+ export { WEB_SEARCH_DESCRIPTION } from './search/index.js';
@@ -0,0 +1,251 @@
1
+ import { createSign, constants } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { logger } from '../../utils/logger.js';
4
+ import { auditTrail } from '../../audit/index.js';
5
+ import { dlqWriter } from './dlq.js';
6
+ import type { KalshiMarket } from './types.js';
7
+
8
+ const PROD_BASE_URL = 'https://api.elections.kalshi.com/trade-api/v2';
9
+ const DEMO_BASE_URL = 'https://demo-api.kalshi.co/trade-api/v2';
10
+
11
+ function getBaseUrl(): string {
12
+ return process.env.KALSHI_USE_DEMO === 'true' ? DEMO_BASE_URL : PROD_BASE_URL;
13
+ }
14
+
15
+ function getPrivateKey(): string {
16
+ if (process.env.KALSHI_PRIVATE_KEY) {
17
+ return process.env.KALSHI_PRIVATE_KEY;
18
+ }
19
+ if (process.env.KALSHI_PRIVATE_KEY_FILE) {
20
+ return readFileSync(process.env.KALSHI_PRIVATE_KEY_FILE, 'utf-8');
21
+ }
22
+ throw new Error('Kalshi private key not configured. Set KALSHI_PRIVATE_KEY or KALSHI_PRIVATE_KEY_FILE.');
23
+ }
24
+
25
+ function getApiKey(): string {
26
+ const key = process.env.KALSHI_API_KEY;
27
+ if (!key) throw new Error('KALSHI_API_KEY not set');
28
+ return key;
29
+ }
30
+
31
+ function buildSignature(method: string, path: string): { timestamp: string; signature: string } {
32
+ const timestamp = Date.now().toString();
33
+ const message = timestamp + method.toUpperCase() + path;
34
+
35
+ const privateKey = getPrivateKey();
36
+ const sign = createSign('SHA256');
37
+ sign.update(message);
38
+ sign.end();
39
+
40
+ const signature = sign.sign(
41
+ {
42
+ key: privateKey,
43
+ padding: constants.RSA_PKCS1_PSS_PADDING,
44
+ saltLength: 32,
45
+ },
46
+ 'base64'
47
+ );
48
+
49
+ return { timestamp, signature };
50
+ }
51
+
52
+ // --- Error class ---
53
+
54
+ export class KalshiApiError extends Error {
55
+ constructor(
56
+ public readonly statusCode: number,
57
+ public readonly statusText: string,
58
+ public readonly body: string
59
+ ) {
60
+ super(`Kalshi API error: ${statusCode} ${statusText}${body ? ` — ${body}` : ''}`);
61
+ this.name = 'KalshiApiError';
62
+ }
63
+ }
64
+
65
+ // --- Dollar conversion utilities ---
66
+
67
+ export function toDollarString(cents: number): string {
68
+ return (cents / 100).toFixed(2);
69
+ }
70
+
71
+ export function fromDollarString(dollar: string): number {
72
+ return Math.round(parseFloat(dollar) * 100);
73
+ }
74
+
75
+ export function supportsFractional(market: KalshiMarket): boolean {
76
+ return market.supports_fractional === true || market.tick_size < 1;
77
+ }
78
+
79
+ // --- Retry logic ---
80
+
81
+ interface RetryContext {
82
+ method: string;
83
+ path: string;
84
+ body?: Record<string, unknown>;
85
+ }
86
+
87
+ const MAX_RETRIES = 5;
88
+ const BASE_DELAY_MS = 1000;
89
+ const MAX_DELAY_MS = 120_000;
90
+ const JITTER_FACTOR = 0.2;
91
+
92
+ function isRetryable(error: unknown): boolean {
93
+ if (!(error instanceof KalshiApiError)) return false;
94
+ if (error.statusCode === 429) return true;
95
+ if (error.statusCode >= 500) return true;
96
+ return false;
97
+ }
98
+
99
+ function computeDelay(attempt: number): number {
100
+ const base = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS);
101
+ const jitter = base * JITTER_FACTOR * (2 * Math.random() - 1);
102
+ return Math.max(0, base + jitter);
103
+ }
104
+
105
+ async function withRetry<T>(fn: () => Promise<T>, context: RetryContext): Promise<T> {
106
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
107
+ try {
108
+ return await fn();
109
+ } catch (error) {
110
+ if (!isRetryable(error) || attempt === MAX_RETRIES) {
111
+ if (attempt > 0 && error instanceof KalshiApiError) {
112
+ // Exhausted retries — write to DLQ
113
+ dlqWriter.append({
114
+ method: context.method,
115
+ path: context.path,
116
+ body: context.body,
117
+ error: error.message,
118
+ attempts: attempt + 1,
119
+ });
120
+ auditTrail.log({
121
+ type: 'DLQ_ENTRY',
122
+ method: context.method,
123
+ path: context.path,
124
+ error: error.message,
125
+ attempts: attempt + 1,
126
+ });
127
+ }
128
+ throw error;
129
+ }
130
+
131
+ const apiError = error as KalshiApiError;
132
+ const delay = computeDelay(attempt);
133
+
134
+ auditTrail.log({
135
+ type: 'API_RETRY',
136
+ method: context.method,
137
+ path: context.path,
138
+ attempt: attempt + 1,
139
+ max_retries: MAX_RETRIES,
140
+ status_code: apiError.statusCode,
141
+ delay_ms: Math.round(delay),
142
+ });
143
+
144
+ logger.warn(
145
+ `[Kalshi API] ${apiError.statusCode} on ${context.method} ${context.path}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})`
146
+ );
147
+ await new Promise((resolve) => setTimeout(resolve, delay));
148
+ }
149
+ }
150
+ throw new Error('Max retries exceeded');
151
+ }
152
+
153
+ // --- Public API ---
154
+
155
+ export interface KalshiApiResponse {
156
+ [key: string]: unknown;
157
+ }
158
+
159
+ export async function callKalshiApi(
160
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
161
+ path: string,
162
+ options?: {
163
+ params?: Record<string, string | number | boolean | string[] | undefined>;
164
+ body?: Record<string, unknown>;
165
+ }
166
+ ): Promise<KalshiApiResponse> {
167
+ return withRetry(
168
+ async () => {
169
+ const baseUrl = getBaseUrl();
170
+
171
+ // Build URL with query params
172
+ const url = new URL(`${baseUrl}${path}`);
173
+ if (options?.params) {
174
+ for (const [key, value] of Object.entries(options.params)) {
175
+ if (value !== undefined && value !== null) {
176
+ if (Array.isArray(value)) {
177
+ value.forEach((v) => url.searchParams.append(key, String(v)));
178
+ } else {
179
+ url.searchParams.append(key, String(value));
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // Sign the request using only the path (no query string)
186
+ // Kalshi signature covers: timestamp + method + path (without query params)
187
+ const pathWithQuery = url.pathname;
188
+ const { timestamp, signature } = buildSignature(method, pathWithQuery);
189
+
190
+ const headers: Record<string, string> = {
191
+ 'KALSHI-ACCESS-KEY': getApiKey(),
192
+ 'KALSHI-ACCESS-SIGNATURE': signature,
193
+ 'KALSHI-ACCESS-TIMESTAMP': timestamp,
194
+ 'Content-Type': 'application/json',
195
+ };
196
+
197
+ const fetchOptions: RequestInit = { method, headers };
198
+
199
+ if (options?.body && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
200
+ fetchOptions.body = JSON.stringify(options.body);
201
+ }
202
+
203
+ const response = await fetch(url.toString(), fetchOptions);
204
+
205
+ if (!response.ok) {
206
+ const text = await response.text().catch(() => '');
207
+ throw new KalshiApiError(response.status, response.statusText, text);
208
+ }
209
+
210
+ // 204 No Content — DELETE operations often return this
211
+ if (response.status === 204) {
212
+ return {};
213
+ }
214
+
215
+ return response.json();
216
+ },
217
+ { method, path, body: options?.body }
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Fetch all pages of a cursor-paginated endpoint.
223
+ */
224
+ export async function fetchAllPages<T>(
225
+ path: string,
226
+ params: Record<string, string | number | boolean | undefined>,
227
+ dataKey: string,
228
+ maxPages = 10,
229
+ onProgress?: (info: { fetchedItems: number; page: number; maxPages: number }) => void
230
+ ): Promise<T[]> {
231
+ const results: T[] = [];
232
+ let cursor: string | undefined;
233
+ let page = 0;
234
+
235
+ while (page < maxPages) {
236
+ const response = await callKalshiApi('GET', path, {
237
+ params: cursor ? { ...params, cursor } : params,
238
+ });
239
+
240
+ const data = response[dataKey] as T[] | undefined;
241
+ if (!data || data.length === 0) break;
242
+
243
+ results.push(...data);
244
+ cursor = response.cursor as string | undefined;
245
+ page++;
246
+ onProgress?.({ fetchedItems: results.length, page, maxPages });
247
+ if (!cursor) break;
248
+ }
249
+
250
+ return results;
251
+ }