flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,662 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { config } from "../config.js";
6
+
7
+ import { processToolDefs, createProcessHandlers, activeChildren, killAllChildren } from "./process-tools.js";
8
+ import { agentToolDefs, createAgentHandlers } from "./agent-tools.js";
9
+
10
+ // Re-export for backward compatibility (used by index.js)
11
+ export { killAllChildren };
12
+
13
+ // HTML → Markdown converter (zero dependencies)
14
+ function _htmlToMarkdown(html) {
15
+ let s = html;
16
+ // Remove script, style, noscript, svg, head
17
+ s = s.replace(/<(script|style|noscript|svg|head)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
18
+ // Remove HTML comments
19
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
20
+ // Headings
21
+ s = s.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
22
+ s = s.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
23
+ s = s.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
24
+ s = s.replace(/<h[4-6][^>]*>([\s\S]*?)<\/h[4-6]>/gi, "\n#### $1\n");
25
+ // Links
26
+ s = s.replace(/<a[^>]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)");
27
+ // Images
28
+ s = s.replace(/<img[^>]+alt="([^"]*)"[^>]+src="([^"]*)"[^>]*\/?>/gi, "![$1]($2)");
29
+ s = s.replace(/<img[^>]+src="([^"]*)"[^>]*\/?>/gi, "![]($1)");
30
+ // Bold, italic
31
+ s = s.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**");
32
+ s = s.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*");
33
+ // Code
34
+ s = s.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
35
+ s = s.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
36
+ // Lists
37
+ s = s.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n");
38
+ s = s.replace(/<\/?[uo]l[^>]*>/gi, "\n");
39
+ // Paragraphs, divs, br
40
+ s = s.replace(/<br\s*\/?>/gi, "\n");
41
+ s = s.replace(/<\/p>/gi, "\n\n");
42
+ s = s.replace(/<\/div>/gi, "\n");
43
+ // Table rows → pipe-separated
44
+ s = s.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, (_, row) => {
45
+ const cells = [];
46
+ row.replace(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi, (__, cell) => cells.push(cell.trim()));
47
+ return cells.length ? "| " + cells.join(" | ") + " |\n" : "";
48
+ });
49
+ // Strip remaining tags
50
+ s = s.replace(/<[^>]+>/g, "");
51
+ // Decode entities
52
+ s = s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
53
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
54
+ // Collapse whitespace
55
+ s = s.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+/g, " ");
56
+ return s.trim();
57
+ }
58
+
59
+ // Factory: takes store so tools can set pendingAction instead of global flags
60
+ export function createSystemTools(store) {
61
+ // Load curated model prefixes from config
62
+ const CURATED_PREFIXES = (() => {
63
+ try {
64
+ const __dir = dirname(fileURLToPath(import.meta.url));
65
+ const cfgPath = join(__dir, "../../config/models-curated.json");
66
+ // homedir(), not process.env.HOME: HOME is unset on Windows.
67
+ const userPath = join(homedir(), ".flint", "models-curated.json");
68
+ for (const p of [userPath, cfgPath]) {
69
+ if (existsSync(p)) {
70
+ const data = JSON.parse(readFileSync(p, "utf-8"));
71
+ return data[config.provider] || data.openrouter || [];
72
+ }
73
+ }
74
+ } catch {}
75
+ return [];
76
+ })();
77
+
78
+ const systemToolDefs = [
79
+ {
80
+ type: "function",
81
+ function: {
82
+ name: "web_fetch",
83
+ description: "Fetch a URL and return its text content. Supports GET (default) and POST with JSON body. Useful for checking APIs, downloading configs, reading documentation.",
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ url: { type: "string", description: "URL to fetch" },
88
+ method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], description: "HTTP method (default: GET)" },
89
+ body: { type: "string", description: "Request body (JSON string). Used with POST/PUT/PATCH." },
90
+ headers: { type: "object", description: "Extra headers as key-value pairs" },
91
+ max_length: { type: "number", description: "Max response length in chars (default: 5000)" },
92
+ },
93
+ required: ["url"],
94
+ },
95
+ },
96
+ },
97
+ {
98
+ type: "function",
99
+ function: {
100
+ name: "web_search",
101
+ description: "Search the web using Google (with DuckDuckGo fallback). Returns titles, URLs and snippets for top results.",
102
+ parameters: {
103
+ type: "object",
104
+ properties: {
105
+ query: { type: "string", description: "Search query" },
106
+ num_results: { type: "number", description: "Max results to return (default: 5, max: 10)" },
107
+ },
108
+ required: ["query"],
109
+ },
110
+ },
111
+ },
112
+ {
113
+ type: "function",
114
+ function: {
115
+ name: "restart_agent",
116
+ description: "Restart the agent process. Session is saved and restored after restart.",
117
+ parameters: {
118
+ type: "object",
119
+ properties: {
120
+ reason: { type: "string", description: "Why the restart is needed" },
121
+ },
122
+ required: ["reason"],
123
+ },
124
+ },
125
+ },
126
+ {
127
+ type: "function",
128
+ function: {
129
+ name: "clear_context",
130
+ description: "Clear the conversation history and start fresh (keeps session).",
131
+ parameters: { type: "object", properties: {} },
132
+ },
133
+ },
134
+ {
135
+ type: "function",
136
+ function: {
137
+ name: "check_balance",
138
+ description: "Check the OpenRouter API credit balance and usage.",
139
+ parameters: { type: "object", properties: {} },
140
+ },
141
+ },
142
+ {
143
+ type: "function",
144
+ function: {
145
+ name: "think",
146
+ description: "Think step-by-step before answering. Internal monologue, not shown to user.",
147
+ parameters: {
148
+ type: "object",
149
+ properties: {
150
+ thought: { type: "string", description: "Your reasoning" },
151
+ },
152
+ required: ["thought"],
153
+ },
154
+ },
155
+ },
156
+ {
157
+ type: "function",
158
+ function: {
159
+ name: "list_models",
160
+ description: "Show available AI models with pricing for the current provider. Use query to search by name (e.g. 'nvidia', 'nemotron', 'free').",
161
+ parameters: {
162
+ type: "object",
163
+ properties: {
164
+ query: { type: "string", description: "Optional search filter — matches model ID or name (case-insensitive)" },
165
+ },
166
+ },
167
+ },
168
+ },
169
+ {
170
+ type: "function",
171
+ function: {
172
+ name: "switch_model",
173
+ description: "Switch to a different AI model. Optionally switch provider at the same time.",
174
+ parameters: {
175
+ type: "object",
176
+ properties: {
177
+ model_id: { type: "string", description: "The model ID to switch to" },
178
+ provider: { type: "string", description: "Optional: switch provider first (openrouter, openai, anthropic, groq, together, ollama)" },
179
+ },
180
+ required: ["model_id"],
181
+ },
182
+ },
183
+ },
184
+ {
185
+ type: "function",
186
+ function: {
187
+ name: "switch_provider",
188
+ description: "Switch to a different LLM provider. Available: openrouter, openai, anthropic, groq, together, ollama.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ provider_id: { type: "string", description: "Provider ID to switch to" },
193
+ },
194
+ required: ["provider_id"],
195
+ },
196
+ },
197
+ },
198
+ {
199
+ type: "function",
200
+ function: {
201
+ name: "list_providers",
202
+ description: "List all available LLM providers and which ones have API keys configured.",
203
+ parameters: { type: "object", properties: {} },
204
+ },
205
+ },
206
+ {
207
+ type: "function",
208
+ function: {
209
+ name: "list_mcp_servers",
210
+ description: "List configured MCP servers and their connection status. Use this to check which external tools are reachable and which have dropped (e.g. when a tool returns 'fetch failed').",
211
+ parameters: { type: "object", properties: {} },
212
+ },
213
+ },
214
+ {
215
+ type: "function",
216
+ function: {
217
+ name: "reconnect_mcp",
218
+ description: "Reconnect to an MCP server by name (the name it has in MCP_SERVERS, which is also the middle part of its tool names: mcp_<name>_<tool>). Use this when an MCP tool call fails with network / 'fetch failed' / session errors — the transport stays 'connected' in status but requests don't go through. Reconnect rebuilds the session.",
219
+ parameters: {
220
+ type: "object",
221
+ properties: {
222
+ name: { type: "string", description: "MCP server name as shown in list_mcp_servers" },
223
+ },
224
+ required: ["name"],
225
+ },
226
+ },
227
+ },
228
+ ];
229
+
230
+ const tools = [...processToolDefs, ...systemToolDefs, ...agentToolDefs];
231
+
232
+ const processHandlers = createProcessHandlers(store);
233
+ const agentHandlers = createAgentHandlers(store);
234
+
235
+ const systemHandlers = {
236
+ async web_fetch({ url, method, body, headers: extraHeaders, max_length }) {
237
+ const textLimit = max_length || 5000;
238
+ const MAX_BODY = 2 * 1024 * 1024; // 2 MB max download into memory
239
+ try {
240
+ const controller = new AbortController();
241
+ const timeout = setTimeout(() => controller.abort(), 15000);
242
+ const fetchHeaders = { "User-Agent": "Flint/0.7", ...extraHeaders };
243
+ const fetchOpts = { headers: fetchHeaders, signal: controller.signal };
244
+ if (method && method !== "GET") {
245
+ fetchOpts.method = method;
246
+ if (body) {
247
+ fetchOpts.body = body;
248
+ if (!fetchHeaders["Content-Type"]) fetchHeaders["Content-Type"] = "application/json";
249
+ }
250
+ }
251
+ const res = await fetch(url, fetchOpts);
252
+ clearTimeout(timeout);
253
+ if (!res.ok) return `Error: HTTP ${res.status} ${res.statusText}`;
254
+
255
+ // Stream body with size cap — never load more than MAX_BODY
256
+ const contentLength = parseInt(res.headers.get("content-length") || "0", 10);
257
+ if (contentLength > MAX_BODY) {
258
+ return `[Large response: ${(contentLength / 1024 / 1024).toFixed(1)} MB. ` +
259
+ `web_fetch limit is 2 MB. Use run_command with curl/wget to download large files to disk.]`;
260
+ }
261
+
262
+ const chunks = [];
263
+ let totalBytes = 0;
264
+ const reader = res.body.getReader();
265
+ const decoder = new TextDecoder();
266
+ while (true) {
267
+ const { done, value } = await reader.read();
268
+ if (done) break;
269
+ totalBytes += value.length;
270
+ if (totalBytes > MAX_BODY) {
271
+ reader.cancel();
272
+ const partial = chunks.join("") + decoder.decode(value, { stream: false });
273
+ const truncated = partial.slice(0, textLimit);
274
+ return `HTTP ${res.status} | ${totalBytes}+ bytes (stopped at 2 MB limit) | Content-Type: ${res.headers.get("content-type")}\n\n` +
275
+ truncated + `\n... (download exceeded 2 MB, truncated. Use run_command with curl/wget for large files.)`;
276
+ }
277
+ chunks.push(decoder.decode(value, { stream: true }));
278
+ }
279
+
280
+ let text = chunks.join("") + decoder.decode();
281
+ const ct = res.headers.get("content-type") || "";
282
+ // Auto-convert HTML to Markdown for readability
283
+ if (ct.includes("html") || text.trimStart().startsWith("<!") || text.trimStart().startsWith("<html")) {
284
+ text = _htmlToMarkdown(text);
285
+ }
286
+
287
+ // Return factual metadata about size and truncation — the agent
288
+ // decides what to do. Behavioural guidance ("use shell + jq for
289
+ // JSON counting") lives in config/classifier-prompt.md, not in
290
+ // the tool's return value.
291
+ const totalChars = text.length;
292
+ const truncated = totalChars > textLimit;
293
+ const header = truncated
294
+ ? `HTTP ${res.status} | ${ct || "unknown"} | ${totalChars} chars TOTAL, returning first ${textLimit} (TRUNCATED) | ${url}`
295
+ : `HTTP ${res.status} | ${ct || "unknown"} | ${totalChars} chars | ${url}`;
296
+ return `${header}\n\n${truncated ? text.slice(0, textLimit) : text}`;
297
+ } catch (err) {
298
+ return `Error fetching ${url}: ${err.message}`;
299
+ }
300
+ },
301
+
302
+ async web_search({ query, num_results }) {
303
+ const max = Math.min(10, Math.max(1, num_results || 5));
304
+ const UA_DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
305
+ const UA_MOBILE = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Mobile Safari/537.36";
306
+
307
+ // DuckDuckGo first, then Bing, then Google. Bing answers a scraper with
308
+ // unrelated pages (2026-10-01: "Vitest test framework" returned hotel
309
+ // sites and Microsoft support pages, "Ink react terminal" returned
310
+ // printer ink) while the DuckDuckGo HTML endpoint returned the right
311
+ // results for the same queries.
312
+ let results = await _searchDDG(query, max, UA_DESKTOP);
313
+ if (!results.length) results = await _searchBing(query, max, UA_MOBILE);
314
+ if (!results.length) results = await _searchGoogle(query, max, UA_DESKTOP);
315
+ if (!results.length) return `No results found for "${query}".`;
316
+
317
+ const lines = [`Search results for "${query}":\n`];
318
+ for (let i = 0; i < results.length; i++) {
319
+ const r = results[i];
320
+ lines.push(`${i + 1}. ${r.title}`);
321
+ lines.push(` ${r.url}`);
322
+ if (r.snippet) lines.push(` ${r.snippet}`);
323
+ lines.push("");
324
+ }
325
+ return lines.join("\n").trim();
326
+ },
327
+
328
+ async restart_agent({ reason }) {
329
+ // Under a host (headless, stdio) nobody acts on the pending restart, and
330
+ // the model was told "restart done" and believed it (2026-10-02).
331
+ if (config.headless) {
332
+ return "Not available: this agent runs under a host program and cannot restart itself. Its tools are what it has; work with them or say what is missing.";
333
+ }
334
+ store.getState().setPendingAction("restart");
335
+ return `Restart scheduled: ${reason}. Session will be saved and agent will restart.`;
336
+ },
337
+
338
+ async clear_context() {
339
+ store.getState().setPendingAction("clear-context");
340
+ return "Context will be cleared after this response.";
341
+ },
342
+
343
+ async check_balance() {
344
+ if (config.provider !== "openrouter") {
345
+ return `Balance check is only available for OpenRouter. Current provider: ${config.provider}`;
346
+ }
347
+ try {
348
+ const [creditsRes, keyRes] = await Promise.all([
349
+ fetch("https://openrouter.ai/api/v1/credits", {
350
+ headers: { Authorization: `Bearer ${config.apiKey}` },
351
+ }),
352
+ fetch("https://openrouter.ai/api/v1/auth/key", {
353
+ headers: { Authorization: `Bearer ${config.apiKey}` },
354
+ }),
355
+ ]);
356
+ const credits = await creditsRes.json();
357
+ const key = await keyRes.json();
358
+ const total = credits.data?.total_credits ?? 0;
359
+ const used = credits.data?.total_usage ?? 0;
360
+ const remaining = (total - used).toFixed(2);
361
+ const daily = key.data?.usage_daily?.toFixed(4) ?? "?";
362
+ const weekly = key.data?.usage_weekly?.toFixed(4) ?? "?";
363
+ const monthly = key.data?.usage_monthly?.toFixed(4) ?? "?";
364
+ return [
365
+ `OpenRouter API balance: $${remaining} remaining (of $${total.toFixed(2)} total credits)`,
366
+ `All-time usage (NOT this session): $${used.toFixed(2)}`,
367
+ `Usage breakdown: today $${daily} | this week $${weekly} | this month $${monthly}`,
368
+ `NOTE: This is your OpenRouter account balance, not session cost. Session cost is shown in the stats line after each response.`,
369
+ ].join("\n");
370
+ } catch (err) {
371
+ return `Error checking balance: ${err.message}`;
372
+ }
373
+ },
374
+
375
+ async think({ thought }) {
376
+ // Think tool — result is just an acknowledgement
377
+ return "Your thought has been recorded. Continue with your response.";
378
+ },
379
+
380
+ async list_models({ query } = {}) {
381
+ try {
382
+ const { fetchModels } = await import("../providers/models.js");
383
+ const { getProvider } = await import("../providers/registry.js");
384
+ const provider = getProvider(config.provider);
385
+ let models = await fetchModels(config.provider);
386
+
387
+ if (query) {
388
+ // Search mode — search ALL models by query, ignore curated filter
389
+ const q = query.toLowerCase();
390
+ models = models.filter((m) => m.id.toLowerCase().includes(q) || (m.name || "").toLowerCase().includes(q));
391
+ } else if (config.provider === "openrouter" && CURATED_PREFIXES.length > 0) {
392
+ // Default mode — filter to curated list for OpenRouter (empty = show all)
393
+ models = models.filter((m) => CURATED_PREFIXES.some((p) => m.id.startsWith(p)));
394
+ }
395
+
396
+ models.sort((a, b) => a.id.localeCompare(b.id));
397
+
398
+ if (!models.length) return `No models found for ${provider?.name || config.provider}.`;
399
+ const lines = [`Available models (${provider?.name || config.provider}):\n`];
400
+ const current = config.model;
401
+ for (const m of models) {
402
+ const p = m.pricing || {};
403
+ const inPrice = (parseFloat(p.prompt || 0) * 1e6).toFixed(2);
404
+ const outPrice = (parseFloat(p.completion || 0) * 1e6).toFixed(2);
405
+ const ctx = m.context_length ? `${(m.context_length / 1000).toFixed(0)}k` : "?";
406
+ const marker = m.id === current ? " ← current" : "";
407
+ const priceStr = p.prompt || p.completion ? ` $${inPrice}/$${outPrice} per 1M` : "";
408
+ lines.push(`${m.id}${priceStr} ctx:${ctx}${marker}`);
409
+ }
410
+ return lines.join("\n");
411
+ } catch (err) {
412
+ return `Error: ${err.message}`;
413
+ }
414
+ },
415
+
416
+ async switch_model({ model_id, provider: providerArg }) {
417
+ // Optionally switch provider first
418
+ if (providerArg && providerArg !== config.provider) {
419
+ const result = await handlers.switch_provider({ provider_id: providerArg });
420
+ if (result.startsWith("Error")) return result;
421
+ }
422
+
423
+ const { fetchModelInfo } = await import("../api/client.js");
424
+ const { setLastModel } = await import("../providers/state.js");
425
+ const info = await fetchModelInfo(model_id);
426
+ config.model = model_id;
427
+ store.getState().setModel(model_id);
428
+ setLastModel(config.provider, model_id);
429
+ if (info) {
430
+ store.getState().setPricing(info);
431
+ return `Switched to ${model_id} (${config.provider}). Pricing: $${(info.prompt * 1e6).toFixed(2)}/$${(info.completion * 1e6).toFixed(2)} per 1M tokens.`;
432
+ }
433
+ return `Switched to ${model_id} (${config.provider}). No pricing info available.`;
434
+ },
435
+
436
+ async switch_provider({ provider_id }) {
437
+ const { getProvider } = await import("../providers/registry.js");
438
+ const { hasKey } = await import("../providers/keys.js");
439
+ const { setActiveProvider, getLastModel } = await import("../providers/state.js");
440
+
441
+ const provider = getProvider(provider_id);
442
+ if (!provider) return `Error: unknown provider "${provider_id}". Use list_providers to see available options.`;
443
+
444
+ if (provider.keyRequired && !hasKey(provider_id)) {
445
+ return `Error: no API key configured for ${provider.name}. Use /key ${provider_id} to set one.`;
446
+ }
447
+
448
+ config.provider = provider_id;
449
+ setActiveProvider(provider_id);
450
+
451
+ // Resolve model for new provider
452
+ const lastModel = getLastModel(provider_id);
453
+ const model = lastModel || provider.defaultModel;
454
+ config.model = model;
455
+ store.getState().setModel(model);
456
+ store.getState().setProvider(provider_id);
457
+
458
+ // Resolve API key for new provider
459
+ await config.resolveApiKey();
460
+
461
+ // Fetch pricing for new model
462
+ const { fetchModelInfo } = await import("../api/client.js");
463
+ const info = await fetchModelInfo(model);
464
+ if (info) store.getState().setPricing(info);
465
+
466
+ return `Switched to ${provider.name} (${model}).`;
467
+ },
468
+
469
+ async list_providers() {
470
+ const { listProviders } = await import("../providers/registry.js");
471
+ const { hasKey } = await import("../providers/keys.js");
472
+ const providers = listProviders();
473
+ const lines = ["Available providers:\n"];
474
+ for (const p of providers) {
475
+ const keyStatus = p.keyRequired
476
+ ? (hasKey(p.id) ? "key: yes" : "key: no")
477
+ : "no key needed";
478
+ const marker = p.id === config.provider ? " <-- current" : "";
479
+ lines.push(`${p.id.padEnd(12)} ${p.name.padEnd(16)} ${keyStatus}${marker}`);
480
+ }
481
+ lines.push("\nUse switch_provider to change, or /key <provider> to add a key.");
482
+ return lines.join("\n");
483
+ },
484
+
485
+ async list_mcp_servers() {
486
+ const { getMcpServerStatus } = await import("./registry.js");
487
+ const servers = getMcpServerStatus();
488
+ if (!servers.length) return "No MCP servers configured.";
489
+ const lines = ["MCP Servers:"];
490
+ for (const s of servers) {
491
+ lines.push(` ${s.connected ? "[+]" : "[ ]"} ${s.name} ${s.url}`);
492
+ }
493
+ return lines.join("\n");
494
+ },
495
+
496
+ async reconnect_mcp({ name }) {
497
+ if (!name || typeof name !== "string") {
498
+ return "Error: name is required: the server's name in MCP_SERVERS, as in mcp_<name>_<tool>.";
499
+ }
500
+ const { mcpReconnect, getMcpServerStatus } = await import("./registry.js");
501
+ const before = getMcpServerStatus().find((s) => s.name === name);
502
+ if (!before) {
503
+ const names = getMcpServerStatus().map((s) => s.name).join(", ");
504
+ return `Error: no MCP server named "${name}". Configured: ${names || "(none)"}.`;
505
+ }
506
+ try {
507
+ const result = await mcpReconnect(name);
508
+ const after = getMcpServerStatus().find((s) => s.name === name);
509
+ if (result && result.ok === false) {
510
+ return `Reconnect attempted for ${name}. Still [${after?.connected ? "+" : " "}] — error: ${result.error || "unknown"}.`;
511
+ }
512
+ return `Reconnected ${name}. Status: [${after?.connected ? "+" : " "}] ${after?.url || ""}`;
513
+ } catch (err) {
514
+ return `Reconnect ${name} failed: ${err.message || String(err)}`;
515
+ }
516
+ },
517
+ };
518
+
519
+ const handlers = { ...processHandlers, ...systemHandlers, ...agentHandlers };
520
+
521
+ return { tools, handlers };
522
+ }
523
+
524
+ // ── Web search helpers ──
525
+
526
+ function decodeHTMLEntities(text) {
527
+ return text
528
+ .replace(/&amp;/g, "&")
529
+ .replace(/&lt;/g, "<")
530
+ .replace(/&gt;/g, ">")
531
+ .replace(/&quot;/g, '"')
532
+ .replace(/&#39;/g, "'")
533
+ .replace(/&#x27;/g, "'")
534
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
535
+ }
536
+
537
+ function stripTags(html) {
538
+ return html.replace(/<[^>]+>/g, "").trim();
539
+ }
540
+
541
+ async function _searchBing(query, max, ua) {
542
+ try {
543
+ const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${max}`;
544
+ const res = await fetch(url, {
545
+ headers: { "User-Agent": ua },
546
+ signal: AbortSignal.timeout(10000),
547
+ });
548
+ if (!res.ok) return [];
549
+ const html = await res.text();
550
+ return parseBingHTML(html, max);
551
+ } catch {
552
+ return [];
553
+ }
554
+ }
555
+
556
+ function parseBingHTML(html, max = 5) {
557
+ const results = [];
558
+ // Parse organic results from b_algo blocks
559
+ const blocks = html.match(/<li class="b_algo"[\s\S]*?<\/li>/g) || [];
560
+ for (const block of blocks) {
561
+ if (results.length >= max) break;
562
+ const link = block.match(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
563
+ if (!link) continue;
564
+ const url = decodeHTMLEntities(link[1]);
565
+ const title = decodeHTMLEntities(stripTags(link[2])).trim();
566
+ if (!title || title.length < 5) continue;
567
+ // Extract snippet from <p> or <span> after the link
568
+ const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/) || block.match(/<span class="[^"]*algoSlug[^"]*"[^>]*>([\s\S]*?)<\/span>/);
569
+ const snippet = snippetMatch ? decodeHTMLEntities(stripTags(snippetMatch[1])).slice(0, 200) : "";
570
+ results.push({ title: title.slice(0, 150), url, snippet });
571
+ }
572
+ return results;
573
+ }
574
+
575
+ async function _searchGoogle(query, max, ua) {
576
+ try {
577
+ const url = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${max}&hl=en`;
578
+ const res = await fetch(url, {
579
+ headers: { "User-Agent": ua },
580
+ signal: AbortSignal.timeout(10000),
581
+ });
582
+ if (!res.ok) return [];
583
+ const html = await res.text();
584
+ return parseGoogleHTML(html, max);
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
590
+ export function parseGoogleHTML(html, max = 5) {
591
+ const results = [];
592
+ // Google wraps results in <div class="g"> or similar; extract <a href="/url?q=..."> + <h3>
593
+ const blockRe = /<a\s+href="\/url\?q=([^&"]+)[^"]*"[^>]*>.*?<h3[^>]*>(.*?)<\/h3>/gs;
594
+ let match;
595
+ while ((match = blockRe.exec(html)) && results.length < max) {
596
+ const url = decodeURIComponent(match[1]);
597
+ const title = decodeHTMLEntities(stripTags(match[2]));
598
+ if (!title || url.startsWith("/") || url.includes("google.com")) continue;
599
+ results.push({ title, url, snippet: "" });
600
+ }
601
+
602
+ // Try to extract snippets — they usually follow in a nearby <span> or <div>
603
+ // This is best-effort since Google's HTML changes
604
+ if (results.length) {
605
+ const snippetRe = /<span[^>]*class="[^"]*(?:st|IsZvec|VwiC3b)[^"]*"[^>]*>(.*?)<\/span>/gs;
606
+ let i = 0;
607
+ while ((match = snippetRe.exec(html)) && i < results.length) {
608
+ const text = decodeHTMLEntities(stripTags(match[1])).slice(0, 200);
609
+ if (text.length > 20) {
610
+ results[i].snippet = text;
611
+ i++;
612
+ }
613
+ }
614
+ }
615
+
616
+ return results;
617
+ }
618
+
619
+ async function _searchDDG(query, max, ua) {
620
+ try {
621
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
622
+ const res = await fetch(url, {
623
+ headers: { "User-Agent": ua },
624
+ signal: AbortSignal.timeout(10000),
625
+ });
626
+ if (!res.ok) return [];
627
+ const html = await res.text();
628
+ return parseDDGHTML(html, max);
629
+ } catch {
630
+ return [];
631
+ }
632
+ }
633
+
634
+ export function parseDDGHTML(html, max = 5) {
635
+ const results = [];
636
+ // DDG HTML results: <a class="result__a" href="...">title</a>
637
+ // Snippet: <a class="result__snippet" ...>text</a>
638
+ const linkRe = /<a\s+[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gs;
639
+ const snippetRe = /<a\s+[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/gs;
640
+
641
+ let match;
642
+ while ((match = linkRe.exec(html)) && results.length < max) {
643
+ let url = match[1];
644
+ const title = decodeHTMLEntities(stripTags(match[2]));
645
+ if (!title) continue;
646
+ // DDG sometimes wraps URLs through redirect
647
+ if (url.includes("uddg=")) {
648
+ const m = url.match(/uddg=([^&]+)/);
649
+ if (m) url = decodeURIComponent(m[1]);
650
+ }
651
+ results.push({ title, url, snippet: "" });
652
+ }
653
+
654
+ // Extract snippets
655
+ let i = 0;
656
+ while ((match = snippetRe.exec(html)) && i < results.length) {
657
+ results[i].snippet = decodeHTMLEntities(stripTags(match[1])).slice(0, 200);
658
+ i++;
659
+ }
660
+
661
+ return results;
662
+ }