kalshi-trading-bot-cli 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/assets/kalshi-flow-light.png +0 -0
- package/assets/screenshot.png +0 -0
- package/env.example +43 -0
- package/kalshi-flow-light.png +0 -0
- package/package.json +66 -0
- package/src/agent/agent.ts +249 -0
- package/src/agent/channels.ts +53 -0
- package/src/agent/index.ts +29 -0
- package/src/agent/prompts.ts +171 -0
- package/src/agent/run-context.ts +23 -0
- package/src/agent/scratchpad.ts +465 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/tool-executor.ts +166 -0
- package/src/agent/types.ts +221 -0
- package/src/audit/index.ts +25 -0
- package/src/audit/reader.ts +43 -0
- package/src/audit/trail.ts +29 -0
- package/src/audit/types.ts +133 -0
- package/src/backtest/discovery.ts +170 -0
- package/src/backtest/fetcher.ts +247 -0
- package/src/backtest/metrics.ts +165 -0
- package/src/backtest/renderer.ts +196 -0
- package/src/backtest/types.ts +45 -0
- package/src/cli.ts +943 -0
- package/src/commands/alerts.ts +48 -0
- package/src/commands/analyze.ts +662 -0
- package/src/commands/backtest.ts +276 -0
- package/src/commands/clear-cache.ts +24 -0
- package/src/commands/config.ts +107 -0
- package/src/commands/dispatch.ts +473 -0
- package/src/commands/edge.ts +62 -0
- package/src/commands/formatters.ts +339 -0
- package/src/commands/help.ts +263 -0
- package/src/commands/helpers.ts +48 -0
- package/src/commands/index.ts +287 -0
- package/src/commands/json.ts +43 -0
- package/src/commands/parse-args.ts +229 -0
- package/src/commands/portfolio.ts +236 -0
- package/src/commands/review.ts +176 -0
- package/src/commands/scan-formatters.ts +98 -0
- package/src/commands/scan.ts +38 -0
- package/src/commands/search-edge.ts +139 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/themes.ts +117 -0
- package/src/commands/watch.ts +295 -0
- package/src/components/answer-box.ts +57 -0
- package/src/components/approval-prompt.ts +34 -0
- package/src/components/browse-list.ts +134 -0
- package/src/components/chat-log.ts +291 -0
- package/src/components/custom-editor.ts +18 -0
- package/src/components/debug-panel.ts +52 -0
- package/src/components/index.ts +17 -0
- package/src/components/intro.ts +92 -0
- package/src/components/select-list.ts +155 -0
- package/src/components/tool-event.ts +127 -0
- package/src/components/user-query.ts +18 -0
- package/src/components/working-indicator.ts +87 -0
- package/src/controllers/agent-runner.ts +283 -0
- package/src/controllers/browse.ts +1013 -0
- package/src/controllers/index.ts +7 -0
- package/src/controllers/input-history.ts +76 -0
- package/src/controllers/model-selection.ts +244 -0
- package/src/db/alerts.ts +77 -0
- package/src/db/edge.ts +105 -0
- package/src/db/event-index.ts +323 -0
- package/src/db/events.ts +41 -0
- package/src/db/index.ts +60 -0
- package/src/db/octagon-cache.ts +118 -0
- package/src/db/positions.ts +71 -0
- package/src/db/risk.ts +51 -0
- package/src/db/schema.ts +227 -0
- package/src/db/themes.ts +34 -0
- package/src/db/trades.ts +50 -0
- package/src/eval/brier.ts +90 -0
- package/src/eval/index.ts +4 -0
- package/src/eval/performance.ts +87 -0
- package/src/gateway/access-control.ts +253 -0
- package/src/gateway/agent-runner.ts +75 -0
- package/src/gateway/alerts/formatter.ts +90 -0
- package/src/gateway/alerts/index.ts +4 -0
- package/src/gateway/alerts/router.ts +32 -0
- package/src/gateway/alerts/terminal.ts +16 -0
- package/src/gateway/alerts/types.ts +13 -0
- package/src/gateway/channels/index.ts +9 -0
- package/src/gateway/channels/manager.ts +153 -0
- package/src/gateway/channels/types.ts +48 -0
- package/src/gateway/channels/whatsapp/README.md +234 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
- package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
- package/src/gateway/channels/whatsapp/error.ts +122 -0
- package/src/gateway/channels/whatsapp/inbound.ts +326 -0
- package/src/gateway/channels/whatsapp/index.ts +5 -0
- package/src/gateway/channels/whatsapp/lid.ts +56 -0
- package/src/gateway/channels/whatsapp/logger.ts +25 -0
- package/src/gateway/channels/whatsapp/login.ts +94 -0
- package/src/gateway/channels/whatsapp/outbound.ts +119 -0
- package/src/gateway/channels/whatsapp/plugin.ts +54 -0
- package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
- package/src/gateway/channels/whatsapp/runtime.ts +122 -0
- package/src/gateway/channels/whatsapp/session.ts +89 -0
- package/src/gateway/channels/whatsapp/types.ts +32 -0
- package/src/gateway/commands/handler.ts +64 -0
- package/src/gateway/commands/index.ts +7 -0
- package/src/gateway/commands/parser.ts +29 -0
- package/src/gateway/commands/wa-formatters.ts +92 -0
- package/src/gateway/config.ts +244 -0
- package/src/gateway/extension-points.ts +17 -0
- package/src/gateway/gateway.ts +301 -0
- package/src/gateway/group/history-buffer.ts +75 -0
- package/src/gateway/group/index.ts +8 -0
- package/src/gateway/group/member-tracker.ts +60 -0
- package/src/gateway/group/mention-detection.ts +42 -0
- package/src/gateway/heartbeat/index.ts +8 -0
- package/src/gateway/heartbeat/prompt.ts +73 -0
- package/src/gateway/heartbeat/runner.ts +200 -0
- package/src/gateway/heartbeat/suppression.ts +74 -0
- package/src/gateway/index.ts +138 -0
- package/src/gateway/routing/resolve-route.ts +119 -0
- package/src/gateway/sessions/store.ts +65 -0
- package/src/gateway/types.ts +11 -0
- package/src/gateway/utils.ts +82 -0
- package/src/index.tsx +30 -0
- package/src/model/llm.ts +247 -0
- package/src/providers.ts +94 -0
- package/src/risk/circuit-breaker.ts +113 -0
- package/src/risk/correlation.ts +40 -0
- package/src/risk/gate.ts +125 -0
- package/src/risk/index.ts +10 -0
- package/src/risk/kelly.ts +230 -0
- package/src/scan/alerter.ts +64 -0
- package/src/scan/edge-computer.ts +164 -0
- package/src/scan/invoker.ts +199 -0
- package/src/scan/loop.ts +184 -0
- package/src/scan/octagon-client.ts +627 -0
- package/src/scan/octagon-events-api.ts +105 -0
- package/src/scan/octagon-prefetch.ts +172 -0
- package/src/scan/theme-resolver.ts +179 -0
- package/src/scan/types.ts +62 -0
- package/src/scan/watchdog.ts +126 -0
- package/src/setup/wizard.ts +659 -0
- package/src/theme.ts +67 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +419 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/kalshi/api.ts +251 -0
- package/src/tools/kalshi/dlq.ts +35 -0
- package/src/tools/kalshi/events.ts +84 -0
- package/src/tools/kalshi/exchange.ts +24 -0
- package/src/tools/kalshi/historical.ts +89 -0
- package/src/tools/kalshi/index.ts +11 -0
- package/src/tools/kalshi/kalshi-search.ts +437 -0
- package/src/tools/kalshi/kalshi-trade.ts +102 -0
- package/src/tools/kalshi/markets.ts +76 -0
- package/src/tools/kalshi/portfolio.ts +100 -0
- package/src/tools/kalshi/search-index.ts +198 -0
- package/src/tools/kalshi/series.ts +16 -0
- package/src/tools/kalshi/trading.ts +115 -0
- package/src/tools/kalshi/types.ts +199 -0
- package/src/tools/registry.ts +160 -0
- package/src/tools/search/index.ts +25 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/types.ts +53 -0
- package/src/tools/v2/edge-query.ts +135 -0
- package/src/tools/v2/octagon-report.ts +112 -0
- package/src/tools/v2/portfolio-query.ts +79 -0
- package/src/tools/v2/portfolio-review.ts +59 -0
- package/src/tools/v2/risk-status.ts +94 -0
- package/src/tools/v2/scan.ts +78 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/types/whiskeysockets-baileys.d.ts +41 -0
- package/src/types.ts +22 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/bot-config.ts +219 -0
- package/src/utils/cache.ts +195 -0
- package/src/utils/config.ts +113 -0
- package/src/utils/env.ts +111 -0
- package/src/utils/errors.ts +313 -0
- package/src/utils/history-context.ts +32 -0
- package/src/utils/in-memory-chat-history.ts +268 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/model.ts +70 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/paths.ts +12 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/telemetry.ts +103 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +18 -0
- package/src/utils/tokens.ts +36 -0
- package/src/utils/tool-description.ts +61 -0
|
@@ -0,0 +1,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
|
+
};
|