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,682 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { readFileSync, statSync, createReadStream } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { config } from "../config.js";
|
|
7
|
+
import { saveCheckpoint } from "./checkpoint.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize /tmp/ paths to os.tmpdir() for cross-platform consistency.
|
|
11
|
+
* On Linux/Mac: /tmp/ stays /tmp/ (os.tmpdir() = /tmp)
|
|
12
|
+
* On Windows: /tmp/foo → C:\Users\...\AppData\Local\Temp\foo
|
|
13
|
+
* Without this, Node.js resolves /tmp/ to C:\tmp\ on Windows,
|
|
14
|
+
* but shells (Git Bash) resolve to AppData\Local\Temp — causing split-brain.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeTmpPath(filePath) {
|
|
17
|
+
// Only fix on Windows where /tmp/ diverges
|
|
18
|
+
if (process.platform !== "win32") return filePath;
|
|
19
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
20
|
+
if (normalized.startsWith("/tmp/") || normalized === "/tmp") {
|
|
21
|
+
const rest = normalized.slice(5); // after "/tmp/"
|
|
22
|
+
return path.join(os.tmpdir(), rest);
|
|
23
|
+
}
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Denied paths — agent cannot write to these directories (e.g. its own source)
|
|
28
|
+
// Inherited from parent agent via AGENT_DENIED_PATHS env var
|
|
29
|
+
let deniedPaths = (process.env.AGENT_DENIED_PATHS || "")
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((p) => p.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((p) => path.resolve(p));
|
|
34
|
+
|
|
35
|
+
export function setDeniedPaths(paths) {
|
|
36
|
+
deniedPaths = paths.map((p) => path.resolve(p));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getDeniedPaths() {
|
|
40
|
+
return deniedPaths;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function clearDeniedPaths() {
|
|
44
|
+
deniedPaths = [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Resolve path for READING — relative to project root (can read anything).
|
|
48
|
+
// config.baseDir moves it, and the folder commands run in (process-tools),
|
|
49
|
+
// without moving projectRoot, which also locates Flint's own code for child
|
|
50
|
+
// agents: the stdio mode sets it to the agent's folder, where an agent's
|
|
51
|
+
// relative paths and commands would go.
|
|
52
|
+
function resolveReadPath(filePath) {
|
|
53
|
+
filePath = normalizeTmpPath(filePath);
|
|
54
|
+
if (path.isAbsolute(filePath)) return path.resolve(filePath);
|
|
55
|
+
const root = config.baseDir || config.projectRoot || process.cwd();
|
|
56
|
+
return path.resolve(root, filePath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve path for WRITING — relative to workspace (isolated per session)
|
|
60
|
+
function resolveWritePath(filePath) {
|
|
61
|
+
filePath = normalizeTmpPath(filePath);
|
|
62
|
+
if (path.isAbsolute(filePath)) return path.resolve(filePath);
|
|
63
|
+
const workdir = config.workdir || config.projectRoot || process.cwd();
|
|
64
|
+
// Strip leading "workspace/" if workdir already IS the workspace dir
|
|
65
|
+
// Models see "workspace/" in list_directory and include it in paths,
|
|
66
|
+
// but workdir already points to .../workspace/ → double nesting
|
|
67
|
+
const workspaceName = path.basename(workdir);
|
|
68
|
+
if (filePath.startsWith(workspaceName + "/") || filePath.startsWith(workspaceName + "\\")) {
|
|
69
|
+
filePath = filePath.slice(workspaceName.length + 1);
|
|
70
|
+
}
|
|
71
|
+
return path.resolve(workdir, filePath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Default resolve (backward compat) — uses read path
|
|
75
|
+
function resolvePath(filePath) {
|
|
76
|
+
return resolveReadPath(filePath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function checkAccess(filePath) {
|
|
80
|
+
const resolved = resolvePath(filePath);
|
|
81
|
+
|
|
82
|
+
// Check deny-list first (e.g. Flint's own source during /auto)
|
|
83
|
+
if (deniedPaths.length) {
|
|
84
|
+
const denied = deniedPaths.some((dir) => resolved.startsWith(dir + path.sep) || resolved === dir);
|
|
85
|
+
if (denied) {
|
|
86
|
+
throw new Error(`Access denied: "${resolved}" is a protected directory (self-modification blocked)`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check allow-list
|
|
91
|
+
const allowed = config.allowedPaths;
|
|
92
|
+
if (!allowed || !allowed.length) return; // unrestricted
|
|
93
|
+
const ok = allowed.some((dir) => resolved.startsWith(dir + path.sep) || resolved === dir);
|
|
94
|
+
if (!ok) {
|
|
95
|
+
throw new Error(`Access denied: "${resolved}" is outside allowed directories [${allowed.join(", ")}]`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const tools = [
|
|
100
|
+
{
|
|
101
|
+
type: "function",
|
|
102
|
+
function: {
|
|
103
|
+
name: "read_file",
|
|
104
|
+
description: "Read the contents of a file. Files up to 256 KB (any ordinary source file) are returned in full: read them whole in one call, not in slices. For larger files, a preview is shown with file stats (size, lines, estimated tokens); use offset/limit to read the rest in chunks of 1000 lines.",
|
|
105
|
+
parameters: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
path: { type: "string", description: "Path to the file to read" },
|
|
109
|
+
offset: { type: "integer", description: "Start line number (0-based). Use for large files or to continue reading." },
|
|
110
|
+
limit: { type: "integer", description: "Max number of lines to read (default: 1000)." },
|
|
111
|
+
},
|
|
112
|
+
required: ["path"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: "function",
|
|
118
|
+
function: {
|
|
119
|
+
name: "write_file",
|
|
120
|
+
description: "Write files to disk. For 1 file: pass path and content. For multiple files: pass files array. Each call = 1 iteration — always batch when creating 2+ files.",
|
|
121
|
+
parameters: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
path: { type: "string", description: "File path (single-file mode)" },
|
|
125
|
+
content: { type: "string", description: "File content (single-file mode)" },
|
|
126
|
+
files: {
|
|
127
|
+
type: "array",
|
|
128
|
+
items: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
path: { type: "string" },
|
|
132
|
+
content: { type: "string" },
|
|
133
|
+
},
|
|
134
|
+
required: ["path", "content"],
|
|
135
|
+
},
|
|
136
|
+
description: "Batch mode: [{path, content}, ...] — use for 2+ files",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: "function",
|
|
144
|
+
function: {
|
|
145
|
+
name: "list_directory",
|
|
146
|
+
description: "List files and directories at the given path",
|
|
147
|
+
parameters: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
path: { type: "string", description: "Path to the directory to list" },
|
|
151
|
+
},
|
|
152
|
+
required: ["path"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
type: "function",
|
|
158
|
+
function: {
|
|
159
|
+
name: "create_directory",
|
|
160
|
+
description: "Create a directory (and parent directories if needed)",
|
|
161
|
+
parameters: {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: {
|
|
164
|
+
path: { type: "string", description: "Path of the directory to create" },
|
|
165
|
+
},
|
|
166
|
+
required: ["path"],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: "function",
|
|
172
|
+
function: {
|
|
173
|
+
name: "copy_file",
|
|
174
|
+
description: "Copy a file from source path to destination path",
|
|
175
|
+
parameters: {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
source: { type: "string", description: "Source file path" },
|
|
179
|
+
destination: { type: "string", description: "Destination file path" },
|
|
180
|
+
},
|
|
181
|
+
required: ["source", "destination"],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
type: "function",
|
|
187
|
+
function: {
|
|
188
|
+
name: "move_file",
|
|
189
|
+
description: "Move or rename a file from source path to destination path",
|
|
190
|
+
parameters: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
source: { type: "string", description: "Source file path" },
|
|
194
|
+
destination: { type: "string", description: "Destination file path" },
|
|
195
|
+
},
|
|
196
|
+
required: ["source", "destination"],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
type: "function",
|
|
202
|
+
function: {
|
|
203
|
+
name: "delete_file",
|
|
204
|
+
description: "Delete a file or empty directory. DESTRUCTIVE — always confirm with user first!",
|
|
205
|
+
parameters: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
path: { type: "string", description: "Path to the file or empty directory to delete" },
|
|
209
|
+
},
|
|
210
|
+
required: ["path"],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
type: "function",
|
|
216
|
+
function: {
|
|
217
|
+
name: "search_in_files",
|
|
218
|
+
description: "Search for a text pattern (regex) across files in a directory. Returns matching lines with file paths and line numbers. Use this to find specific code, functions, imports, patterns without reading entire files. Much cheaper than reading every file.",
|
|
219
|
+
parameters: {
|
|
220
|
+
type: "object",
|
|
221
|
+
properties: {
|
|
222
|
+
pattern: { type: "string", description: "Regex pattern to search for (e.g. 'function handleSubmit', 'import.*react', 'TODO')" },
|
|
223
|
+
path: { type: "string", description: "Directory to search in (default: current dir)" },
|
|
224
|
+
glob: { type: "string", description: "File glob filter (e.g. '*.js', '*.ts', '*.py'). Default: all text files" },
|
|
225
|
+
max_results: { type: "number", description: "Maximum matches to return (default: 30)" },
|
|
226
|
+
},
|
|
227
|
+
required: ["pattern"],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
type: "function",
|
|
233
|
+
function: {
|
|
234
|
+
name: "view_image",
|
|
235
|
+
description: "View an image file. Returns image description and injects image into conversation for visual analysis. Supports: png, jpg, gif, webp, bmp.",
|
|
236
|
+
parameters: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
path: { type: "string", description: "Path to image file" },
|
|
240
|
+
},
|
|
241
|
+
required: ["path"],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
type: "function",
|
|
247
|
+
function: {
|
|
248
|
+
name: "edit_file",
|
|
249
|
+
description: "Edit a file by replacing a specific text fragment. More precise than write_file — doesn't require rewriting the entire file.",
|
|
250
|
+
parameters: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
path: { type: "string", description: "Path to the file to edit" },
|
|
254
|
+
old_text: { type: "string", description: "Exact text to find and replace" },
|
|
255
|
+
new_text: { type: "string", description: "Replacement text" },
|
|
256
|
+
all: { type: "boolean", description: "Replace all occurrences (default: false)" },
|
|
257
|
+
},
|
|
258
|
+
required: ["path", "old_text", "new_text"],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
type: "function",
|
|
264
|
+
function: {
|
|
265
|
+
name: "glob",
|
|
266
|
+
description: "Find files matching a glob pattern. Supports: *, **, ?. Example: 'src/**/*.js', '*.md'.",
|
|
267
|
+
parameters: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {
|
|
270
|
+
pattern: { type: "string", description: "Glob pattern" },
|
|
271
|
+
path: { type: "string", description: "Base directory (default: current dir)" },
|
|
272
|
+
},
|
|
273
|
+
required: ["pattern"],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
export const handlers = {
|
|
280
|
+
async read_file({ path: filePath, offset, limit }) {
|
|
281
|
+
checkAccess(filePath);
|
|
282
|
+
const resolved = resolvePath(filePath);
|
|
283
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
284
|
+
const binaryExts = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".svg",
|
|
285
|
+
".mp3", ".mp4", ".wav", ".avi", ".mkv", ".mov", ".flac",
|
|
286
|
+
".zip", ".rar", ".7z", ".tar", ".gz", ".bz2",
|
|
287
|
+
".exe", ".dll", ".so", ".bin", ".dat", ".pdf",
|
|
288
|
+
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
|
289
|
+
if (binaryExts.includes(ext)) {
|
|
290
|
+
const stat = await fs.stat(resolved);
|
|
291
|
+
return `[Binary file: ${resolved} (${(stat.size / 1024).toFixed(1)} KB, type: ${ext}). Cannot read binary files as text.]`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const MAX_SIZE = 512 * 1024; // 512 KB — small enough to read into memory
|
|
295
|
+
const MAX_LINES = 1000;
|
|
296
|
+
const PREVIEW_LINES = 20; // auto-preview for large files
|
|
297
|
+
const stat = await fs.stat(resolved);
|
|
298
|
+
const isLarge = stat.size > MAX_SIZE;
|
|
299
|
+
|
|
300
|
+
// Large file: always use streaming (never load into memory)
|
|
301
|
+
if (isLarge) {
|
|
302
|
+
const start = Math.max(0, offset || 0);
|
|
303
|
+
const count = limit || PREVIEW_LINES; // default to preview, not 1000
|
|
304
|
+
const { lines, totalLines } = await readLinesStream(resolved, start, count);
|
|
305
|
+
|
|
306
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
307
|
+
const sizeGB = stat.size > 1024 * 1024 * 1024
|
|
308
|
+
? ` (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB)` : "";
|
|
309
|
+
|
|
310
|
+
let header = `[Large file: ${resolved} — ${sizeMB} MB${sizeGB}`;
|
|
311
|
+
if (totalLines != null) header += `, ~${totalLines} lines`;
|
|
312
|
+
header += `]\n`;
|
|
313
|
+
|
|
314
|
+
if (!offset && !limit) {
|
|
315
|
+
const estimatedTokens = Math.round(stat.size / 3.5);
|
|
316
|
+
header += `[Auto-preview: first ${lines.length} lines. ~${estimatedTokens.toLocaleString()} tokens total.]\n`;
|
|
317
|
+
header += `[IMPORTANT: Ask the user before reading the full file. Warn about size and estimated cost.]\n`;
|
|
318
|
+
header += `[To read more: use read_file(path="${filePath}", offset=0, limit=1000) and continue in chunks.]\n\n`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let result = header + lines.join("\n");
|
|
322
|
+
|
|
323
|
+
if (start + count < (totalLines || Infinity)) {
|
|
324
|
+
result += `\n[... showing lines ${start + 1}-${start + lines.length}` +
|
|
325
|
+
(totalLines != null ? ` of ${totalLines}` : "") +
|
|
326
|
+
`. Use offset=${start + lines.length} limit=${count} to continue.]`;
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Small file: read into memory (safe)
|
|
332
|
+
const content = await fs.readFile(resolved, "utf-8");
|
|
333
|
+
|
|
334
|
+
if (offset != null || limit != null) {
|
|
335
|
+
const lines = content.split("\n");
|
|
336
|
+
const start = Math.max(0, offset || 0);
|
|
337
|
+
const count = limit || MAX_LINES;
|
|
338
|
+
const slice = lines.slice(start, start + count);
|
|
339
|
+
let result = slice.join("\n");
|
|
340
|
+
if (start + count < lines.length) {
|
|
341
|
+
result += `\n[... showing lines ${start + 1}-${start + count} of ${lines.length} total. Use offset=${start + count} to continue.]`;
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The limit is in bytes, not lines: 1000 lines of source is ~50 KB, ~15k
|
|
347
|
+
// tokens, cheap to read whole. A line limit caught ordinary files like
|
|
348
|
+
// agent.js and made the model read them in 20-line slices, one slow model
|
|
349
|
+
// call each. No "ask the user" here: under a task nobody is there
|
|
350
|
+
// to answer, and at this size the cost is not worth a question.
|
|
351
|
+
const FULL_READ_BYTES = 256 * 1024;
|
|
352
|
+
if (stat.size > FULL_READ_BYTES) {
|
|
353
|
+
const lineCount = content.split("\n").length;
|
|
354
|
+
const previewLines = 50;
|
|
355
|
+
const preview = content.split("\n").slice(0, previewLines).join("\n");
|
|
356
|
+
const sizeKB = (stat.size / 1024).toFixed(1);
|
|
357
|
+
const estimatedTokens = Math.round(stat.size / 3.5); // ~3.5 bytes per token for mixed content
|
|
358
|
+
return preview + `\n\n[FILE PREVIEW: ${resolved}]\n` +
|
|
359
|
+
`[Total: ${lineCount} lines, ${sizeKB} KB, ~${estimatedTokens.toLocaleString()} tokens]\n` +
|
|
360
|
+
`[Showing first ${previewLines} lines. Full file NOT loaded.]\n` +
|
|
361
|
+
`[To read more: use read_file(path="${filePath}", offset=${previewLines}, limit=${MAX_LINES}) and continue in chunks.]`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return content;
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async write_file({ path: filePath, content, files }) {
|
|
368
|
+
// Normalize: if model passed path+content instead of files[], wrap it
|
|
369
|
+
if (!files && filePath && content != null) {
|
|
370
|
+
files = [{ path: filePath, content }];
|
|
371
|
+
}
|
|
372
|
+
if (!files || !files.length) return "Error: files array is required. Example: write_file({files: [{path: 'a.js', content: '...'}]})";
|
|
373
|
+
if (files.length > config.maxBatchFiles) return `Error: max ${config.maxBatchFiles} files per call`;
|
|
374
|
+
const results = [];
|
|
375
|
+
for (const { path: fp, content: fc } of files) {
|
|
376
|
+
try {
|
|
377
|
+
checkAccess(fp);
|
|
378
|
+
await saveCheckpoint(fp, "write");
|
|
379
|
+
const resolved = resolveWritePath(fp);
|
|
380
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
381
|
+
await fs.writeFile(resolved, fc, "utf-8");
|
|
382
|
+
results.push(`✓ ${fp}`);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
results.push(`✗ ${fp}: ${err.message}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (files.length === 1) return results[0].startsWith("✓") ? `File written: ${files[0].path}` : results[0];
|
|
388
|
+
return `${results.filter(r => r.startsWith("✓")).length}/${files.length} files written:\n${results.join("\n")}`;
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
async list_directory({ path: dirPath }) {
|
|
392
|
+
checkAccess(dirPath);
|
|
393
|
+
const resolved = resolvePath(dirPath);
|
|
394
|
+
const entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
395
|
+
const lines = entries.map((e) => {
|
|
396
|
+
const suffix = e.isDirectory() ? "/" : "";
|
|
397
|
+
return `${e.name}${suffix}`;
|
|
398
|
+
});
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
async create_directory({ path: dirPath }) {
|
|
403
|
+
checkAccess(dirPath);
|
|
404
|
+
const resolved = resolveWritePath(dirPath);
|
|
405
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
406
|
+
return `Directory created: ${resolved}`;
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
async copy_file({ source, destination }) {
|
|
410
|
+
checkAccess(source);
|
|
411
|
+
checkAccess(destination);
|
|
412
|
+
const src = resolvePath(source);
|
|
413
|
+
const dst = resolveWritePath(destination);
|
|
414
|
+
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
415
|
+
await fs.copyFile(src, dst);
|
|
416
|
+
return `File copied: ${src} -> ${dst}`;
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
async move_file({ source, destination }) {
|
|
420
|
+
checkAccess(source);
|
|
421
|
+
checkAccess(destination);
|
|
422
|
+
const src = resolvePath(source);
|
|
423
|
+
const dst = resolveWritePath(destination);
|
|
424
|
+
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
425
|
+
await fs.rename(src, dst);
|
|
426
|
+
return `File moved: ${src} -> ${dst}`;
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
async search_in_files({ pattern, path: searchPath, glob: globPattern, max_results: maxResults = 30 }) {
|
|
430
|
+
checkAccess(searchPath || ".");
|
|
431
|
+
const dir = resolvePath(searchPath || ".");
|
|
432
|
+
const regex = new RegExp(pattern, "i");
|
|
433
|
+
const matches = [];
|
|
434
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", ".cache"]);
|
|
435
|
+
const BINARY_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico",
|
|
436
|
+
".mp3", ".mp4", ".wav", ".zip", ".rar", ".7z", ".tar", ".gz",
|
|
437
|
+
".exe", ".dll", ".so", ".bin", ".dat", ".pdf", ".woff", ".woff2", ".ttf",
|
|
438
|
+
".lock"]);
|
|
439
|
+
|
|
440
|
+
// Fail-fast constraints: overall timeout + per-file size cap.
|
|
441
|
+
// Without these, a single 500MB log file or a pathological tree can hang
|
|
442
|
+
// the agent forever. 15s timeout, 2MB per-file cap. Bail cleanly.
|
|
443
|
+
const TIMEOUT_MS = 15000;
|
|
444
|
+
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
445
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
446
|
+
let timedOut = false;
|
|
447
|
+
|
|
448
|
+
// The glob filters by extension: "*.js", "**/*.js" and "*.{js,ts,mjs}".
|
|
449
|
+
// Braces used to fall through to a literal comparison that matched no
|
|
450
|
+
// extension at all, so the tool answered "No matches" for code that was
|
|
451
|
+
// there, and on 2026-09-22 the agent believed it for three calls. A shape
|
|
452
|
+
// this cannot read is refused out loud instead of silently.
|
|
453
|
+
let globExts = null;
|
|
454
|
+
if (globPattern) {
|
|
455
|
+
const base = globPattern.split("/").pop();
|
|
456
|
+
const m = base.match(/^\*\.(?:\{([^{}]+)\}|([^*{}]+))$/);
|
|
457
|
+
if (!m) {
|
|
458
|
+
return `Error: glob "${globPattern}" is not supported. Use "*.ext" or "*.{ext1,ext2}", or omit glob and narrow the path.`;
|
|
459
|
+
}
|
|
460
|
+
const list = m[1] ? m[1].split(",") : [m[2]];
|
|
461
|
+
globExts = new Set(list.map(e => "." + e.trim().toLowerCase()).filter(e => e.length > 1));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function walk(dir) {
|
|
465
|
+
if (matches.length >= maxResults) return;
|
|
466
|
+
if (Date.now() > deadline) { timedOut = true; return; }
|
|
467
|
+
let entries;
|
|
468
|
+
try {
|
|
469
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
470
|
+
} catch { return; }
|
|
471
|
+
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
if (matches.length >= maxResults) return;
|
|
474
|
+
if (Date.now() > deadline) { timedOut = true; return; }
|
|
475
|
+
const full = path.join(dir, entry.name);
|
|
476
|
+
|
|
477
|
+
if (entry.isDirectory()) {
|
|
478
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(full);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
483
|
+
if (BINARY_EXTS.has(ext)) continue;
|
|
484
|
+
// endsWith, not extname: "*.test.js" names a suffix with a dot in it.
|
|
485
|
+
if (globExts && ![...globExts].some(e => entry.name.toLowerCase().endsWith(e))) continue;
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
// Skip huge files — regex on 100MB text blocks the event loop.
|
|
489
|
+
const stat = await fs.stat(full);
|
|
490
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
491
|
+
const content = await fs.readFile(full, "utf-8");
|
|
492
|
+
const lines = content.split("\n");
|
|
493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
494
|
+
if (matches.length >= maxResults) break;
|
|
495
|
+
if (regex.test(lines[i])) {
|
|
496
|
+
const rel = path.relative(resolvePath("."), full).replace(/\\/g, "/");
|
|
497
|
+
matches.push(`${rel}:${i + 1}: ${lines[i].trimEnd().slice(0, 200)}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch { /* skip unreadable */ }
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
await walk(dir);
|
|
505
|
+
|
|
506
|
+
const timeoutNote = timedOut ? " (TIMEOUT after 15s — try narrower path/glob)" : "";
|
|
507
|
+
if (!matches.length) return `No matches for /${pattern}/ in ${dir}${timeoutNote}`;
|
|
508
|
+
const header = `Found ${matches.length}${matches.length >= maxResults ? "+" : ""} matches for /${pattern}/${timeoutNote}:`;
|
|
509
|
+
return header + "\n" + matches.join("\n");
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async view_image({ path: filePath }) {
|
|
513
|
+
checkAccess(filePath);
|
|
514
|
+
const resolved = resolvePath(filePath);
|
|
515
|
+
const ext = path.extname(resolved).toLowerCase().replace(".", "");
|
|
516
|
+
const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
|
|
517
|
+
const mime = mimeMap[ext];
|
|
518
|
+
if (!mime) return `Error: unsupported image format "${ext}". Supported: png, jpg, gif, webp, bmp`;
|
|
519
|
+
const stat = statSync(resolved);
|
|
520
|
+
const data = readFileSync(resolved);
|
|
521
|
+
const base64 = data.toString("base64");
|
|
522
|
+
const dataUrl = `data:${mime};base64,${base64}`;
|
|
523
|
+
return { _image: true, data: base64, format: ext, text: `Image loaded: ${(stat.size / 1024).toFixed(1)} KB, ${ext}` };
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
async edit_file({ path: filePath, old_text, new_text, all }) {
|
|
527
|
+
checkAccess(filePath);
|
|
528
|
+
await saveCheckpoint(filePath, "edit");
|
|
529
|
+
const resolved = resolveWritePath(filePath);
|
|
530
|
+
const content = await fs.readFile(resolved, "utf-8");
|
|
531
|
+
|
|
532
|
+
// Normalize line endings for matching. Files checked out on Windows
|
|
533
|
+
// have CRLF, but the agent reads/displays content as LF (most model
|
|
534
|
+
// responses and read_file outputs use LF). Strict includes() would
|
|
535
|
+
// fail on any multi-line old_text because \r\n ≠ \n. Normalize both
|
|
536
|
+
// sides, match, then write back in the file's original convention.
|
|
537
|
+
const hasCRLF = /\r\n/.test(content);
|
|
538
|
+
const contentN = content.replace(/\r\n/g, "\n");
|
|
539
|
+
const oldN = String(old_text || "").replace(/\r\n/g, "\n");
|
|
540
|
+
const newN = String(new_text || "").replace(/\r\n/g, "\n");
|
|
541
|
+
|
|
542
|
+
if (!contentN.includes(oldN)) {
|
|
543
|
+
// Give the agent concrete diagnostic info so it can self-correct.
|
|
544
|
+
const firstLine = oldN.split("\n")[0].slice(0, 80);
|
|
545
|
+
const firstLinePresent = firstLine && contentN.includes(firstLine);
|
|
546
|
+
const hints = [
|
|
547
|
+
`Error: old_text not found in ${filePath}.`,
|
|
548
|
+
`- old_text length: ${oldN.length} chars, ${oldN.split("\n").length} lines`,
|
|
549
|
+
`- file length: ${contentN.length} chars`,
|
|
550
|
+
`- first line of old_text: ${JSON.stringify(firstLine)}`,
|
|
551
|
+
`- that first line exists in file: ${firstLinePresent}`,
|
|
552
|
+
];
|
|
553
|
+
if (firstLinePresent) {
|
|
554
|
+
hints.push(`- hint: the first line matches but the rest doesn't — check indentation / trailing whitespace / blank lines in your old_text`);
|
|
555
|
+
} else {
|
|
556
|
+
hints.push(`- hint: re-read the file with read_file and copy the exact section you want to replace`);
|
|
557
|
+
}
|
|
558
|
+
return hints.join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const count = contentN.split(oldN).length - 1;
|
|
562
|
+
const updatedN = all
|
|
563
|
+
? contentN.replaceAll(oldN, newN)
|
|
564
|
+
: contentN.replace(oldN, newN);
|
|
565
|
+
|
|
566
|
+
// Preserve original line-ending convention when writing back.
|
|
567
|
+
const updated = hasCRLF ? updatedN.replace(/\n/g, "\r\n") : updatedN;
|
|
568
|
+
|
|
569
|
+
await fs.writeFile(resolved, updated, "utf-8");
|
|
570
|
+
return `Replaced ${all ? count : 1} occurrence(s) in ${filePath}`;
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
async glob({ pattern, path: basePath }) {
|
|
574
|
+
checkAccess(basePath || ".");
|
|
575
|
+
const base = resolvePath(basePath || ".");
|
|
576
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", ".cache"]);
|
|
577
|
+
const MAX_FILES = 200;
|
|
578
|
+
const results = [];
|
|
579
|
+
|
|
580
|
+
async function walk(dir, depth) {
|
|
581
|
+
if (results.length >= MAX_FILES || depth > 20) return;
|
|
582
|
+
let entries;
|
|
583
|
+
try {
|
|
584
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
585
|
+
} catch { return; }
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
if (results.length >= MAX_FILES) return;
|
|
588
|
+
const full = path.join(dir, entry.name);
|
|
589
|
+
const rel = path.relative(base, full).replace(/\\/g, "/");
|
|
590
|
+
if (entry.isDirectory()) {
|
|
591
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(full, depth + 1);
|
|
592
|
+
} else if (matchGlob(rel, pattern)) {
|
|
593
|
+
results.push(rel);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
await walk(base, 0);
|
|
599
|
+
if (!results.length) return `No files matching "${pattern}" in ${base}`;
|
|
600
|
+
return `${results.length}${results.length >= MAX_FILES ? "+" : ""} files:\n${results.join("\n")}`;
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
async delete_file({ path: filePath }) {
|
|
604
|
+
checkAccess(filePath);
|
|
605
|
+
await saveCheckpoint(filePath, "delete");
|
|
606
|
+
const resolved = resolveWritePath(filePath);
|
|
607
|
+
const stat = await fs.stat(resolved);
|
|
608
|
+
if (stat.isDirectory()) {
|
|
609
|
+
await fs.rmdir(resolved);
|
|
610
|
+
return `Directory deleted: ${resolved}`;
|
|
611
|
+
}
|
|
612
|
+
await fs.unlink(resolved);
|
|
613
|
+
return `File deleted: ${resolved}`;
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// Simple glob matcher: supports *, **, ?
|
|
618
|
+
function matchGlob(filePath, pattern) {
|
|
619
|
+
// Convert glob pattern to regex
|
|
620
|
+
let regex = "";
|
|
621
|
+
let i = 0;
|
|
622
|
+
while (i < pattern.length) {
|
|
623
|
+
const ch = pattern[i];
|
|
624
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
625
|
+
// ** matches any path segment(s)
|
|
626
|
+
regex += ".*";
|
|
627
|
+
i += 2;
|
|
628
|
+
if (pattern[i] === "/") i++; // skip trailing slash after **
|
|
629
|
+
} else if (ch === "*") {
|
|
630
|
+
regex += "[^/]*";
|
|
631
|
+
i++;
|
|
632
|
+
} else if (ch === "?") {
|
|
633
|
+
regex += "[^/]";
|
|
634
|
+
i++;
|
|
635
|
+
} else if (".+^${}()|[]\\".includes(ch)) {
|
|
636
|
+
regex += "\\" + ch;
|
|
637
|
+
i++;
|
|
638
|
+
} else {
|
|
639
|
+
regex += ch;
|
|
640
|
+
i++;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return new RegExp("^" + regex + "$").test(filePath);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Stream-read specific lines from a file without loading it all into memory.
|
|
648
|
+
* Stops reading as soon as requested lines are collected — does NOT scan the
|
|
649
|
+
* entire file. Returns { lines: string[], totalLines: number|null }.
|
|
650
|
+
*/
|
|
651
|
+
function readLinesStream(filePath, startLine, count) {
|
|
652
|
+
return new Promise((resolve, reject) => {
|
|
653
|
+
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
654
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
655
|
+
const lines = [];
|
|
656
|
+
let lineNum = 0;
|
|
657
|
+
|
|
658
|
+
rl.on("line", (line) => {
|
|
659
|
+
if (lineNum >= startLine && lines.length < count) {
|
|
660
|
+
lines.push(line);
|
|
661
|
+
}
|
|
662
|
+
lineNum++;
|
|
663
|
+
// Stop immediately once we have all requested lines
|
|
664
|
+
if (lines.length >= count) {
|
|
665
|
+
rl.close();
|
|
666
|
+
stream.destroy();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
rl.on("close", () => {
|
|
671
|
+
// totalLines is null — we didn't scan the whole file
|
|
672
|
+
resolve({ lines, totalLines: null });
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
rl.on("error", reject);
|
|
676
|
+
stream.on("error", (err) => {
|
|
677
|
+
// stream.destroy() triggers ERR_STREAM_PREMATURE_CLOSE — that's expected
|
|
678
|
+
if (err.code === "ERR_STREAM_PREMATURE_CLOSE") return;
|
|
679
|
+
reject(err);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|