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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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, "");
|
|
29
|
+
s = s.replace(/<img[^>]+src="([^"]*)"[^>]*\/?>/gi, "");
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
53
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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(/&/g, "&")
|
|
529
|
+
.replace(/</g, "<")
|
|
530
|
+
.replace(/>/g, ">")
|
|
531
|
+
.replace(/"/g, '"')
|
|
532
|
+
.replace(/'/g, "'")
|
|
533
|
+
.replace(/'/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
|
+
}
|