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,465 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { appPath } from '../utils/paths.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record of a tool call for external consumers (e.g., DoneEvent)
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolCallRecord {
|
|
10
|
+
tool: string;
|
|
11
|
+
args: Record<string, unknown>;
|
|
12
|
+
result: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ScratchpadEntry {
|
|
16
|
+
type: 'init' | 'tool_result' | 'thinking';
|
|
17
|
+
timestamp: string;
|
|
18
|
+
// For init/thinking:
|
|
19
|
+
content?: string;
|
|
20
|
+
// For tool_result:
|
|
21
|
+
toolName?: string;
|
|
22
|
+
args?: Record<string, unknown>;
|
|
23
|
+
result?: unknown; // Stored as parsed object when possible, string otherwise
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tool call limit configuration
|
|
28
|
+
*/
|
|
29
|
+
export interface ToolLimitConfig {
|
|
30
|
+
/** Max calls per tool per query (default: 3) */
|
|
31
|
+
maxCallsPerTool: number;
|
|
32
|
+
/** Query similarity threshold (0-1, default: 0.7) */
|
|
33
|
+
similarityThreshold: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Status of tool usage for graceful exit mechanism
|
|
38
|
+
*/
|
|
39
|
+
export interface ToolUsageStatus {
|
|
40
|
+
toolName: string;
|
|
41
|
+
callCount: number;
|
|
42
|
+
maxCalls: number;
|
|
43
|
+
remainingCalls: number;
|
|
44
|
+
recentQueries: string[];
|
|
45
|
+
isBlocked: boolean;
|
|
46
|
+
blockReason?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default tool limit configuration */
|
|
50
|
+
const DEFAULT_LIMIT_CONFIG: ToolLimitConfig = {
|
|
51
|
+
maxCallsPerTool: 3,
|
|
52
|
+
similarityThreshold: 0.7,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Append-only scratchpad for tracking agent work on a query.
|
|
57
|
+
* Uses JSONL format (newline-delimited JSON) for resilient appending.
|
|
58
|
+
* Files are persisted in the app scratchpad directory for debugging/history.
|
|
59
|
+
*
|
|
60
|
+
* This is the single source of truth for all agent work on a query.
|
|
61
|
+
*
|
|
62
|
+
* Includes soft limit warnings to guide the LLM:
|
|
63
|
+
* - Tool call counting with suggested limits (warnings, not blocks)
|
|
64
|
+
* - Query similarity detection to help prevent retry loops
|
|
65
|
+
*/
|
|
66
|
+
export class Scratchpad {
|
|
67
|
+
private readonly scratchpadDir = appPath('scratchpad');
|
|
68
|
+
private readonly filepath: string;
|
|
69
|
+
private readonly limitConfig: ToolLimitConfig;
|
|
70
|
+
|
|
71
|
+
// In-memory tracking for tool limits (also persisted in JSONL)
|
|
72
|
+
private toolCallCounts: Map<string, number> = new Map();
|
|
73
|
+
private toolQueries: Map<string, string[]> = new Map();
|
|
74
|
+
|
|
75
|
+
// In-memory tracking for Anthropic-style context clearing (JSONL file untouched)
|
|
76
|
+
// Stores indices of tool_result entries that have been cleared from context
|
|
77
|
+
private clearedToolIndices: Set<number> = new Set();
|
|
78
|
+
|
|
79
|
+
constructor(query: string, limitConfig?: Partial<ToolLimitConfig>) {
|
|
80
|
+
this.limitConfig = { ...DEFAULT_LIMIT_CONFIG, ...limitConfig };
|
|
81
|
+
|
|
82
|
+
if (!existsSync(this.scratchpadDir)) {
|
|
83
|
+
mkdirSync(this.scratchpadDir, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hash = createHash('md5').update(query).digest('hex').slice(0, 12);
|
|
87
|
+
const now = new Date();
|
|
88
|
+
const timestamp = now.toISOString()
|
|
89
|
+
.slice(0, 19) // "2026-01-21T15:30:45"
|
|
90
|
+
.replace('T', '-') // "2026-01-21-15:30:45"
|
|
91
|
+
.replace(/:/g, ''); // "2026-01-21-153045"
|
|
92
|
+
this.filepath = join(this.scratchpadDir, `${timestamp}_${hash}.jsonl`);
|
|
93
|
+
|
|
94
|
+
// Write initial entry with the query
|
|
95
|
+
this.append({ type: 'init', content: query, timestamp: new Date().toISOString() });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add a complete tool result with full data.
|
|
100
|
+
* Parses JSON strings to store as objects for cleaner JSONL output.
|
|
101
|
+
* Anthropic-style: no inline summarization, full results preserved.
|
|
102
|
+
*/
|
|
103
|
+
addToolResult(
|
|
104
|
+
toolName: string,
|
|
105
|
+
args: Record<string, unknown>,
|
|
106
|
+
result: string
|
|
107
|
+
): void {
|
|
108
|
+
this.append({
|
|
109
|
+
type: 'tool_result',
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
toolName,
|
|
112
|
+
args,
|
|
113
|
+
result: this.parseResultSafely(result),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Tool Limit / Graceful Exit Methods
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a tool call can proceed. Returns status with warning if limits exceeded.
|
|
123
|
+
* Call this BEFORE executing a tool to help prevent retry loops.
|
|
124
|
+
* Note: Always allows the call but provides warnings to guide the LLM.
|
|
125
|
+
*/
|
|
126
|
+
canCallTool(toolName: string, query?: string): { allowed: boolean; warning?: string } {
|
|
127
|
+
const currentCount = this.toolCallCounts.get(toolName) ?? 0;
|
|
128
|
+
const maxCalls = this.limitConfig.maxCallsPerTool;
|
|
129
|
+
|
|
130
|
+
// Check if over the suggested limit - warn but allow
|
|
131
|
+
if (currentCount >= maxCalls) {
|
|
132
|
+
return {
|
|
133
|
+
allowed: true,
|
|
134
|
+
warning: `Tool '${toolName}' has been called ${currentCount} times (suggested limit: ${maxCalls}). ` +
|
|
135
|
+
`If previous calls didn't return the needed data, consider: ` +
|
|
136
|
+
`(1) trying a different tool, (2) using different search terms, or ` +
|
|
137
|
+
`(3) proceeding with what you have and noting any data gaps to the user.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check query similarity if query provided
|
|
142
|
+
if (query) {
|
|
143
|
+
const previousQueries = this.toolQueries.get(toolName) ?? [];
|
|
144
|
+
const similarQuery = this.findSimilarQuery(query, previousQueries);
|
|
145
|
+
|
|
146
|
+
if (similarQuery) {
|
|
147
|
+
// Allow but warn - the LLM should know it's repeating
|
|
148
|
+
const remaining = maxCalls - currentCount;
|
|
149
|
+
return {
|
|
150
|
+
allowed: true,
|
|
151
|
+
warning: `This query is very similar to a previous '${toolName}' call. ` +
|
|
152
|
+
`You have ${remaining} attempt(s) before reaching the suggested limit. ` +
|
|
153
|
+
`If the tool isn't returning useful results, consider: ` +
|
|
154
|
+
`(1) trying a different tool, (2) using different search terms, or ` +
|
|
155
|
+
`(3) acknowledging the data limitation to the user.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check if approaching limit (1 call remaining)
|
|
161
|
+
if (currentCount === maxCalls - 1) {
|
|
162
|
+
return {
|
|
163
|
+
allowed: true,
|
|
164
|
+
warning: `You are approaching the suggested limit for '${toolName}' (${currentCount + 1}/${maxCalls}). ` +
|
|
165
|
+
`If this doesn't return the needed data, consider trying a different approach.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { allowed: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Record a tool call attempt. Call this AFTER the tool executes successfully.
|
|
174
|
+
*/
|
|
175
|
+
recordToolCall(toolName: string, query?: string): void {
|
|
176
|
+
// Update call count
|
|
177
|
+
const currentCount = this.toolCallCounts.get(toolName) ?? 0;
|
|
178
|
+
this.toolCallCounts.set(toolName, currentCount + 1);
|
|
179
|
+
|
|
180
|
+
// Track query if provided
|
|
181
|
+
if (query) {
|
|
182
|
+
const queries = this.toolQueries.get(toolName) ?? [];
|
|
183
|
+
queries.push(query);
|
|
184
|
+
this.toolQueries.set(toolName, queries);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get usage status for all tools that have been called.
|
|
190
|
+
* Used to inject tool attempt status into prompts.
|
|
191
|
+
*/
|
|
192
|
+
getToolUsageStatus(): ToolUsageStatus[] {
|
|
193
|
+
const statuses: ToolUsageStatus[] = [];
|
|
194
|
+
|
|
195
|
+
for (const [toolName, callCount] of this.toolCallCounts) {
|
|
196
|
+
const maxCalls = this.limitConfig.maxCallsPerTool;
|
|
197
|
+
const remainingCalls = Math.max(0, maxCalls - callCount);
|
|
198
|
+
const recentQueries = this.toolQueries.get(toolName) ?? [];
|
|
199
|
+
const overLimit = callCount >= maxCalls;
|
|
200
|
+
|
|
201
|
+
statuses.push({
|
|
202
|
+
toolName,
|
|
203
|
+
callCount,
|
|
204
|
+
maxCalls,
|
|
205
|
+
remainingCalls,
|
|
206
|
+
recentQueries: recentQueries.slice(-3), // Last 3 queries
|
|
207
|
+
isBlocked: false, // Never block, just warn
|
|
208
|
+
blockReason: overLimit ? `Over suggested limit of ${maxCalls} calls` : undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return statuses;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Format tool usage status for injection into prompts.
|
|
217
|
+
*/
|
|
218
|
+
formatToolUsageForPrompt(): string | null {
|
|
219
|
+
const statuses = this.getToolUsageStatus();
|
|
220
|
+
|
|
221
|
+
if (statuses.length === 0) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lines = statuses.map(s => {
|
|
226
|
+
const status = s.callCount >= s.maxCalls
|
|
227
|
+
? `${s.callCount} calls (over suggested limit of ${s.maxCalls})`
|
|
228
|
+
: `${s.callCount}/${s.maxCalls} calls`;
|
|
229
|
+
return `- ${s.toolName}: ${status}`;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return `## Tool Usage This Query\n\n${lines.join('\n')}\n\n` +
|
|
233
|
+
`Note: If a tool isn't returning useful results after several attempts, consider trying a different tool/approach.`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check if a query is too similar to previous queries.
|
|
238
|
+
* Uses word overlap similarity (Jaccard-like).
|
|
239
|
+
*/
|
|
240
|
+
private findSimilarQuery(newQuery: string, previousQueries: string[]): string | null {
|
|
241
|
+
const newWords = this.tokenize(newQuery);
|
|
242
|
+
|
|
243
|
+
for (const prevQuery of previousQueries) {
|
|
244
|
+
const prevWords = this.tokenize(prevQuery);
|
|
245
|
+
const similarity = this.calculateSimilarity(newWords, prevWords);
|
|
246
|
+
|
|
247
|
+
if (similarity >= this.limitConfig.similarityThreshold) {
|
|
248
|
+
return prevQuery;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Tokenize a query into normalized words for similarity comparison.
|
|
257
|
+
*/
|
|
258
|
+
private tokenize(query: string): Set<string> {
|
|
259
|
+
return new Set(
|
|
260
|
+
query
|
|
261
|
+
.toLowerCase()
|
|
262
|
+
.replace(/[^\w\s]/g, ' ')
|
|
263
|
+
.split(/\s+/)
|
|
264
|
+
.filter(w => w.length > 2) // Skip very short words
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Calculate word overlap similarity between two word sets.
|
|
270
|
+
*/
|
|
271
|
+
private calculateSimilarity(set1: Set<string>, set2: Set<string>): number {
|
|
272
|
+
if (set1.size === 0 || set2.size === 0) return 0;
|
|
273
|
+
|
|
274
|
+
const intersection = [...set1].filter(w => set2.has(w)).length;
|
|
275
|
+
const union = new Set([...set1, ...set2]).size;
|
|
276
|
+
|
|
277
|
+
return intersection / union; // Jaccard similarity
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Safely parse a result string as JSON if possible.
|
|
282
|
+
* Returns the parsed object if valid JSON, otherwise returns the original string.
|
|
283
|
+
*/
|
|
284
|
+
private parseResultSafely(result: string): unknown {
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(result);
|
|
287
|
+
} catch {
|
|
288
|
+
// Not valid JSON, return as-is (e.g., error messages, plain text)
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Append thinking/reasoning
|
|
295
|
+
*/
|
|
296
|
+
addThinking(thought: string): void {
|
|
297
|
+
this.append({ type: 'thinking', content: thought, timestamp: new Date().toISOString() });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get full tool results formatted for the iteration prompt.
|
|
302
|
+
* Anthropic-style: full results in context, excluding cleared entries.
|
|
303
|
+
* Does NOT modify the JSONL file - clearing is in-memory only.
|
|
304
|
+
*/
|
|
305
|
+
getToolResults(): string {
|
|
306
|
+
const entries = this.readEntries();
|
|
307
|
+
let toolResultIndex = 0;
|
|
308
|
+
|
|
309
|
+
const formattedResults: string[] = [];
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (entry.type !== 'tool_result' || !entry.toolName) continue;
|
|
312
|
+
|
|
313
|
+
// Skip entries that have been cleared from context (in-memory only)
|
|
314
|
+
if (this.clearedToolIndices.has(toolResultIndex)) {
|
|
315
|
+
formattedResults.push(`[Tool result #${toolResultIndex + 1} cleared from context]`);
|
|
316
|
+
toolResultIndex++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const argsStr = entry.args
|
|
321
|
+
? Object.entries(entry.args).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
322
|
+
: '';
|
|
323
|
+
const resultStr = this.stringifyResult(entry.result);
|
|
324
|
+
formattedResults.push(`### ${entry.toolName}(${argsStr})\n${resultStr}`);
|
|
325
|
+
toolResultIndex++;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return formattedResults.join('\n\n');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Clear oldest tool results from context (in-memory only).
|
|
333
|
+
* Anthropic-style: removes oldest tool results, keeping most recent N.
|
|
334
|
+
* The JSONL file is NOT modified - this only affects what gets sent to the LLM.
|
|
335
|
+
*
|
|
336
|
+
* @param keepCount - Number of most recent tool results to keep
|
|
337
|
+
* @returns Number of tool results that were cleared
|
|
338
|
+
*/
|
|
339
|
+
clearOldestToolResults(keepCount: number): number {
|
|
340
|
+
const entries = this.readEntries();
|
|
341
|
+
const toolResultIndices: number[] = [];
|
|
342
|
+
|
|
343
|
+
let index = 0;
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
if (entry.type === 'tool_result') {
|
|
346
|
+
// Only consider entries not already cleared
|
|
347
|
+
if (!this.clearedToolIndices.has(index)) {
|
|
348
|
+
toolResultIndices.push(index);
|
|
349
|
+
}
|
|
350
|
+
index++;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Calculate how many to clear
|
|
355
|
+
const toClearCount = Math.max(0, toolResultIndices.length - keepCount);
|
|
356
|
+
|
|
357
|
+
if (toClearCount === 0) return 0;
|
|
358
|
+
|
|
359
|
+
// Clear oldest entries (first N indices)
|
|
360
|
+
for (let i = 0; i < toClearCount; i++) {
|
|
361
|
+
this.clearedToolIndices.add(toolResultIndices[i]);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return toClearCount;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get count of active (non-cleared) tool results.
|
|
369
|
+
*/
|
|
370
|
+
getActiveToolResultCount(): number {
|
|
371
|
+
const entries = this.readEntries();
|
|
372
|
+
let count = 0;
|
|
373
|
+
let index = 0;
|
|
374
|
+
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
if (entry.type === 'tool_result') {
|
|
377
|
+
if (!this.clearedToolIndices.has(index)) {
|
|
378
|
+
count++;
|
|
379
|
+
}
|
|
380
|
+
index++;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return count;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get tool call records for DoneEvent (external consumers)
|
|
389
|
+
*/
|
|
390
|
+
getToolCallRecords(): ToolCallRecord[] {
|
|
391
|
+
return this.readEntries()
|
|
392
|
+
.filter(e => e.type === 'tool_result' && e.toolName)
|
|
393
|
+
.map(e => ({
|
|
394
|
+
tool: e.toolName!,
|
|
395
|
+
args: e.args!,
|
|
396
|
+
result: this.stringifyResult(e.result),
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Convert a result back to string for API compatibility.
|
|
402
|
+
* If already a string, returns as-is. Otherwise JSON stringifies.
|
|
403
|
+
*/
|
|
404
|
+
private stringifyResult(result: unknown): string {
|
|
405
|
+
if (typeof result === 'string') {
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
return JSON.stringify(result);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Check if any tool results have been recorded
|
|
413
|
+
*/
|
|
414
|
+
hasToolResults(): boolean {
|
|
415
|
+
return this.readEntries().some(e => e.type === 'tool_result');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Check if a skill has already been executed in this query.
|
|
420
|
+
* Used for deduplication - each skill should only run once per query.
|
|
421
|
+
*/
|
|
422
|
+
hasExecutedSkill(skillName: string): boolean {
|
|
423
|
+
return this.readEntries().some(
|
|
424
|
+
e => e.type === 'tool_result' && e.toolName === 'skill' && e.args?.skill === skillName
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Append-only write
|
|
430
|
+
*/
|
|
431
|
+
private append(entry: ScratchpadEntry): void {
|
|
432
|
+
appendFileSync(this.filepath, JSON.stringify(entry) + '\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Parse and validate a single JSONL line. Returns null for malformed or invalid entries.
|
|
437
|
+
*/
|
|
438
|
+
private parseLine(line: string): ScratchpadEntry | null {
|
|
439
|
+
try {
|
|
440
|
+
const parsed = JSON.parse(line);
|
|
441
|
+
return parsed && typeof parsed === 'object' && 'type' in parsed && 'timestamp' in parsed
|
|
442
|
+
? (parsed as ScratchpadEntry)
|
|
443
|
+
: null;
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Read all entries from the log.
|
|
451
|
+
* Skips malformed or corrupt lines (partial writes, disk corruption) to avoid
|
|
452
|
+
* a single bad line crashing tool-context methods.
|
|
453
|
+
*/
|
|
454
|
+
private readEntries(): ScratchpadEntry[] {
|
|
455
|
+
if (!existsSync(this.filepath)) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return readFileSync(this.filepath, 'utf-8')
|
|
460
|
+
.split('\n')
|
|
461
|
+
.filter((line) => line.trim())
|
|
462
|
+
.map((line) => this.parseLine(line))
|
|
463
|
+
.filter((entry): entry is ScratchpadEntry => entry !== null);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { TokenUsage } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks token usage across multiple LLM calls.
|
|
5
|
+
*/
|
|
6
|
+
export class TokenCounter {
|
|
7
|
+
private usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Add usage from an LLM call to the running total.
|
|
11
|
+
*/
|
|
12
|
+
add(usage?: TokenUsage): void {
|
|
13
|
+
if (!usage) return;
|
|
14
|
+
this.usage.inputTokens += usage.inputTokens;
|
|
15
|
+
this.usage.outputTokens += usage.outputTokens;
|
|
16
|
+
this.usage.totalTokens += usage.totalTokens;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the accumulated token usage, or undefined if no tokens were tracked.
|
|
21
|
+
*/
|
|
22
|
+
getUsage(): TokenUsage | undefined {
|
|
23
|
+
return this.usage.totalTokens > 0 ? { ...this.usage } : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculate tokens per second given elapsed time in milliseconds.
|
|
28
|
+
*/
|
|
29
|
+
getTokensPerSecond(elapsedMs: number): number | undefined {
|
|
30
|
+
if (this.usage.totalTokens === 0 || elapsedMs <= 0) return undefined;
|
|
31
|
+
return this.usage.totalTokens / (elapsedMs / 1000);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { AIMessage } from '@langchain/core/messages';
|
|
2
|
+
import { StructuredToolInterface } from '@langchain/core/tools';
|
|
3
|
+
import { createProgressChannel } from '../utils/progress-channel.js';
|
|
4
|
+
import type {
|
|
5
|
+
ApprovalDecision,
|
|
6
|
+
ToolApprovalEvent,
|
|
7
|
+
ToolDeniedEvent,
|
|
8
|
+
ToolEndEvent,
|
|
9
|
+
ToolErrorEvent,
|
|
10
|
+
ToolLimitEvent,
|
|
11
|
+
ToolProgressEvent,
|
|
12
|
+
ToolStartEvent,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
import type { RunContext } from './run-context.js';
|
|
15
|
+
import { trackEvent } from '../utils/telemetry.js';
|
|
16
|
+
|
|
17
|
+
type ToolExecutionEvent =
|
|
18
|
+
| ToolStartEvent
|
|
19
|
+
| ToolProgressEvent
|
|
20
|
+
| ToolEndEvent
|
|
21
|
+
| ToolErrorEvent
|
|
22
|
+
| ToolApprovalEvent
|
|
23
|
+
| ToolDeniedEvent
|
|
24
|
+
| ToolLimitEvent;
|
|
25
|
+
|
|
26
|
+
const TOOLS_REQUIRING_APPROVAL = ['kalshi_trade'] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Executes tool calls and emits streaming tool lifecycle events.
|
|
30
|
+
*/
|
|
31
|
+
export class AgentToolExecutor {
|
|
32
|
+
private readonly sessionApprovedTools: Set<string>;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly toolMap: Map<string, StructuredToolInterface>,
|
|
36
|
+
private readonly signal?: AbortSignal,
|
|
37
|
+
private readonly requestToolApproval?: (request: {
|
|
38
|
+
tool: string;
|
|
39
|
+
args: Record<string, unknown>;
|
|
40
|
+
}) => Promise<ApprovalDecision>,
|
|
41
|
+
sessionApprovedTools?: Set<string>,
|
|
42
|
+
) {
|
|
43
|
+
this.sessionApprovedTools = sessionApprovedTools ?? new Set();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async *executeAll(
|
|
47
|
+
response: AIMessage,
|
|
48
|
+
ctx: RunContext
|
|
49
|
+
): AsyncGenerator<ToolExecutionEvent, void> {
|
|
50
|
+
for (const toolCall of response.tool_calls!) {
|
|
51
|
+
const toolName = toolCall.name;
|
|
52
|
+
const toolArgs = toolCall.args as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
yield* this.executeSingle(toolName, toolArgs, ctx);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async *executeSingle(
|
|
59
|
+
toolName: string,
|
|
60
|
+
toolArgs: Record<string, unknown>,
|
|
61
|
+
ctx: RunContext
|
|
62
|
+
): AsyncGenerator<ToolExecutionEvent, void> {
|
|
63
|
+
const toolQuery = this.extractQueryFromArgs(toolArgs);
|
|
64
|
+
|
|
65
|
+
if (this.requiresApproval(toolName) && !this.sessionApprovedTools.has(toolName)) {
|
|
66
|
+
const decision = (await this.requestToolApproval?.({ tool: toolName, args: toolArgs })) ?? 'deny';
|
|
67
|
+
trackEvent('tool_approval', { tool: toolName, decision: String(decision) });
|
|
68
|
+
yield { type: 'tool_approval', tool: toolName, args: toolArgs, approved: decision };
|
|
69
|
+
if (decision === 'deny') {
|
|
70
|
+
yield { type: 'tool_denied', tool: toolName, args: toolArgs };
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (decision === 'allow-session') {
|
|
74
|
+
for (const name of TOOLS_REQUIRING_APPROVAL) {
|
|
75
|
+
this.sessionApprovedTools.add(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const limitCheck = ctx.scratchpad.canCallTool(toolName, toolQuery);
|
|
81
|
+
|
|
82
|
+
if (limitCheck.warning) {
|
|
83
|
+
yield {
|
|
84
|
+
type: 'tool_limit',
|
|
85
|
+
tool: toolName,
|
|
86
|
+
warning: limitCheck.warning,
|
|
87
|
+
blocked: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
yield { type: 'tool_start', tool: toolName, args: toolArgs };
|
|
92
|
+
|
|
93
|
+
const toolStartTime = Date.now();
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const tool = this.toolMap.get(toolName);
|
|
97
|
+
if (!tool) {
|
|
98
|
+
throw new Error(`Tool '${toolName}' not found`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Create a progress channel so subagent tools can stream status updates
|
|
102
|
+
const channel = createProgressChannel();
|
|
103
|
+
const config = {
|
|
104
|
+
metadata: { onProgress: channel.emit },
|
|
105
|
+
...(this.signal ? { signal: this.signal } : {}),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Launch tool invocation -- closes the channel when it settles
|
|
109
|
+
const toolPromise = tool.invoke(toolArgs, config).then(
|
|
110
|
+
(raw) => {
|
|
111
|
+
channel.close();
|
|
112
|
+
return raw;
|
|
113
|
+
},
|
|
114
|
+
(err) => {
|
|
115
|
+
channel.close();
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Drain progress events in real-time as the tool executes
|
|
121
|
+
for await (const message of channel) {
|
|
122
|
+
yield { type: 'tool_progress', tool: toolName, message } as ToolProgressEvent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Tool has finished -- collect the result
|
|
126
|
+
const rawResult = await toolPromise;
|
|
127
|
+
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
|
|
128
|
+
const duration = Date.now() - toolStartTime;
|
|
129
|
+
|
|
130
|
+
yield { type: 'tool_end', tool: toolName, args: toolArgs, result, duration };
|
|
131
|
+
trackEvent('tool_execution', { tool: toolName, duration_ms: duration, success: 'true' });
|
|
132
|
+
|
|
133
|
+
// Record the tool call for limit tracking
|
|
134
|
+
ctx.scratchpad.recordToolCall(toolName, toolQuery);
|
|
135
|
+
|
|
136
|
+
// Add full tool result to scratchpad (Anthropic-style: no inline summarization)
|
|
137
|
+
ctx.scratchpad.addToolResult(toolName, toolArgs, result);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
140
|
+
yield { type: 'tool_error', tool: toolName, error: errorMessage };
|
|
141
|
+
trackEvent('tool_execution', { tool: toolName, duration_ms: Date.now() - toolStartTime, success: 'false' });
|
|
142
|
+
|
|
143
|
+
// Still record the call even on error (counts toward limit)
|
|
144
|
+
ctx.scratchpad.recordToolCall(toolName, toolQuery);
|
|
145
|
+
|
|
146
|
+
// Add error to scratchpad
|
|
147
|
+
ctx.scratchpad.addToolResult(toolName, toolArgs, `Error: ${errorMessage}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private extractQueryFromArgs(args: Record<string, unknown>): string | undefined {
|
|
152
|
+
const queryKeys = ['query', 'search', 'question', 'q', 'text', 'input'];
|
|
153
|
+
|
|
154
|
+
for (const key of queryKeys) {
|
|
155
|
+
if (typeof args[key] === 'string') {
|
|
156
|
+
return args[key] as string;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private requiresApproval(toolName: string): boolean {
|
|
164
|
+
return (TOOLS_REQUIRING_APPROVAL as readonly string[]).includes(toolName);
|
|
165
|
+
}
|
|
166
|
+
}
|