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,313 @@
1
+ export interface ApiErrorInfo {
2
+ httpCode?: number;
3
+ type?: string;
4
+ code?: string;
5
+ message?: string;
6
+ requestId?: string;
7
+ }
8
+
9
+ export type ErrorType =
10
+ | 'context_overflow'
11
+ | 'rate_limit'
12
+ | 'billing'
13
+ | 'auth'
14
+ | 'timeout'
15
+ | 'overloaded'
16
+ | 'unknown';
17
+
18
+ type ErrorPattern = RegExp | string;
19
+
20
+ const ERROR_PATTERNS = {
21
+ rateLimit: [
22
+ /rate[_ ]limit|too many requests|429/i,
23
+ 'model_cooldown',
24
+ 'cooling down',
25
+ 'exceeded your current quota',
26
+ 'resource has been exhausted',
27
+ 'quota exceeded',
28
+ 'resource_exhausted',
29
+ 'usage limit',
30
+ 'tpm',
31
+ 'tokens per minute',
32
+ ],
33
+ overloaded: [
34
+ /overloaded_error|"type"\s*:\s*"overloaded_error"/i,
35
+ 'overloaded',
36
+ 'service unavailable',
37
+ 'high demand',
38
+ ],
39
+ timeout: [
40
+ 'timeout',
41
+ 'timed out',
42
+ 'deadline exceeded',
43
+ 'context deadline exceeded',
44
+ ],
45
+ billing: [
46
+ /["']?(?:status|code)["']?\s*[:=]\s*402\b/i,
47
+ /\bhttp\s*402\b/i,
48
+ 'payment required',
49
+ 'insufficient credits',
50
+ 'credit balance',
51
+ 'insufficient balance',
52
+ ],
53
+ auth: [
54
+ /invalid[_ ]?api[_ ]?key/i,
55
+ 'incorrect api key',
56
+ 'invalid token',
57
+ 'authentication',
58
+ 'unauthorized',
59
+ 'forbidden',
60
+ 'access denied',
61
+ /\b401\b/,
62
+ /\b403\b/,
63
+ 'no api key found',
64
+ ],
65
+ contextOverflow: [
66
+ 'context length exceeded',
67
+ 'maximum context length',
68
+ "this model's maximum context length",
69
+ 'prompt is too long',
70
+ 'request_too_large',
71
+ 'exceeds model context window',
72
+ 'context overflow',
73
+ 'exceed context limit',
74
+ 'model token limit',
75
+ 'your request exceeded model token limit',
76
+ ],
77
+ } as const;
78
+
79
+ const CHINESE_CONTEXT_OVERFLOW = ['上下文过长', '上下文超出', '超出最大上下文'];
80
+ const ERROR_PAYLOAD_PREFIX_RE = /^\[[\w\s]+\]\s*|^(?:error|api\s*error)[:\s-]+/i;
81
+ const HTTP_STATUS_PREFIX_RE = /^(\d{3})\s+(.+)$/s;
82
+
83
+ export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null {
84
+ if (!raw) return null;
85
+
86
+ let trimmed = raw.trim();
87
+ if (!trimmed) return null;
88
+
89
+ let httpCode: number | undefined;
90
+
91
+ trimmed = trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, '').trim();
92
+
93
+ const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
94
+ if (httpMatch) {
95
+ httpCode = Number.parseInt(httpMatch[1], 10);
96
+ trimmed = httpMatch[2].trim();
97
+ }
98
+
99
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
100
+ return null;
101
+ }
102
+
103
+ try {
104
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
105
+ const requestId = typeof parsed.request_id === 'string'
106
+ ? parsed.request_id
107
+ : typeof parsed.requestId === 'string'
108
+ ? parsed.requestId
109
+ : undefined;
110
+
111
+ if (parsed.error && typeof parsed.error === 'object' && !Array.isArray(parsed.error)) {
112
+ const err = parsed.error as Record<string, unknown>;
113
+ return {
114
+ httpCode,
115
+ type: typeof err.type === 'string' ? err.type : undefined,
116
+ code: typeof err.code === 'string' ? err.code : undefined,
117
+ message: typeof err.message === 'string' ? err.message : undefined,
118
+ requestId,
119
+ };
120
+ }
121
+
122
+ if (parsed.type === 'error' && parsed.error && typeof parsed.error === 'object' && !Array.isArray(parsed.error)) {
123
+ const err = parsed.error as Record<string, unknown>;
124
+ return {
125
+ httpCode,
126
+ type: typeof err.type === 'string' ? err.type : undefined,
127
+ message: typeof err.message === 'string' ? err.message : undefined,
128
+ requestId,
129
+ };
130
+ }
131
+
132
+ if (typeof parsed.message === 'string') {
133
+ return {
134
+ httpCode,
135
+ type: typeof parsed.type === 'string' ? parsed.type : undefined,
136
+ code: typeof parsed.code === 'string' ? parsed.code : undefined,
137
+ message: parsed.message,
138
+ requestId,
139
+ };
140
+ }
141
+
142
+ return null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function matchesPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
149
+ const lower = raw.toLowerCase();
150
+ return patterns.some((pattern) =>
151
+ pattern instanceof RegExp ? pattern.test(raw) : lower.includes(pattern)
152
+ );
153
+ }
154
+
155
+ export function isContextOverflowError(raw?: string): boolean {
156
+ if (!raw) return false;
157
+ const lower = raw.toLowerCase();
158
+
159
+ if (lower.includes('tpm') || lower.includes('tokens per minute')) {
160
+ return false;
161
+ }
162
+
163
+ if (matchesPatterns(raw, ERROR_PATTERNS.contextOverflow)) {
164
+ return true;
165
+ }
166
+
167
+ if (CHINESE_CONTEXT_OVERFLOW.some(msg => raw.includes(msg))) {
168
+ return true;
169
+ }
170
+
171
+ if (lower.includes('request size exceeds') && lower.includes('context')) {
172
+ return true;
173
+ }
174
+ if (lower.includes('max_tokens') && lower.includes('exceed') && lower.includes('context')) {
175
+ return true;
176
+ }
177
+ if (lower.includes('input length') && lower.includes('exceed') && lower.includes('context')) {
178
+ return true;
179
+ }
180
+ if (lower.includes('413') && lower.includes('too large')) {
181
+ return true;
182
+ }
183
+
184
+ return false;
185
+ }
186
+
187
+ export function isRateLimitError(raw?: string): boolean {
188
+ return raw ? matchesPatterns(raw, ERROR_PATTERNS.rateLimit) : false;
189
+ }
190
+
191
+ export function isBillingError(raw?: string): boolean {
192
+ return raw ? matchesPatterns(raw, ERROR_PATTERNS.billing) : false;
193
+ }
194
+
195
+ export function isAuthError(raw?: string): boolean {
196
+ return raw ? matchesPatterns(raw, ERROR_PATTERNS.auth) : false;
197
+ }
198
+
199
+ export function isTimeoutError(raw?: string): boolean {
200
+ return raw ? matchesPatterns(raw, ERROR_PATTERNS.timeout) : false;
201
+ }
202
+
203
+ export function isOverloadedError(raw?: string): boolean {
204
+ return raw ? matchesPatterns(raw, ERROR_PATTERNS.overloaded) : false;
205
+ }
206
+
207
+ export function classifyError(raw?: string): ErrorType {
208
+ if (!raw) return 'unknown';
209
+
210
+ if (isContextOverflowError(raw)) return 'context_overflow';
211
+ if (isRateLimitError(raw)) return 'rate_limit';
212
+ if (isBillingError(raw)) return 'billing';
213
+ if (isAuthError(raw)) return 'auth';
214
+ if (isTimeoutError(raw)) return 'timeout';
215
+ if (isOverloadedError(raw)) return 'overloaded';
216
+
217
+ return 'unknown';
218
+ }
219
+
220
+ export function isNonRetryableError(raw?: string): boolean {
221
+ const type = classifyError(raw);
222
+ return type === 'context_overflow' || type === 'billing' || type === 'auth';
223
+ }
224
+
225
+ // ─── CLI exit codes ─────────────────────────────────────────────────────────
226
+ // 0 = success
227
+ // 1 = user / validation error (bad args, unknown command, invalid price)
228
+ // 2 = auth / permission error (401, 403 from Kalshi API)
229
+ // 3 = external / server error (500, 503 from Kalshi API, network timeout)
230
+ // 4 = internal error (unhandled exception, unexpected state)
231
+
232
+ export const ExitCode = {
233
+ SUCCESS: 0,
234
+ USER_ERROR: 1,
235
+ AUTH_ERROR: 2,
236
+ EXTERNAL_ERROR: 3,
237
+ INTERNAL_ERROR: 4,
238
+ } as const;
239
+
240
+ export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
241
+
242
+ /**
243
+ * Derive an exit code from an error. Inspects KalshiApiError.statusCode
244
+ * and falls back to message-based classification.
245
+ */
246
+ export function exitCodeFromError(err: unknown): ExitCode {
247
+ // KalshiApiError (or anything with a numeric statusCode)
248
+ if (err && typeof err === 'object' && 'statusCode' in err) {
249
+ const code = (err as { statusCode: number }).statusCode;
250
+ if (code === 401 || code === 402 || code === 403) return ExitCode.AUTH_ERROR;
251
+ if (code === 429) return ExitCode.EXTERNAL_ERROR;
252
+ if (code >= 400 && code < 500) return ExitCode.USER_ERROR;
253
+ if (code >= 500) return ExitCode.EXTERNAL_ERROR;
254
+ }
255
+
256
+ // Fall back to message-based heuristics from classifyError
257
+ const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
258
+ const errorType = classifyError(msg);
259
+ switch (errorType) {
260
+ case 'auth':
261
+ case 'billing':
262
+ return ExitCode.AUTH_ERROR;
263
+ case 'rate_limit':
264
+ case 'timeout':
265
+ case 'overloaded':
266
+ case 'context_overflow':
267
+ return ExitCode.EXTERNAL_ERROR;
268
+ default:
269
+ return ExitCode.INTERNAL_ERROR;
270
+ }
271
+ }
272
+
273
+ export function formatUserFacingError(raw: string, provider?: string): string {
274
+ if (!raw.trim()) {
275
+ return 'LLM request failed with an unknown error.';
276
+ }
277
+
278
+ const errorType = classifyError(raw);
279
+ const info = parseApiErrorInfo(raw);
280
+ const providerLabel = provider ? `${provider} ` : '';
281
+
282
+ switch (errorType) {
283
+ case 'context_overflow':
284
+ return 'Context overflow: the conversation is too large for the model. ' +
285
+ 'Try starting a new conversation or use a model with a larger context window.';
286
+ case 'rate_limit':
287
+ return `${providerLabel}API rate limit reached. Please wait a moment and try again.`;
288
+ case 'billing':
289
+ return `${providerLabel}API key has run out of credits or has an insufficient balance. ` +
290
+ 'Check your billing dashboard and top up, or switch to a different API key.';
291
+ case 'auth':
292
+ return `${providerLabel}API key is invalid or expired. ` +
293
+ 'Check that your API key is correct in your environment variables.';
294
+ case 'timeout':
295
+ return 'LLM request timed out. Please try again.';
296
+ case 'overloaded':
297
+ return 'The AI service is temporarily overloaded. Please try again in a moment.';
298
+ case 'unknown':
299
+ default:
300
+ if (info?.message) {
301
+ const prefix = info.httpCode ? `HTTP ${info.httpCode}` : 'LLM error';
302
+ const type = info.type ? ` (${info.type})` : '';
303
+ const requestId = info.requestId ? ` [request_id: ${info.requestId}]` : '';
304
+ return `${prefix}${type}: ${info.message}${requestId}`;
305
+ }
306
+
307
+ if (raw.length > 300) {
308
+ return `${raw.slice(0, 300)}...`;
309
+ }
310
+
311
+ return raw;
312
+ }
313
+ }
@@ -0,0 +1,32 @@
1
+ export const HISTORY_CONTEXT_MARKER = '[Chat history for context]';
2
+ export const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
3
+ export const DEFAULT_HISTORY_LIMIT = 10;
4
+ export const FULL_ANSWER_TURNS = 3;
5
+
6
+ export interface HistoryEntry {
7
+ role: 'user' | 'assistant';
8
+ content: string;
9
+ }
10
+
11
+ export function buildHistoryContext(params: {
12
+ entries: HistoryEntry[];
13
+ currentMessage: string;
14
+ lineBreak?: string;
15
+ }): string {
16
+ const lineBreak = params.lineBreak ?? '\n';
17
+ if (params.entries.length === 0) {
18
+ return params.currentMessage;
19
+ }
20
+
21
+ const historyText = params.entries
22
+ .map(entry => `${entry.role === 'user' ? 'User' : 'Assistant'}: ${entry.content}`)
23
+ .join(`${lineBreak}${lineBreak}`);
24
+
25
+ return [
26
+ HISTORY_CONTEXT_MARKER,
27
+ historyText,
28
+ '',
29
+ CURRENT_MESSAGE_MARKER,
30
+ params.currentMessage,
31
+ ].join(lineBreak);
32
+ }
@@ -0,0 +1,268 @@
1
+ import { createHash } from 'crypto';
2
+ import { callLlm, DEFAULT_MODEL } from '../model/llm.js';
3
+ import {
4
+ DEFAULT_HISTORY_LIMIT,
5
+ FULL_ANSWER_TURNS,
6
+ type HistoryEntry,
7
+ } from './history-context.js';
8
+ import { z } from 'zod';
9
+
10
+ /**
11
+ * Represents a single conversation turn (query + answer + summary)
12
+ */
13
+ export interface Message {
14
+ id: number;
15
+ query: string;
16
+ answer: string | null; // null until answer completes
17
+ summary: string | null; // LLM-generated summary, null until answer arrives
18
+ }
19
+
20
+ /**
21
+ * Schema for LLM to select relevant messages
22
+ */
23
+ export const SelectedMessagesSchema = z.object({
24
+ message_ids: z.array(z.number()).describe('List of relevant message IDs (0-indexed)'),
25
+ });
26
+
27
+ /**
28
+ * System prompt for generating message summaries
29
+ */
30
+ const MESSAGE_SUMMARY_SYSTEM_PROMPT = `You are a concise summarizer. Generate brief summaries of conversation answers.
31
+ Keep summaries to 1-2 sentences that capture the key information.`;
32
+
33
+ /**
34
+ * System prompt for selecting relevant messages
35
+ */
36
+ const MESSAGE_SELECTION_SYSTEM_PROMPT = `You are a relevance evaluator. Select which previous conversation messages are relevant to the current query.
37
+ Return only message IDs that contain information directly useful for answering the current query.`;
38
+
39
+ /**
40
+ * Manages in-memory conversation history for multi-turn conversations.
41
+ * Stores user queries, final answers, and LLM-generated summaries.
42
+ */
43
+ export class InMemoryChatHistory {
44
+ private messages: Message[] = [];
45
+ private model: string;
46
+ private readonly maxTurns: number;
47
+ private relevantMessagesByQuery: Map<string, Message[]> = new Map();
48
+
49
+ constructor(model: string = DEFAULT_MODEL, maxTurns: number = DEFAULT_HISTORY_LIMIT) {
50
+ this.model = model;
51
+ this.maxTurns = maxTurns;
52
+ }
53
+
54
+ /**
55
+ * Hashes a query string for cache key generation
56
+ */
57
+ private hashQuery(query: string): string {
58
+ return createHash('md5').update(query).digest('hex').slice(0, 12);
59
+ }
60
+
61
+ /**
62
+ * Updates the model used for LLM calls (e.g., when user switches models)
63
+ */
64
+ setModel(model: string): void {
65
+ this.model = model;
66
+ }
67
+
68
+ /**
69
+ * Generates a brief summary of an answer for later relevance matching
70
+ */
71
+ private async generateSummary(query: string, answer: string): Promise<string> {
72
+ const answerPreview = answer.slice(0, 1500); // Limit for prompt size
73
+
74
+ const prompt = `Query: "${query}"
75
+ Answer: "${answerPreview}"
76
+
77
+ Generate a brief 1-2 sentence summary of this answer.`;
78
+
79
+ try {
80
+ const { response } = await callLlm(prompt, {
81
+ systemPrompt: MESSAGE_SUMMARY_SYSTEM_PROMPT,
82
+ model: this.model,
83
+ });
84
+ return typeof response === 'string' ? response.trim() : String(response).trim();
85
+ } catch {
86
+ // Fallback to a simple summary if LLM fails
87
+ return `Answer to: ${query.slice(0, 100)}`;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Saves a new user query to history immediately (before answer is available).
93
+ * Answer and summary are null until saveAnswer() is called with the answer.
94
+ */
95
+ saveUserQuery(query: string): void {
96
+ // Clear the relevance cache since message history has changed
97
+ this.relevantMessagesByQuery.clear();
98
+
99
+ this.messages.push({
100
+ id: this.messages.length,
101
+ query,
102
+ answer: null,
103
+ summary: null,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Saves the answer to the most recent message and generates a summary.
109
+ * Should be called when the agent completes answering.
110
+ */
111
+ async saveAnswer(answer: string): Promise<void> {
112
+ const lastMessage = this.messages[this.messages.length - 1];
113
+ if (!lastMessage || lastMessage.answer !== null) {
114
+ return; // No pending query or already has answer
115
+ }
116
+
117
+ lastMessage.answer = answer;
118
+ lastMessage.summary = await this.generateSummary(lastMessage.query, answer);
119
+ }
120
+
121
+ /**
122
+ * Uses LLM to select which messages are relevant to the current query.
123
+ * Results are cached by query hash to avoid redundant LLM calls within the same query.
124
+ * Only considers messages with completed answers for relevance selection.
125
+ */
126
+ async selectRelevantMessages(currentQuery: string): Promise<Message[]> {
127
+ // Only consider messages with completed answers
128
+ const completedMessages = this.messages.filter((m) => m.answer !== null);
129
+ if (completedMessages.length === 0) {
130
+ return [];
131
+ }
132
+
133
+ // Check cache first
134
+ const cacheKey = this.hashQuery(currentQuery);
135
+ const cached = this.relevantMessagesByQuery.get(cacheKey);
136
+ if (cached) {
137
+ return cached;
138
+ }
139
+
140
+ const messagesInfo = completedMessages.map((message) => ({
141
+ id: message.id,
142
+ query: message.query,
143
+ summary: message.summary,
144
+ }));
145
+
146
+ const prompt = `Current user query: "${currentQuery}"
147
+
148
+ Previous conversations:
149
+ ${JSON.stringify(messagesInfo, null, 2)}
150
+
151
+ Select which previous messages are relevant to understanding or answering the current query.`;
152
+
153
+ try {
154
+ const { response } = await callLlm(prompt, {
155
+ systemPrompt: MESSAGE_SELECTION_SYSTEM_PROMPT,
156
+ model: this.model,
157
+ outputSchema: SelectedMessagesSchema,
158
+ });
159
+
160
+ const selectedIds = (response as unknown as { message_ids: number[] }).message_ids || [];
161
+
162
+ const selectedMessages = selectedIds
163
+ .filter((idx) => idx >= 0 && idx < this.messages.length)
164
+ .map((idx) => this.messages[idx])
165
+ .filter((m) => m.answer !== null); // Ensure we only return completed messages
166
+
167
+ // Cache the result
168
+ this.relevantMessagesByQuery.set(cacheKey, selectedMessages);
169
+
170
+ return selectedMessages;
171
+ } catch {
172
+ // On failure, return empty (don't inject potentially irrelevant context)
173
+ return [];
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Formats selected messages for task planning (queries + summaries only, lightweight)
179
+ */
180
+ formatForPlanning(messages: Message[]): string {
181
+ if (messages.length === 0) {
182
+ return '';
183
+ }
184
+
185
+ return messages
186
+ .map((message) => `User: ${message.query}\nAssistant: ${message.summary}`)
187
+ .join('\n\n');
188
+ }
189
+
190
+ /**
191
+ * Formats selected messages for answer generation (queries + full answers)
192
+ */
193
+ formatForAnswerGeneration(messages: Message[]): string {
194
+ if (messages.length === 0) {
195
+ return '';
196
+ }
197
+
198
+ return messages
199
+ .map((message) => `User: ${message.query}\nAssistant: ${message.answer}`)
200
+ .join('\n\n');
201
+ }
202
+
203
+ /**
204
+ * Returns all messages
205
+ */
206
+ getMessages(): Message[] {
207
+ return [...this.messages];
208
+ }
209
+
210
+ /**
211
+ * Returns user queries in chronological order (no LLM call)
212
+ */
213
+ getUserMessages(): string[] {
214
+ return this.messages.map((message) => message.query);
215
+ }
216
+
217
+ /**
218
+ * Returns recent completed turns as alternating user/assistant entries.
219
+ * Uses full answers for the most recent turns and summaries for older ones.
220
+ */
221
+ getRecentTurns(limit: number = this.maxTurns): HistoryEntry[] {
222
+ const boundedLimit = Math.max(0, limit);
223
+ if (boundedLimit === 0) {
224
+ return [];
225
+ }
226
+
227
+ const completedMessages = this.messages.filter((message) => message.answer !== null);
228
+ const recentMessages = completedMessages.slice(-boundedLimit);
229
+
230
+ return recentMessages.flatMap((message, index) => {
231
+ const isRecentTurn = index >= recentMessages.length - FULL_ANSWER_TURNS;
232
+ const assistantContent = isRecentTurn
233
+ ? message.answer
234
+ : (message.summary ?? message.answer);
235
+
236
+ return [
237
+ { role: 'user', content: message.query },
238
+ { role: 'assistant', content: assistantContent ?? '' },
239
+ ];
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Returns true if there are any messages
245
+ */
246
+ hasMessages(): boolean {
247
+ return this.messages.length > 0;
248
+ }
249
+
250
+ /**
251
+ * Removes the last message from history and clears the relevance cache.
252
+ * Used to prune HEARTBEAT_OK turns that add no conversational value.
253
+ */
254
+ pruneLastTurn(): void {
255
+ if (this.messages.length > 0) {
256
+ this.messages.pop();
257
+ this.relevantMessagesByQuery.clear();
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Clears all messages and cache
263
+ */
264
+ clear(): void {
265
+ this.messages = [];
266
+ this.relevantMessagesByQuery.clear();
267
+ }
268
+ }
@@ -0,0 +1,28 @@
1
+ export { loadConfig, saveConfig, getSetting, setSetting } from './config.js';
2
+ export {
3
+ getApiKeyNameForProvider,
4
+ getProviderDisplayName,
5
+ checkApiKeyExistsForProvider,
6
+ saveApiKeyForProvider,
7
+ } from './env.js';
8
+ export { InMemoryChatHistory } from './in-memory-chat-history.js';
9
+ export { logger } from './logger.js';
10
+ export type { LogEntry, LogLevel } from './logger.js';
11
+ export { extractTextContent, hasToolCalls } from './ai-message.js';
12
+ export { LongTermChatHistory } from './long-term-chat-history.js';
13
+ export type { ConversationEntry } from './long-term-chat-history.js';
14
+ export { findPrevWordStart, findNextWordEnd } from './text-navigation.js';
15
+ export { cursorHandlers } from './input-key-handlers.js';
16
+ export type { CursorContext } from './input-key-handlers.js';
17
+ export { getToolDescription } from './tool-description.js';
18
+ export { transformMarkdownTables, formatResponse } from './markdown-table.js';
19
+ export { estimateTokens, TOKEN_BUDGET } from './tokens.js';
20
+ export {
21
+ parseApiErrorInfo,
22
+ classifyError,
23
+ isContextOverflowError,
24
+ isNonRetryableError,
25
+ formatUserFacingError,
26
+ ExitCode,
27
+ exitCodeFromError,
28
+ } from './errors.js';
@@ -0,0 +1,64 @@
1
+ import {
2
+ findPrevWordStart,
3
+ findNextWordEnd,
4
+ getLineAndColumn,
5
+ getCursorPosition,
6
+ getLineStart,
7
+ getLineEnd,
8
+ getLineCount,
9
+ } from './text-navigation.js';
10
+
11
+ /**
12
+ * Context needed for cursor position calculations
13
+ */
14
+ export interface CursorContext {
15
+ text: string;
16
+ cursorPosition: number;
17
+ }
18
+
19
+ /**
20
+ * Pure functions for computing new cursor positions.
21
+ * Each function takes the current context and returns the new cursor position.
22
+ * For vertical movement (moveUp/moveDown), returns null if at boundary to signal
23
+ * that the caller should handle it (e.g., history navigation).
24
+ */
25
+ export const cursorHandlers = {
26
+ /** Move cursor one character left */
27
+ moveLeft: (ctx: CursorContext): number =>
28
+ Math.max(0, ctx.cursorPosition - 1),
29
+
30
+ /** Move cursor one character right */
31
+ moveRight: (ctx: CursorContext): number =>
32
+ Math.min(ctx.text.length, ctx.cursorPosition + 1),
33
+
34
+ /** Move cursor to start of current line */
35
+ moveToLineStart: (ctx: CursorContext): number =>
36
+ getLineStart(ctx.text, ctx.cursorPosition),
37
+
38
+ /** Move cursor to end of current line */
39
+ moveToLineEnd: (ctx: CursorContext): number =>
40
+ getLineEnd(ctx.text, ctx.cursorPosition),
41
+
42
+ /** Move cursor up one line, maintaining column position. Returns null if on first line. */
43
+ moveUp: (ctx: CursorContext): number | null => {
44
+ const { line, column } = getLineAndColumn(ctx.text, ctx.cursorPosition);
45
+ if (line === 0) return null; // At first line, signal to caller
46
+ return getCursorPosition(ctx.text, line - 1, column);
47
+ },
48
+
49
+ /** Move cursor down one line, maintaining column position. Returns null if on last line. */
50
+ moveDown: (ctx: CursorContext): number | null => {
51
+ const { line, column } = getLineAndColumn(ctx.text, ctx.cursorPosition);
52
+ const lineCount = getLineCount(ctx.text);
53
+ if (line >= lineCount - 1) return null; // At last line, signal to caller
54
+ return getCursorPosition(ctx.text, line + 1, column);
55
+ },
56
+
57
+ /** Move cursor to start of previous word */
58
+ moveWordBackward: (ctx: CursorContext): number =>
59
+ findPrevWordStart(ctx.text, ctx.cursorPosition),
60
+
61
+ /** Move cursor to end of next word */
62
+ moveWordForward: (ctx: CursorContext): number =>
63
+ findNextWordEnd(ctx.text, ctx.cursorPosition),
64
+ };