smolerclaw 1.0.0 → 1.0.2
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/dist/README.md +159 -0
- package/package.json +11 -3
- package/.github/workflows/ci.yml +0 -30
- package/.github/workflows/release.yml +0 -67
- package/bun.lock +0 -33
- package/install.ps1 +0 -119
- package/skills/business.md +0 -77
- package/skills/default.md +0 -77
- package/src/ansi.ts +0 -164
- package/src/approval.ts +0 -74
- package/src/auth.ts +0 -125
- package/src/briefing.ts +0 -52
- package/src/claude.ts +0 -267
- package/src/cli.ts +0 -137
- package/src/clipboard.ts +0 -27
- package/src/config.ts +0 -87
- package/src/context-window.ts +0 -190
- package/src/context.ts +0 -125
- package/src/decisions.ts +0 -122
- package/src/email.ts +0 -123
- package/src/errors.ts +0 -78
- package/src/export.ts +0 -82
- package/src/finance.ts +0 -148
- package/src/git.ts +0 -62
- package/src/history.ts +0 -100
- package/src/images.ts +0 -68
- package/src/index.ts +0 -1431
- package/src/investigate.ts +0 -415
- package/src/markdown.ts +0 -125
- package/src/memos.ts +0 -191
- package/src/models.ts +0 -94
- package/src/monitor.ts +0 -169
- package/src/morning.ts +0 -108
- package/src/news.ts +0 -329
- package/src/openai-provider.ts +0 -127
- package/src/people.ts +0 -472
- package/src/personas.ts +0 -99
- package/src/platform.ts +0 -84
- package/src/plugins.ts +0 -125
- package/src/pomodoro.ts +0 -169
- package/src/providers.ts +0 -70
- package/src/retry.ts +0 -108
- package/src/session.ts +0 -128
- package/src/skills.ts +0 -102
- package/src/tasks.ts +0 -418
- package/src/tokens.ts +0 -102
- package/src/tool-safety.ts +0 -100
- package/src/tools.ts +0 -1479
- package/src/tui.ts +0 -693
- package/src/types.ts +0 -55
- package/src/undo.ts +0 -83
- package/src/windows.ts +0 -299
- package/src/workflows.ts +0 -197
- package/tests/ansi.test.ts +0 -58
- package/tests/approval.test.ts +0 -43
- package/tests/briefing.test.ts +0 -10
- package/tests/cli.test.ts +0 -53
- package/tests/context-window.test.ts +0 -83
- package/tests/images.test.ts +0 -28
- package/tests/memos.test.ts +0 -116
- package/tests/models.test.ts +0 -34
- package/tests/news.test.ts +0 -13
- package/tests/path-guard.test.ts +0 -37
- package/tests/people.test.ts +0 -204
- package/tests/skills.test.ts +0 -35
- package/tests/ssrf.test.ts +0 -80
- package/tests/tasks.test.ts +0 -152
- package/tests/tokens.test.ts +0 -44
- package/tests/tool-safety.test.ts +0 -55
- package/tests/windows-security.test.ts +0 -59
- package/tests/windows.test.ts +0 -20
- package/tsconfig.json +0 -19
package/src/tools.ts
DELETED
|
@@ -1,1479 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
readdirSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
writeFileSync,
|
|
6
|
-
renameSync,
|
|
7
|
-
statSync,
|
|
8
|
-
realpathSync,
|
|
9
|
-
} from 'node:fs'
|
|
10
|
-
import { resolve, relative, join, sep, dirname } from 'node:path'
|
|
11
|
-
import { randomUUID } from 'node:crypto'
|
|
12
|
-
import type Anthropic from '@anthropic-ai/sdk'
|
|
13
|
-
import { getShell, hasRipgrep, shouldExclude, SEARCH_EXCLUDES, IS_WINDOWS } from './platform'
|
|
14
|
-
import { UndoStack } from './undo'
|
|
15
|
-
import { type Plugin, executePlugin } from './plugins'
|
|
16
|
-
import { openApp, openFile, openUrl, getRunningApps, getSystemInfo, getOutlookEvents } from './windows'
|
|
17
|
-
import { fetchNews, type NewsCategory } from './news'
|
|
18
|
-
import { addTask, completeTask, listTasks, formatTaskList, parseTime } from './tasks'
|
|
19
|
-
import { saveMemo, searchMemos, listMemos, deleteMemo, formatMemoList, formatMemoDetail } from './memos'
|
|
20
|
-
import { openEmailDraft, formatDraftPreview, type EmailDraft } from './email'
|
|
21
|
-
import { startPomodoro, stopPomodoro, pomodoroStatus } from './pomodoro'
|
|
22
|
-
import { addTransaction, getMonthSummary, getRecentTransactions } from './finance'
|
|
23
|
-
import { logDecision, searchDecisions, listDecisions, formatDecisionList, formatDecisionDetail } from './decisions'
|
|
24
|
-
import { runWorkflow, listWorkflows, formatWorkflowList } from './workflows'
|
|
25
|
-
import {
|
|
26
|
-
openInvestigation, collectEvidence, addFinding, closeInvestigation,
|
|
27
|
-
getInvestigation, listInvestigations, searchInvestigations, generateReport,
|
|
28
|
-
formatInvestigationList, formatInvestigationDetail, formatEvidenceDetail,
|
|
29
|
-
type InvestigationType, type InvestigationStatus, type EvidenceSource,
|
|
30
|
-
} from './investigate'
|
|
31
|
-
import {
|
|
32
|
-
addPerson, findPerson, listPeople, updatePerson, removePerson,
|
|
33
|
-
logInteraction, getInteractions, delegateTask, updateDelegation,
|
|
34
|
-
getDelegations, getPendingFollowUps, markFollowUpDone,
|
|
35
|
-
formatPeopleList, formatPersonDetail, formatDelegationList, formatFollowUps,
|
|
36
|
-
generatePeopleDashboard,
|
|
37
|
-
type PersonGroup, type InteractionType,
|
|
38
|
-
} from './people'
|
|
39
|
-
|
|
40
|
-
// Global undo stack shared across tool calls
|
|
41
|
-
export const undoStack = new UndoStack()
|
|
42
|
-
|
|
43
|
-
// Registered plugins (set from index.ts at startup)
|
|
44
|
-
let _plugins: Plugin[] = []
|
|
45
|
-
export function registerPlugins(plugins: Plugin[]): void {
|
|
46
|
-
_plugins = plugins
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ─── Tool Definitions ────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
export const TOOLS: Anthropic.Tool[] = [
|
|
52
|
-
{
|
|
53
|
-
name: 'read_file',
|
|
54
|
-
description:
|
|
55
|
-
'Read file contents. For large files, use offset/limit to read specific line ranges.',
|
|
56
|
-
input_schema: {
|
|
57
|
-
type: 'object' as const,
|
|
58
|
-
properties: {
|
|
59
|
-
path: { type: 'string', description: 'File path (relative or absolute)' },
|
|
60
|
-
offset: {
|
|
61
|
-
type: 'number',
|
|
62
|
-
description: 'Start reading from this line number (1-based). Optional.',
|
|
63
|
-
},
|
|
64
|
-
limit: {
|
|
65
|
-
type: 'number',
|
|
66
|
-
description: 'Max lines to read. Optional, defaults to 500.',
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
required: ['path'],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: 'write_file',
|
|
74
|
-
description: 'Create a new file or completely overwrite an existing file.',
|
|
75
|
-
input_schema: {
|
|
76
|
-
type: 'object' as const,
|
|
77
|
-
properties: {
|
|
78
|
-
path: { type: 'string', description: 'File path to write' },
|
|
79
|
-
content: { type: 'string', description: 'Full file content' },
|
|
80
|
-
},
|
|
81
|
-
required: ['path', 'content'],
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
name: 'edit_file',
|
|
86
|
-
description:
|
|
87
|
-
'Make a precise edit to a file. Finds old_text and replaces it with new_text. ' +
|
|
88
|
-
'The old_text must match exactly (including whitespace). ' +
|
|
89
|
-
'Use this instead of write_file when modifying existing files — it preserves the rest of the file.',
|
|
90
|
-
input_schema: {
|
|
91
|
-
type: 'object' as const,
|
|
92
|
-
properties: {
|
|
93
|
-
path: { type: 'string', description: 'File path to edit' },
|
|
94
|
-
old_text: {
|
|
95
|
-
type: 'string',
|
|
96
|
-
description: 'Exact text to find (must be unique in the file)',
|
|
97
|
-
},
|
|
98
|
-
new_text: { type: 'string', description: 'Replacement text' },
|
|
99
|
-
},
|
|
100
|
-
required: ['path', 'old_text', 'new_text'],
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
name: 'search_files',
|
|
105
|
-
description:
|
|
106
|
-
'Search file contents using a regex pattern (like grep). ' +
|
|
107
|
-
'Returns matching lines with file paths and line numbers.',
|
|
108
|
-
input_schema: {
|
|
109
|
-
type: 'object' as const,
|
|
110
|
-
properties: {
|
|
111
|
-
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
|
112
|
-
path: {
|
|
113
|
-
type: 'string',
|
|
114
|
-
description: 'Directory to search in. Defaults to cwd.',
|
|
115
|
-
},
|
|
116
|
-
include: {
|
|
117
|
-
type: 'string',
|
|
118
|
-
description: 'Glob pattern to filter files, e.g. "*.ts" or "*.py"',
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
required: ['pattern'],
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
name: 'find_files',
|
|
126
|
-
description:
|
|
127
|
-
'Find files by name pattern (glob). Returns matching file paths.',
|
|
128
|
-
input_schema: {
|
|
129
|
-
type: 'object' as const,
|
|
130
|
-
properties: {
|
|
131
|
-
pattern: {
|
|
132
|
-
type: 'string',
|
|
133
|
-
description: 'Glob pattern, e.g. "**/*.ts", "src/**/test*"',
|
|
134
|
-
},
|
|
135
|
-
path: { type: 'string', description: 'Base directory. Defaults to cwd.' },
|
|
136
|
-
},
|
|
137
|
-
required: ['pattern'],
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
name: 'list_directory',
|
|
142
|
-
description: 'List files and directories with type indicators and sizes.',
|
|
143
|
-
input_schema: {
|
|
144
|
-
type: 'object' as const,
|
|
145
|
-
properties: {
|
|
146
|
-
path: { type: 'string', description: 'Directory to list. Defaults to cwd.' },
|
|
147
|
-
},
|
|
148
|
-
required: [],
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
name: 'run_command',
|
|
153
|
-
description:
|
|
154
|
-
'Run a shell command. Use for: git operations, running tests, installing packages, ' +
|
|
155
|
-
'building projects, or any CLI task. Commands run in the current working directory.',
|
|
156
|
-
input_schema: {
|
|
157
|
-
type: 'object' as const,
|
|
158
|
-
properties: {
|
|
159
|
-
command: { type: 'string', description: 'Shell command to execute' },
|
|
160
|
-
timeout: {
|
|
161
|
-
type: 'number',
|
|
162
|
-
description: 'Timeout in seconds. Default 30, max 120.',
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
required: ['command'],
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
name: 'fetch_url',
|
|
170
|
-
description:
|
|
171
|
-
'Fetch the content of a URL. Use for: reading documentation, checking APIs, ' +
|
|
172
|
-
'downloading config files, or verifying endpoints. Returns the response body as text. ' +
|
|
173
|
-
'For HTML pages, returns a text-only extraction (no tags).',
|
|
174
|
-
input_schema: {
|
|
175
|
-
type: 'object' as const,
|
|
176
|
-
properties: {
|
|
177
|
-
url: { type: 'string', description: 'The URL to fetch' },
|
|
178
|
-
method: {
|
|
179
|
-
type: 'string',
|
|
180
|
-
description: 'HTTP method. Default GET.',
|
|
181
|
-
enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
|
|
182
|
-
},
|
|
183
|
-
headers: {
|
|
184
|
-
type: 'object',
|
|
185
|
-
description: 'Optional request headers as key-value pairs.',
|
|
186
|
-
},
|
|
187
|
-
body: {
|
|
188
|
-
type: 'string',
|
|
189
|
-
description: 'Optional request body (for POST/PUT/PATCH).',
|
|
190
|
-
},
|
|
191
|
-
},
|
|
192
|
-
required: ['url'],
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
]
|
|
196
|
-
|
|
197
|
-
// ─── Windows / Business Tools (added at runtime if on Windows) ──
|
|
198
|
-
|
|
199
|
-
export const WINDOWS_TOOLS: Anthropic.Tool[] = [
|
|
200
|
-
{
|
|
201
|
-
name: 'open_application',
|
|
202
|
-
description:
|
|
203
|
-
'Open a Windows application by name. Available apps: excel, word, powerpoint, outlook, ' +
|
|
204
|
-
'onenote, teams, edge, chrome, firefox, calculator, notepad, terminal, explorer, ' +
|
|
205
|
-
'vscode, cursor, paint, snip, settings, taskmanager.',
|
|
206
|
-
input_schema: {
|
|
207
|
-
type: 'object' as const,
|
|
208
|
-
properties: {
|
|
209
|
-
name: { type: 'string', description: 'App name (e.g. "excel", "outlook", "teams")' },
|
|
210
|
-
argument: { type: 'string', description: 'Optional argument (e.g. file path to open in the app)' },
|
|
211
|
-
},
|
|
212
|
-
required: ['name'],
|
|
213
|
-
},
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
name: 'open_file_default',
|
|
217
|
-
description:
|
|
218
|
-
'Open a file with its default Windows application. E.g. .xlsx opens in Excel, .pdf in the PDF reader.',
|
|
219
|
-
input_schema: {
|
|
220
|
-
type: 'object' as const,
|
|
221
|
-
properties: {
|
|
222
|
-
path: { type: 'string', description: 'File path to open' },
|
|
223
|
-
},
|
|
224
|
-
required: ['path'],
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
name: 'open_url_browser',
|
|
229
|
-
description: 'Open a URL in the default web browser.',
|
|
230
|
-
input_schema: {
|
|
231
|
-
type: 'object' as const,
|
|
232
|
-
properties: {
|
|
233
|
-
url: { type: 'string', description: 'URL to open' },
|
|
234
|
-
},
|
|
235
|
-
required: ['url'],
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
name: 'get_running_apps',
|
|
240
|
-
description: 'List currently running Windows applications with memory usage. Read-only, non-destructive.',
|
|
241
|
-
input_schema: {
|
|
242
|
-
type: 'object' as const,
|
|
243
|
-
properties: {},
|
|
244
|
-
required: [],
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
name: 'get_system_info',
|
|
249
|
-
description: 'Get Windows system resource summary: CPU, RAM, disk, uptime, battery. Read-only.',
|
|
250
|
-
input_schema: {
|
|
251
|
-
type: 'object' as const,
|
|
252
|
-
properties: {},
|
|
253
|
-
required: [],
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
{
|
|
257
|
-
name: 'get_calendar_events',
|
|
258
|
-
description: 'Get today\'s Outlook calendar events. Read-only. Returns event times and subjects.',
|
|
259
|
-
input_schema: {
|
|
260
|
-
type: 'object' as const,
|
|
261
|
-
properties: {},
|
|
262
|
-
required: [],
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
{
|
|
266
|
-
name: 'get_news',
|
|
267
|
-
description:
|
|
268
|
-
'Fetch current news headlines. Categories: business, tech, finance, brazil, world. ' +
|
|
269
|
-
'Returns headlines grouped by category with source attribution.',
|
|
270
|
-
input_schema: {
|
|
271
|
-
type: 'object' as const,
|
|
272
|
-
properties: {
|
|
273
|
-
category: {
|
|
274
|
-
type: 'string',
|
|
275
|
-
description: 'News category to filter. Omit for all categories.',
|
|
276
|
-
enum: ['business', 'tech', 'finance', 'brazil', 'world'],
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
required: [],
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
]
|
|
283
|
-
|
|
284
|
-
// ─── Task/Reminder Tools (cross-platform) ──────────────────
|
|
285
|
-
|
|
286
|
-
export const TASK_TOOLS: Anthropic.Tool[] = [
|
|
287
|
-
{
|
|
288
|
-
name: 'create_task',
|
|
289
|
-
description:
|
|
290
|
-
'Create a task or reminder for the user. If a time is provided, a notification ' +
|
|
291
|
-
'will fire at that time. Supports natural-language times like "18h", "em 30 minutos", "amanha 9h".',
|
|
292
|
-
input_schema: {
|
|
293
|
-
type: 'object' as const,
|
|
294
|
-
properties: {
|
|
295
|
-
title: { type: 'string', description: 'Task description (e.g. "buscar pao")' },
|
|
296
|
-
time: { type: 'string', description: 'When to remind. E.g. "18h", "18:30", "em 30 minutos", "amanha 9h". Optional.' },
|
|
297
|
-
},
|
|
298
|
-
required: ['title'],
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
name: 'complete_task',
|
|
303
|
-
description: 'Mark a task as done by its ID or partial title match.',
|
|
304
|
-
input_schema: {
|
|
305
|
-
type: 'object' as const,
|
|
306
|
-
properties: {
|
|
307
|
-
reference: { type: 'string', description: 'Task ID or partial title to match' },
|
|
308
|
-
},
|
|
309
|
-
required: ['reference'],
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
{
|
|
313
|
-
name: 'list_tasks',
|
|
314
|
-
description: 'List all pending tasks and reminders. Shows title, due time, and ID.',
|
|
315
|
-
input_schema: {
|
|
316
|
-
type: 'object' as const,
|
|
317
|
-
properties: {
|
|
318
|
-
show_done: { type: 'boolean', description: 'Include completed tasks. Default false.' },
|
|
319
|
-
},
|
|
320
|
-
required: [],
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
]
|
|
324
|
-
|
|
325
|
-
// ─── People Management Tools (cross-platform) ──────────────
|
|
326
|
-
|
|
327
|
-
export const PEOPLE_TOOLS: Anthropic.Tool[] = [
|
|
328
|
-
{
|
|
329
|
-
name: 'add_person',
|
|
330
|
-
description:
|
|
331
|
-
'Register a person (team member, family, or contact). ' +
|
|
332
|
-
'Groups: equipe (work team), familia (family/home), contato (other contacts).',
|
|
333
|
-
input_schema: {
|
|
334
|
-
type: 'object' as const,
|
|
335
|
-
properties: {
|
|
336
|
-
name: { type: 'string', description: 'Person name' },
|
|
337
|
-
group: { type: 'string', enum: ['equipe', 'familia', 'contato'], description: 'Group: equipe, familia, or contato' },
|
|
338
|
-
role: { type: 'string', description: 'Role or relationship (e.g. "dev frontend", "esposa", "fornecedor"). Optional.' },
|
|
339
|
-
contact: { type: 'string', description: 'Phone, email, or other contact info. Optional.' },
|
|
340
|
-
},
|
|
341
|
-
required: ['name', 'group'],
|
|
342
|
-
},
|
|
343
|
-
},
|
|
344
|
-
{
|
|
345
|
-
name: 'find_person_info',
|
|
346
|
-
description:
|
|
347
|
-
'Look up a person by name or ID. Returns their profile, recent interactions, and pending delegated tasks.',
|
|
348
|
-
input_schema: {
|
|
349
|
-
type: 'object' as const,
|
|
350
|
-
properties: {
|
|
351
|
-
name_or_id: { type: 'string', description: 'Person name (partial match) or ID' },
|
|
352
|
-
},
|
|
353
|
-
required: ['name_or_id'],
|
|
354
|
-
},
|
|
355
|
-
},
|
|
356
|
-
{
|
|
357
|
-
name: 'list_people',
|
|
358
|
-
description: 'List all registered people, optionally filtered by group.',
|
|
359
|
-
input_schema: {
|
|
360
|
-
type: 'object' as const,
|
|
361
|
-
properties: {
|
|
362
|
-
group: { type: 'string', enum: ['equipe', 'familia', 'contato'], description: 'Filter by group. Optional.' },
|
|
363
|
-
},
|
|
364
|
-
required: [],
|
|
365
|
-
},
|
|
366
|
-
},
|
|
367
|
-
{
|
|
368
|
-
name: 'log_interaction',
|
|
369
|
-
description:
|
|
370
|
-
'Log an interaction with a person. Types: conversa, email, reuniao, ligacao, mensagem, delegacao, entrega, outro. ' +
|
|
371
|
-
'Optionally set a follow-up date for a reminder.',
|
|
372
|
-
input_schema: {
|
|
373
|
-
type: 'object' as const,
|
|
374
|
-
properties: {
|
|
375
|
-
person: { type: 'string', description: 'Person name or ID' },
|
|
376
|
-
type: { type: 'string', enum: ['conversa', 'email', 'reuniao', 'ligacao', 'mensagem', 'delegacao', 'entrega', 'outro'], description: 'Interaction type' },
|
|
377
|
-
summary: { type: 'string', description: 'What was discussed or happened' },
|
|
378
|
-
follow_up: { type: 'string', description: 'When to follow up (e.g. "em 3 dias", "amanha", "25/03"). Optional.' },
|
|
379
|
-
},
|
|
380
|
-
required: ['person', 'type', 'summary'],
|
|
381
|
-
},
|
|
382
|
-
},
|
|
383
|
-
{
|
|
384
|
-
name: 'delegate_to_person',
|
|
385
|
-
description:
|
|
386
|
-
'Delegate/assign a task to a person with optional due date. ' +
|
|
387
|
-
'Use to track what you asked someone to do.',
|
|
388
|
-
input_schema: {
|
|
389
|
-
type: 'object' as const,
|
|
390
|
-
properties: {
|
|
391
|
-
person: { type: 'string', description: 'Person name or ID' },
|
|
392
|
-
task: { type: 'string', description: 'What they need to do' },
|
|
393
|
-
due_date: { type: 'string', description: 'Due date (e.g. "sexta", "em 3 dias", "28/03"). Optional.' },
|
|
394
|
-
},
|
|
395
|
-
required: ['person', 'task'],
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
{
|
|
399
|
-
name: 'update_delegation_status',
|
|
400
|
-
description: 'Update the status of a delegated task. Statuses: pendente, em_andamento, concluido.',
|
|
401
|
-
input_schema: {
|
|
402
|
-
type: 'object' as const,
|
|
403
|
-
properties: {
|
|
404
|
-
delegation_id: { type: 'string', description: 'Delegation ID' },
|
|
405
|
-
status: { type: 'string', enum: ['pendente', 'em_andamento', 'concluido'], description: 'New status' },
|
|
406
|
-
notes: { type: 'string', description: 'Optional notes about the update' },
|
|
407
|
-
},
|
|
408
|
-
required: ['delegation_id', 'status'],
|
|
409
|
-
},
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
name: 'get_people_dashboard',
|
|
413
|
-
description:
|
|
414
|
-
'Show the people management dashboard: summary of team/family/contacts, ' +
|
|
415
|
-
'overdue follow-ups, overdue delegations, and recent interactions.',
|
|
416
|
-
input_schema: {
|
|
417
|
-
type: 'object' as const,
|
|
418
|
-
properties: {},
|
|
419
|
-
required: [],
|
|
420
|
-
},
|
|
421
|
-
},
|
|
422
|
-
]
|
|
423
|
-
|
|
424
|
-
// ─── Memo Tools (cross-platform) ────────────────────────────
|
|
425
|
-
|
|
426
|
-
export const MEMO_TOOLS: Anthropic.Tool[] = [
|
|
427
|
-
{
|
|
428
|
-
name: 'save_memo',
|
|
429
|
-
description:
|
|
430
|
-
'Save a note/memo to the user\'s personal knowledge base. ' +
|
|
431
|
-
'Use #hashtags in the content to auto-tag. ' +
|
|
432
|
-
'Use when the user says "anota", "lembra disso", "salva isso", or shares important information.',
|
|
433
|
-
input_schema: {
|
|
434
|
-
type: 'object' as const,
|
|
435
|
-
properties: {
|
|
436
|
-
content: { type: 'string', description: 'The memo content. Use #tags for categorization.' },
|
|
437
|
-
tags: {
|
|
438
|
-
type: 'array',
|
|
439
|
-
items: { type: 'string' },
|
|
440
|
-
description: 'Optional additional tags (without #). Auto-extracted #tags from content are always included.',
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
required: ['content'],
|
|
444
|
-
},
|
|
445
|
-
},
|
|
446
|
-
{
|
|
447
|
-
name: 'search_memos',
|
|
448
|
-
description:
|
|
449
|
-
'Search the user\'s memos by keyword or tag. ' +
|
|
450
|
-
'Use #tag to search by tag only. Use plain text for content search. ' +
|
|
451
|
-
'Use when the user asks "o que eu anotei sobre...", "qual era aquela nota...", etc.',
|
|
452
|
-
input_schema: {
|
|
453
|
-
type: 'object' as const,
|
|
454
|
-
properties: {
|
|
455
|
-
query: { type: 'string', description: 'Search query. Use #tag for tag search, or plain text for content search.' },
|
|
456
|
-
},
|
|
457
|
-
required: ['query'],
|
|
458
|
-
},
|
|
459
|
-
},
|
|
460
|
-
]
|
|
461
|
-
|
|
462
|
-
// ─── Email Tool (cross-platform) ────────────────────────────
|
|
463
|
-
|
|
464
|
-
export const EMAIL_TOOL: Anthropic.Tool = {
|
|
465
|
-
name: 'draft_email',
|
|
466
|
-
description:
|
|
467
|
-
'Create an email draft and open it in Outlook (Windows) or the default mail client. ' +
|
|
468
|
-
'The user can review and send manually. ' +
|
|
469
|
-
'Use when the user says "escreve um email", "manda um email", "rascunho de email", etc.',
|
|
470
|
-
input_schema: {
|
|
471
|
-
type: 'object' as const,
|
|
472
|
-
properties: {
|
|
473
|
-
to: { type: 'string', description: 'Recipient email address' },
|
|
474
|
-
subject: { type: 'string', description: 'Email subject line' },
|
|
475
|
-
body: { type: 'string', description: 'Email body text' },
|
|
476
|
-
cc: { type: 'string', description: 'CC recipients (optional)' },
|
|
477
|
-
},
|
|
478
|
-
required: ['to', 'subject', 'body'],
|
|
479
|
-
},
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ─── Tier 2 Tools ───────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
export const TIER2_TOOLS: Anthropic.Tool[] = [
|
|
485
|
-
{
|
|
486
|
-
name: 'record_transaction',
|
|
487
|
-
description:
|
|
488
|
-
'Record a financial transaction (income or expense). ' +
|
|
489
|
-
'Use when user mentions spending, receiving money, or financial tracking.',
|
|
490
|
-
input_schema: {
|
|
491
|
-
type: 'object' as const,
|
|
492
|
-
properties: {
|
|
493
|
-
type: { type: 'string', enum: ['entrada', 'saida'], description: 'Transaction type: entrada (income) or saida (expense)' },
|
|
494
|
-
amount: { type: 'number', description: 'Amount in BRL (always positive)' },
|
|
495
|
-
category: { type: 'string', description: 'Category (e.g. alimentacao, transporte, salario, freelance)' },
|
|
496
|
-
description: { type: 'string', description: 'Description of the transaction' },
|
|
497
|
-
},
|
|
498
|
-
required: ['type', 'amount', 'category', 'description'],
|
|
499
|
-
},
|
|
500
|
-
},
|
|
501
|
-
{
|
|
502
|
-
name: 'financial_summary',
|
|
503
|
-
description: 'Show monthly financial summary with income, expenses, and balance by category.',
|
|
504
|
-
input_schema: {
|
|
505
|
-
type: 'object' as const,
|
|
506
|
-
properties: {},
|
|
507
|
-
required: [],
|
|
508
|
-
},
|
|
509
|
-
},
|
|
510
|
-
{
|
|
511
|
-
name: 'log_decision',
|
|
512
|
-
description:
|
|
513
|
-
'Record an important decision with context and rationale. ' +
|
|
514
|
-
'Use when the user says "decidi", "optei por", "escolhi", or discusses a major choice.',
|
|
515
|
-
input_schema: {
|
|
516
|
-
type: 'object' as const,
|
|
517
|
-
properties: {
|
|
518
|
-
title: { type: 'string', description: 'Decision title (short)' },
|
|
519
|
-
context: { type: 'string', description: 'Why this decision was needed' },
|
|
520
|
-
chosen: { type: 'string', description: 'What was decided' },
|
|
521
|
-
alternatives: { type: 'string', description: 'What was considered but rejected. Optional.' },
|
|
522
|
-
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization. Optional.' },
|
|
523
|
-
},
|
|
524
|
-
required: ['title', 'context', 'chosen'],
|
|
525
|
-
},
|
|
526
|
-
},
|
|
527
|
-
{
|
|
528
|
-
name: 'search_decisions',
|
|
529
|
-
description: 'Search past decisions by keyword or tag.',
|
|
530
|
-
input_schema: {
|
|
531
|
-
type: 'object' as const,
|
|
532
|
-
properties: {
|
|
533
|
-
query: { type: 'string', description: 'Search query' },
|
|
534
|
-
},
|
|
535
|
-
required: ['query'],
|
|
536
|
-
},
|
|
537
|
-
},
|
|
538
|
-
]
|
|
539
|
-
|
|
540
|
-
// ─── Investigation Tools ─────────────────────────────────────
|
|
541
|
-
|
|
542
|
-
export const INVESTIGATE_TOOLS: Anthropic.Tool[] = [
|
|
543
|
-
{
|
|
544
|
-
name: 'open_investigation',
|
|
545
|
-
description:
|
|
546
|
-
'Start a new investigation to systematically collect evidence. ' +
|
|
547
|
-
'Types: bug (malfunction), feature (material for building), test (test scenarios), audit (code review), incident (runtime issue). ' +
|
|
548
|
-
'Use when the user says "investiga", "analisa", "diagnostica", "verifica", or needs structured evidence collection.',
|
|
549
|
-
input_schema: {
|
|
550
|
-
type: 'object' as const,
|
|
551
|
-
properties: {
|
|
552
|
-
title: { type: 'string', description: 'Investigation title (short, descriptive)' },
|
|
553
|
-
type: { type: 'string', enum: ['bug', 'feature', 'test', 'audit', 'incident'], description: 'Investigation type' },
|
|
554
|
-
hypothesis: { type: 'string', description: 'Initial theory or goal to investigate. Optional.' },
|
|
555
|
-
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization. Optional.' },
|
|
556
|
-
},
|
|
557
|
-
required: ['title', 'type'],
|
|
558
|
-
},
|
|
559
|
-
},
|
|
560
|
-
{
|
|
561
|
-
name: 'collect_evidence',
|
|
562
|
-
description:
|
|
563
|
-
'Add a piece of evidence to an active investigation. ' +
|
|
564
|
-
'Sources: file (file content), command (command output), log (log entries), diff (code changes), url (web content), observation (manual note). ' +
|
|
565
|
-
'Use after reading files, running commands, or observing behavior to build the investigation record.',
|
|
566
|
-
input_schema: {
|
|
567
|
-
type: 'object' as const,
|
|
568
|
-
properties: {
|
|
569
|
-
investigation: { type: 'string', description: 'Investigation ID or title (partial match)' },
|
|
570
|
-
source: { type: 'string', enum: ['file', 'command', 'log', 'diff', 'url', 'observation'], description: 'Evidence source type' },
|
|
571
|
-
label: { type: 'string', description: 'Short description of this evidence' },
|
|
572
|
-
content: { type: 'string', description: 'The evidence data (file content, command output, observation text, etc.)' },
|
|
573
|
-
path: { type: 'string', description: 'File path or URL associated with this evidence. Optional.' },
|
|
574
|
-
},
|
|
575
|
-
required: ['investigation', 'source', 'label', 'content'],
|
|
576
|
-
},
|
|
577
|
-
},
|
|
578
|
-
{
|
|
579
|
-
name: 'add_finding',
|
|
580
|
-
description:
|
|
581
|
-
'Record a conclusion or insight derived from collected evidence. ' +
|
|
582
|
-
'Severity: critical, high, medium, low, info. ' +
|
|
583
|
-
'Link to evidence IDs that support this finding.',
|
|
584
|
-
input_schema: {
|
|
585
|
-
type: 'object' as const,
|
|
586
|
-
properties: {
|
|
587
|
-
investigation: { type: 'string', description: 'Investigation ID or title' },
|
|
588
|
-
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Finding severity' },
|
|
589
|
-
title: { type: 'string', description: 'Finding title (short)' },
|
|
590
|
-
description: { type: 'string', description: 'Detailed description of the finding' },
|
|
591
|
-
evidence_ids: { type: 'array', items: { type: 'string' }, description: 'IDs of evidence supporting this finding. Optional.' },
|
|
592
|
-
},
|
|
593
|
-
required: ['investigation', 'severity', 'title', 'description'],
|
|
594
|
-
},
|
|
595
|
-
},
|
|
596
|
-
{
|
|
597
|
-
name: 'close_investigation',
|
|
598
|
-
description:
|
|
599
|
-
'Close an investigation with a summary and recommendations. ' +
|
|
600
|
-
'Use after all evidence is collected and findings are recorded.',
|
|
601
|
-
input_schema: {
|
|
602
|
-
type: 'object' as const,
|
|
603
|
-
properties: {
|
|
604
|
-
investigation: { type: 'string', description: 'Investigation ID or title' },
|
|
605
|
-
summary: { type: 'string', description: 'Final summary of the investigation' },
|
|
606
|
-
recommendations: { type: 'string', description: 'Action items and next steps. Optional.' },
|
|
607
|
-
},
|
|
608
|
-
required: ['investigation', 'summary'],
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
{
|
|
612
|
-
name: 'investigation_status',
|
|
613
|
-
description:
|
|
614
|
-
'View the current state of an investigation: evidence collected, findings, and progress. ' +
|
|
615
|
-
'Use to check progress or review before closing.',
|
|
616
|
-
input_schema: {
|
|
617
|
-
type: 'object' as const,
|
|
618
|
-
properties: {
|
|
619
|
-
investigation: { type: 'string', description: 'Investigation ID or title' },
|
|
620
|
-
},
|
|
621
|
-
required: ['investigation'],
|
|
622
|
-
},
|
|
623
|
-
},
|
|
624
|
-
{
|
|
625
|
-
name: 'investigation_report',
|
|
626
|
-
description:
|
|
627
|
-
'Generate a full structured report (markdown) for an investigation. ' +
|
|
628
|
-
'Includes all evidence, findings, summary, and recommendations.',
|
|
629
|
-
input_schema: {
|
|
630
|
-
type: 'object' as const,
|
|
631
|
-
properties: {
|
|
632
|
-
investigation: { type: 'string', description: 'Investigation ID or title' },
|
|
633
|
-
},
|
|
634
|
-
required: ['investigation'],
|
|
635
|
-
},
|
|
636
|
-
},
|
|
637
|
-
{
|
|
638
|
-
name: 'list_investigations',
|
|
639
|
-
description:
|
|
640
|
-
'List all investigations, optionally filtered by status or type.',
|
|
641
|
-
input_schema: {
|
|
642
|
-
type: 'object' as const,
|
|
643
|
-
properties: {
|
|
644
|
-
status: { type: 'string', enum: ['aberta', 'em_andamento', 'concluida', 'arquivada'], description: 'Filter by status. Optional.' },
|
|
645
|
-
type: { type: 'string', enum: ['bug', 'feature', 'test', 'audit', 'incident'], description: 'Filter by type. Optional.' },
|
|
646
|
-
query: { type: 'string', description: 'Search by keyword. Optional.' },
|
|
647
|
-
},
|
|
648
|
-
required: [],
|
|
649
|
-
},
|
|
650
|
-
},
|
|
651
|
-
]
|
|
652
|
-
|
|
653
|
-
/** get_news tool definition (cross-platform, extracted for reference by name) */
|
|
654
|
-
const NEWS_TOOL = WINDOWS_TOOLS.find((t) => t.name === 'get_news')!
|
|
655
|
-
|
|
656
|
-
let _windowsToolsRegistered = false
|
|
657
|
-
|
|
658
|
-
/** Register Windows tools and task tools. Idempotent. */
|
|
659
|
-
export function registerWindowsTools(): void {
|
|
660
|
-
if (_windowsToolsRegistered) return
|
|
661
|
-
_windowsToolsRegistered = true
|
|
662
|
-
|
|
663
|
-
if (IS_WINDOWS) {
|
|
664
|
-
TOOLS.push(...WINDOWS_TOOLS)
|
|
665
|
-
} else {
|
|
666
|
-
// Add get_news on all platforms (it's network-only)
|
|
667
|
-
TOOLS.push(NEWS_TOOL)
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Task, people, memo, and email tools are cross-platform
|
|
671
|
-
TOOLS.push(...TASK_TOOLS)
|
|
672
|
-
TOOLS.push(...PEOPLE_TOOLS)
|
|
673
|
-
TOOLS.push(...MEMO_TOOLS)
|
|
674
|
-
TOOLS.push(EMAIL_TOOL)
|
|
675
|
-
TOOLS.push(...TIER2_TOOLS)
|
|
676
|
-
TOOLS.push(...INVESTIGATE_TOOLS)
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// ─── Tool Execution ──────────────────────────────────────────
|
|
680
|
-
|
|
681
|
-
const MAX_OUTPUT = 50_000
|
|
682
|
-
|
|
683
|
-
export async function executeTool(
|
|
684
|
-
name: string,
|
|
685
|
-
input: Record<string, unknown>,
|
|
686
|
-
): Promise<string> {
|
|
687
|
-
try {
|
|
688
|
-
switch (name) {
|
|
689
|
-
case 'read_file':
|
|
690
|
-
return toolReadFile(input)
|
|
691
|
-
case 'write_file':
|
|
692
|
-
return toolWriteFile(input)
|
|
693
|
-
case 'edit_file':
|
|
694
|
-
return toolEditFile(input)
|
|
695
|
-
case 'search_files':
|
|
696
|
-
return await toolSearchFiles(input)
|
|
697
|
-
case 'find_files':
|
|
698
|
-
return await toolFindFiles(input)
|
|
699
|
-
case 'list_directory':
|
|
700
|
-
return toolListDirectory(input)
|
|
701
|
-
case 'run_command':
|
|
702
|
-
return await toolRunCommand(input)
|
|
703
|
-
case 'fetch_url':
|
|
704
|
-
return await toolFetchUrl(input)
|
|
705
|
-
// Windows / business tools
|
|
706
|
-
case 'open_application':
|
|
707
|
-
return await openApp(input.name as string, input.argument as string | undefined)
|
|
708
|
-
case 'open_file_default':
|
|
709
|
-
return await openFile(input.path as string)
|
|
710
|
-
case 'open_url_browser':
|
|
711
|
-
return await openUrl(input.url as string)
|
|
712
|
-
case 'get_running_apps':
|
|
713
|
-
return await getRunningApps()
|
|
714
|
-
case 'get_system_info':
|
|
715
|
-
return await getSystemInfo()
|
|
716
|
-
case 'get_calendar_events':
|
|
717
|
-
return await getOutlookEvents()
|
|
718
|
-
case 'get_news': {
|
|
719
|
-
const cat = input.category as NewsCategory | undefined
|
|
720
|
-
return await fetchNews(cat ? [cat] : undefined)
|
|
721
|
-
}
|
|
722
|
-
// Task/reminder tools
|
|
723
|
-
case 'create_task': {
|
|
724
|
-
const title = input.title as string
|
|
725
|
-
if (!title?.trim()) return 'Error: title is required.'
|
|
726
|
-
const timeStr = input.time as string | undefined
|
|
727
|
-
const dueTime = timeStr ? parseTime(timeStr) : undefined
|
|
728
|
-
const task = addTask(title, dueTime || undefined)
|
|
729
|
-
const dueInfo = dueTime
|
|
730
|
-
? ` — lembrete: ${dueTime.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`
|
|
731
|
-
: ''
|
|
732
|
-
return `Tarefa criada: "${task.title}"${dueInfo} [${task.id}]`
|
|
733
|
-
}
|
|
734
|
-
case 'complete_task': {
|
|
735
|
-
const ref = input.reference as string
|
|
736
|
-
if (!ref?.trim()) return 'Error: reference is required.'
|
|
737
|
-
const task = completeTask(ref)
|
|
738
|
-
return task ? `Concluida: "${task.title}"` : `Tarefa nao encontrada: "${ref}"`
|
|
739
|
-
}
|
|
740
|
-
case 'list_tasks': {
|
|
741
|
-
const showDone = (input.show_done as boolean) || false
|
|
742
|
-
const tasks = listTasks(showDone)
|
|
743
|
-
return formatTaskList(tasks)
|
|
744
|
-
}
|
|
745
|
-
// People management tools
|
|
746
|
-
case 'add_person': {
|
|
747
|
-
const name = input.name as string
|
|
748
|
-
if (!name?.trim()) return 'Error: name is required.'
|
|
749
|
-
const group = input.group as PersonGroup
|
|
750
|
-
const validGroups: PersonGroup[] = ['equipe', 'familia', 'contato']
|
|
751
|
-
if (!validGroups.includes(group)) return 'Error: group must be equipe, familia, or contato.'
|
|
752
|
-
const person = addPerson(name, group, input.role as string, input.contact as string)
|
|
753
|
-
return `Pessoa adicionada: ${person.name} (${group}) [${person.id}]`
|
|
754
|
-
}
|
|
755
|
-
case 'find_person_info': {
|
|
756
|
-
const ref = input.name_or_id as string
|
|
757
|
-
if (!ref?.trim()) return 'Error: name_or_id is required.'
|
|
758
|
-
const person = findPerson(ref)
|
|
759
|
-
if (!person) return `Pessoa nao encontrada: "${ref}"`
|
|
760
|
-
return formatPersonDetail(person)
|
|
761
|
-
}
|
|
762
|
-
case 'list_people': {
|
|
763
|
-
const group = input.group as PersonGroup | undefined
|
|
764
|
-
const people = listPeople(group)
|
|
765
|
-
return formatPeopleList(people)
|
|
766
|
-
}
|
|
767
|
-
case 'log_interaction': {
|
|
768
|
-
const personRef = input.person as string
|
|
769
|
-
if (!personRef?.trim()) return 'Error: person is required.'
|
|
770
|
-
const type = input.type as InteractionType
|
|
771
|
-
const summary = input.summary as string
|
|
772
|
-
if (!summary?.trim()) return 'Error: summary is required.'
|
|
773
|
-
const followUpStr = input.follow_up as string | undefined
|
|
774
|
-
const followUpDate = followUpStr ? parseFuzzyDate(followUpStr) : undefined
|
|
775
|
-
const interaction = logInteraction(personRef, type, summary, followUpDate || undefined)
|
|
776
|
-
if (!interaction) return `Pessoa nao encontrada: "${personRef}"`
|
|
777
|
-
const fuMsg = followUpDate ? ` — follow-up: ${followUpDate.toLocaleDateString('pt-BR')}` : ''
|
|
778
|
-
return `Interacao registrada: ${type} com ${personRef}${fuMsg}`
|
|
779
|
-
}
|
|
780
|
-
case 'delegate_to_person': {
|
|
781
|
-
const personRef = input.person as string
|
|
782
|
-
if (!personRef?.trim()) return 'Error: person is required.'
|
|
783
|
-
const task = input.task as string
|
|
784
|
-
if (!task?.trim()) return 'Error: task is required.'
|
|
785
|
-
const dueDateStr = input.due_date as string | undefined
|
|
786
|
-
const dueDate = dueDateStr ? parseFuzzyDate(dueDateStr) : undefined
|
|
787
|
-
const delegation = delegateTask(personRef, task, dueDate || undefined)
|
|
788
|
-
if (!delegation) return `Pessoa nao encontrada: "${personRef}"`
|
|
789
|
-
const dueMsg = dueDate ? ` — prazo: ${dueDate.toLocaleDateString('pt-BR')}` : ''
|
|
790
|
-
return `Tarefa delegada para ${personRef}: "${task}"${dueMsg} [${delegation.id}]`
|
|
791
|
-
}
|
|
792
|
-
case 'update_delegation_status': {
|
|
793
|
-
const id = input.delegation_id as string
|
|
794
|
-
if (!id?.trim()) return 'Error: delegation_id is required.'
|
|
795
|
-
const status = input.status as 'pendente' | 'em_andamento' | 'concluido'
|
|
796
|
-
const result = updateDelegation(id, status, input.notes as string)
|
|
797
|
-
if (!result) return `Delegacao nao encontrada: "${id}"`
|
|
798
|
-
return `Delegacao atualizada: "${result.task}" -> ${status}`
|
|
799
|
-
}
|
|
800
|
-
case 'get_people_dashboard':
|
|
801
|
-
return generatePeopleDashboard()
|
|
802
|
-
// Memo tools
|
|
803
|
-
case 'save_memo': {
|
|
804
|
-
const content = input.content as string
|
|
805
|
-
if (!content?.trim()) return 'Error: content is required.'
|
|
806
|
-
const tags = (input.tags as string[]) || []
|
|
807
|
-
const memo = saveMemo(content, tags)
|
|
808
|
-
const tagStr = memo.tags.length > 0 ? ` [${memo.tags.map((t) => '#' + t).join(' ')}]` : ''
|
|
809
|
-
return `Memo salvo${tagStr} {${memo.id}}`
|
|
810
|
-
}
|
|
811
|
-
case 'search_memos': {
|
|
812
|
-
const query = input.query as string
|
|
813
|
-
if (!query?.trim()) return formatMemoList(listMemos())
|
|
814
|
-
const results = searchMemos(query)
|
|
815
|
-
return formatMemoList(results)
|
|
816
|
-
}
|
|
817
|
-
// Email tool
|
|
818
|
-
// Finance tools
|
|
819
|
-
case 'record_transaction': {
|
|
820
|
-
const type = input.type as 'entrada' | 'saida'
|
|
821
|
-
const amount = input.amount as number
|
|
822
|
-
const category = input.category as string
|
|
823
|
-
const description = input.description as string
|
|
824
|
-
if (!type || !amount || !category || !description) return 'Error: all fields required.'
|
|
825
|
-
const tx = addTransaction(type, amount, category, description)
|
|
826
|
-
const sign = tx.type === 'entrada' ? '+' : '-'
|
|
827
|
-
return `${sign} R$ ${tx.amount.toFixed(2)} (${tx.category}) — ${tx.description} [${tx.id}]`
|
|
828
|
-
}
|
|
829
|
-
case 'financial_summary':
|
|
830
|
-
return getMonthSummary()
|
|
831
|
-
// Decision tools
|
|
832
|
-
case 'log_decision': {
|
|
833
|
-
const title = input.title as string
|
|
834
|
-
const context = input.context as string
|
|
835
|
-
const chosen = input.chosen as string
|
|
836
|
-
if (!title || !context || !chosen) return 'Error: title, context, and chosen are required.'
|
|
837
|
-
const d = logDecision(title, context, chosen, input.alternatives as string, (input.tags as string[]) || [])
|
|
838
|
-
return `Decisao registrada: "${d.title}" {${d.id}}`
|
|
839
|
-
}
|
|
840
|
-
case 'search_decisions': {
|
|
841
|
-
const query = input.query as string
|
|
842
|
-
if (!query?.trim()) return formatDecisionList(listDecisions())
|
|
843
|
-
return formatDecisionList(searchDecisions(query))
|
|
844
|
-
}
|
|
845
|
-
// Investigation tools
|
|
846
|
-
case 'open_investigation': {
|
|
847
|
-
const title = input.title as string
|
|
848
|
-
if (!title?.trim()) return 'Error: title is required.'
|
|
849
|
-
const type = input.type as InvestigationType
|
|
850
|
-
const validTypes: InvestigationType[] = ['bug', 'feature', 'test', 'audit', 'incident']
|
|
851
|
-
if (!validTypes.includes(type)) return 'Error: type must be bug, feature, test, audit, or incident.'
|
|
852
|
-
const inv = openInvestigation(title, type, input.hypothesis as string, (input.tags as string[]) || [])
|
|
853
|
-
return `Investigacao aberta: "${inv.title}" (${inv.type}) {${inv.id}}`
|
|
854
|
-
}
|
|
855
|
-
case 'collect_evidence': {
|
|
856
|
-
const ref = input.investigation as string
|
|
857
|
-
if (!ref?.trim()) return 'Error: investigation is required.'
|
|
858
|
-
const source = input.source as EvidenceSource
|
|
859
|
-
const label = input.label as string
|
|
860
|
-
const content = input.content as string
|
|
861
|
-
if (!label?.trim() || !content?.trim()) return 'Error: label and content are required.'
|
|
862
|
-
const ev = collectEvidence(ref, source, label, content, input.path as string)
|
|
863
|
-
if (!ev) return `Investigacao nao encontrada: "${ref}"`
|
|
864
|
-
return `Evidencia coletada: [${ev.id}] ${ev.source}: ${ev.label}`
|
|
865
|
-
}
|
|
866
|
-
case 'add_finding': {
|
|
867
|
-
const ref = input.investigation as string
|
|
868
|
-
if (!ref?.trim()) return 'Error: investigation is required.'
|
|
869
|
-
const severity = input.severity as 'critical' | 'high' | 'medium' | 'low' | 'info'
|
|
870
|
-
const title = input.title as string
|
|
871
|
-
const description = input.description as string
|
|
872
|
-
if (!title?.trim() || !description?.trim()) return 'Error: title and description are required.'
|
|
873
|
-
const evidenceIds = (input.evidence_ids as string[]) || []
|
|
874
|
-
const finding = addFinding(ref, severity, title, description, evidenceIds)
|
|
875
|
-
if (!finding) return `Investigacao nao encontrada: "${ref}"`
|
|
876
|
-
return `Conclusao registrada: [${finding.severity.toUpperCase()}] ${finding.title} {${finding.id}}`
|
|
877
|
-
}
|
|
878
|
-
case 'close_investigation': {
|
|
879
|
-
const ref = input.investigation as string
|
|
880
|
-
if (!ref?.trim()) return 'Error: investigation is required.'
|
|
881
|
-
const summary = input.summary as string
|
|
882
|
-
if (!summary?.trim()) return 'Error: summary is required.'
|
|
883
|
-
const inv = closeInvestigation(ref, summary, input.recommendations as string)
|
|
884
|
-
if (!inv) return `Investigacao nao encontrada: "${ref}"`
|
|
885
|
-
return `Investigacao concluida: "${inv.title}" — ${inv.evidence.length} evidencias, ${inv.findings.length} conclusoes`
|
|
886
|
-
}
|
|
887
|
-
case 'investigation_status': {
|
|
888
|
-
const ref = input.investigation as string
|
|
889
|
-
if (!ref?.trim()) return 'Error: investigation is required.'
|
|
890
|
-
const inv = getInvestigation(ref)
|
|
891
|
-
if (!inv) return `Investigacao nao encontrada: "${ref}"`
|
|
892
|
-
return formatInvestigationDetail(inv)
|
|
893
|
-
}
|
|
894
|
-
case 'investigation_report': {
|
|
895
|
-
const ref = input.investigation as string
|
|
896
|
-
if (!ref?.trim()) return 'Error: investigation is required.'
|
|
897
|
-
const report = generateReport(ref)
|
|
898
|
-
if (!report) return `Investigacao nao encontrada: "${ref}"`
|
|
899
|
-
return report
|
|
900
|
-
}
|
|
901
|
-
case 'list_investigations': {
|
|
902
|
-
const query = input.query as string | undefined
|
|
903
|
-
if (query?.trim()) return formatInvestigationList(searchInvestigations(query))
|
|
904
|
-
const status = input.status as InvestigationStatus | undefined
|
|
905
|
-
const type = input.type as InvestigationType | undefined
|
|
906
|
-
return formatInvestigationList(listInvestigations(status, type))
|
|
907
|
-
}
|
|
908
|
-
// Email tool
|
|
909
|
-
case 'draft_email': {
|
|
910
|
-
const to = input.to as string
|
|
911
|
-
const subject = input.subject as string
|
|
912
|
-
const body = input.body as string
|
|
913
|
-
if (!to?.trim() || !subject?.trim() || !body?.trim()) {
|
|
914
|
-
return 'Error: to, subject, and body are required.'
|
|
915
|
-
}
|
|
916
|
-
const draft: EmailDraft = { to, subject, body, cc: input.cc as string }
|
|
917
|
-
const preview = formatDraftPreview(draft)
|
|
918
|
-
const result = await openEmailDraft(draft)
|
|
919
|
-
return `${preview}\n\n${result}`
|
|
920
|
-
}
|
|
921
|
-
default: {
|
|
922
|
-
// Check plugins
|
|
923
|
-
const plugin = _plugins.find((p) => p.name === name)
|
|
924
|
-
if (plugin) return await executePlugin(plugin, input)
|
|
925
|
-
return `Error: unknown tool "${name}"`
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
} catch (err) {
|
|
929
|
-
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// ─── Security ───────────────────────────────────────────────
|
|
934
|
-
|
|
935
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Atomic write: write to temp file then rename.
|
|
939
|
-
* Prevents corruption from crash/power loss mid-write.
|
|
940
|
-
*/
|
|
941
|
-
function atomicWrite(filePath: string, content: string): void {
|
|
942
|
-
const tmp = join(dirname(filePath), `.smolerclaw-${randomUUID().slice(0, 8)}.tmp`)
|
|
943
|
-
writeFileSync(tmp, content)
|
|
944
|
-
renameSync(tmp, filePath)
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function guardPath(filePath: string): string | null {
|
|
948
|
-
const resolved = resolve(filePath)
|
|
949
|
-
const cwd = process.cwd()
|
|
950
|
-
if (resolved !== cwd && !resolved.startsWith(cwd + sep)) {
|
|
951
|
-
return `Error: path outside working directory is not permitted: ${resolved}`
|
|
952
|
-
}
|
|
953
|
-
// Follow symlinks and re-check containment
|
|
954
|
-
try {
|
|
955
|
-
if (existsSync(resolved)) {
|
|
956
|
-
const real = realpathSync(resolved)
|
|
957
|
-
if (real !== cwd && !real.startsWith(cwd + sep)) {
|
|
958
|
-
return `Error: symlink target is outside working directory: ${real}`
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
} catch {
|
|
962
|
-
// File doesn't exist yet (write_file creating new file) — that's OK
|
|
963
|
-
}
|
|
964
|
-
return null
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/** Validate that a required string input is present and non-empty */
|
|
968
|
-
function requireString(input: Record<string, unknown>, key: string): string | null {
|
|
969
|
-
const val = input[key]
|
|
970
|
-
if (typeof val !== 'string' || val.trim().length === 0) {
|
|
971
|
-
return `Error: '${key}' is required and must be a non-empty string.`
|
|
972
|
-
}
|
|
973
|
-
return null
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// ─── Implementations ─────────────────────────────────────────
|
|
977
|
-
|
|
978
|
-
function toolReadFile(input: Record<string, unknown>): string {
|
|
979
|
-
const pathValErr = requireString(input, 'path')
|
|
980
|
-
if (pathValErr) return pathValErr
|
|
981
|
-
const path = resolve(input.path as string)
|
|
982
|
-
const pathErr = guardPath(path)
|
|
983
|
-
if (pathErr) return pathErr
|
|
984
|
-
if (!existsSync(path)) return `Error: file not found: ${path}`
|
|
985
|
-
|
|
986
|
-
// Check file size before reading
|
|
987
|
-
const size = statSync(path).size
|
|
988
|
-
if (size > MAX_FILE_SIZE) {
|
|
989
|
-
return `Error: file too large (${formatSize(size)}). Max is ${formatSize(MAX_FILE_SIZE)}.`
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
const content = readFileSync(path, 'utf-8')
|
|
993
|
-
const lines = content.split('\n')
|
|
994
|
-
const offset = Math.max(1, (input.offset as number) || 1)
|
|
995
|
-
const limit = Math.min(2000, (input.limit as number) || 500)
|
|
996
|
-
|
|
997
|
-
const slice = lines.slice(offset - 1, offset - 1 + limit)
|
|
998
|
-
const numbered = slice.map((l, i) => `${String(offset + i).padStart(4)} ${l}`)
|
|
999
|
-
|
|
1000
|
-
let result = numbered.join('\n')
|
|
1001
|
-
const remaining = lines.length - (offset - 1 + limit)
|
|
1002
|
-
if (remaining > 0) {
|
|
1003
|
-
result += `\n... (${remaining} more lines, total ${lines.length})`
|
|
1004
|
-
}
|
|
1005
|
-
return truncate(result)
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function toolWriteFile(input: Record<string, unknown>): string {
|
|
1009
|
-
const pathValErr = requireString(input, 'path')
|
|
1010
|
-
if (pathValErr) return pathValErr
|
|
1011
|
-
const path = resolve(input.path as string)
|
|
1012
|
-
const pathErr = guardPath(path)
|
|
1013
|
-
if (pathErr) return pathErr
|
|
1014
|
-
const content = input.content as string
|
|
1015
|
-
const existed = existsSync(path)
|
|
1016
|
-
undoStack.saveState(path)
|
|
1017
|
-
atomicWrite(path, content)
|
|
1018
|
-
const lines = content.split('\n').length
|
|
1019
|
-
return `${existed ? 'Updated' : 'Created'}: ${path} (${lines} lines)`
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function toolEditFile(input: Record<string, unknown>): string {
|
|
1023
|
-
const pathValErr = requireString(input, 'path')
|
|
1024
|
-
if (pathValErr) return pathValErr
|
|
1025
|
-
const path = resolve(input.path as string)
|
|
1026
|
-
const pathErr = guardPath(path)
|
|
1027
|
-
if (pathErr) return pathErr
|
|
1028
|
-
if (!existsSync(path)) return `Error: file not found: ${path}`
|
|
1029
|
-
|
|
1030
|
-
const content = readFileSync(path, 'utf-8')
|
|
1031
|
-
const oldText = input.old_text as string
|
|
1032
|
-
const newText = input.new_text as string
|
|
1033
|
-
|
|
1034
|
-
const count = content.split(oldText).length - 1
|
|
1035
|
-
if (count === 0) {
|
|
1036
|
-
return 'Error: old_text not found in file. Make sure it matches exactly, including whitespace and indentation.'
|
|
1037
|
-
}
|
|
1038
|
-
if (count > 1) {
|
|
1039
|
-
return `Error: old_text found ${count} times. It must be unique. Include more surrounding context.`
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
undoStack.saveState(path)
|
|
1043
|
-
// Use split/join instead of String.replace to avoid $& back-reference issues
|
|
1044
|
-
const updated = content.split(oldText).join(newText)
|
|
1045
|
-
atomicWrite(path, updated)
|
|
1046
|
-
|
|
1047
|
-
const oldLines = oldText.split('\n').length
|
|
1048
|
-
const newLines = newText.split('\n').length
|
|
1049
|
-
return `Edited: ${path} (replaced ${oldLines} lines with ${newLines} lines)`
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
// ─── search_files: ripgrep → pure-Bun fallback ─────────────
|
|
1053
|
-
|
|
1054
|
-
async function toolSearchFiles(input: Record<string, unknown>): Promise<string> {
|
|
1055
|
-
const patternErr = requireString(input, 'pattern')
|
|
1056
|
-
if (patternErr) return patternErr
|
|
1057
|
-
const pattern = input.pattern as string
|
|
1058
|
-
const dir = resolve((input.path as string) || '.')
|
|
1059
|
-
const pathErr = guardPath(dir)
|
|
1060
|
-
if (pathErr) return pathErr
|
|
1061
|
-
const include = input.include as string | undefined
|
|
1062
|
-
|
|
1063
|
-
if (await hasRipgrep()) {
|
|
1064
|
-
return searchWithRipgrep(pattern, dir, include)
|
|
1065
|
-
}
|
|
1066
|
-
return searchWithBun(pattern, dir, include)
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
async function searchWithRipgrep(
|
|
1070
|
-
pattern: string,
|
|
1071
|
-
dir: string,
|
|
1072
|
-
include?: string,
|
|
1073
|
-
): Promise<string> {
|
|
1074
|
-
const args = ['rg', '--no-heading', '--line-number', '--color=never']
|
|
1075
|
-
if (include) args.push('--glob', include)
|
|
1076
|
-
for (const ex of SEARCH_EXCLUDES) {
|
|
1077
|
-
args.push('--glob', `!${ex}`)
|
|
1078
|
-
}
|
|
1079
|
-
args.push('-e', pattern, dir)
|
|
1080
|
-
|
|
1081
|
-
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' })
|
|
1082
|
-
const stdout = await new Response(proc.stdout).text()
|
|
1083
|
-
const stderr = await new Response(proc.stderr).text()
|
|
1084
|
-
await proc.exited
|
|
1085
|
-
|
|
1086
|
-
if (!stdout.trim() && !stderr.trim()) return 'No matches found.'
|
|
1087
|
-
if (stderr.trim() && !stdout.trim()) return `Error: ${stderr.trim()}`
|
|
1088
|
-
|
|
1089
|
-
return formatSearchResults(stdout, dir)
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
async function searchWithBun(
|
|
1093
|
-
pattern: string,
|
|
1094
|
-
dir: string,
|
|
1095
|
-
include?: string,
|
|
1096
|
-
): Promise<string> {
|
|
1097
|
-
let regex: RegExp
|
|
1098
|
-
try {
|
|
1099
|
-
regex = new RegExp(pattern)
|
|
1100
|
-
} catch (err) {
|
|
1101
|
-
return `Error: invalid regex pattern: ${err instanceof Error ? err.message : pattern}`
|
|
1102
|
-
}
|
|
1103
|
-
const fileGlob = include || '**/*'
|
|
1104
|
-
const glob = new Bun.Glob(fileGlob)
|
|
1105
|
-
const results: string[] = []
|
|
1106
|
-
let fileCount = 0
|
|
1107
|
-
const MAX_FILES = 5000
|
|
1108
|
-
|
|
1109
|
-
for await (const entry of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
1110
|
-
if (shouldExclude(entry)) continue
|
|
1111
|
-
if (++fileCount > MAX_FILES) {
|
|
1112
|
-
results.push(`... (stopped after scanning ${MAX_FILES} files)`)
|
|
1113
|
-
break
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
const fullPath = join(dir, entry)
|
|
1117
|
-
try {
|
|
1118
|
-
const content = readFileSync(fullPath, 'utf-8')
|
|
1119
|
-
const lines = content.split('\n')
|
|
1120
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1121
|
-
if (regex.test(lines[i])) {
|
|
1122
|
-
results.push(`${entry}:${i + 1}:${lines[i]}`)
|
|
1123
|
-
if (results.length >= 100) break
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
} catch {
|
|
1127
|
-
// Skip binary or unreadable files
|
|
1128
|
-
}
|
|
1129
|
-
if (results.length >= 100) break
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
if (results.length === 0) return 'No matches found.'
|
|
1133
|
-
|
|
1134
|
-
let result = results.slice(0, 100).join('\n')
|
|
1135
|
-
if (results.length > 100) {
|
|
1136
|
-
result += `\n... (showing first 100 matches)`
|
|
1137
|
-
}
|
|
1138
|
-
return truncate(result)
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// ─── find_files: Bun.Glob (cross-platform) ─────────────────
|
|
1142
|
-
|
|
1143
|
-
async function toolFindFiles(input: Record<string, unknown>): Promise<string> {
|
|
1144
|
-
const patternErr = requireString(input, 'pattern')
|
|
1145
|
-
if (patternErr) return patternErr
|
|
1146
|
-
const pattern = input.pattern as string
|
|
1147
|
-
const dir = resolve((input.path as string) || '.')
|
|
1148
|
-
const pathErr = guardPath(dir)
|
|
1149
|
-
if (pathErr) return pathErr
|
|
1150
|
-
|
|
1151
|
-
const glob = new Bun.Glob(pattern)
|
|
1152
|
-
const matches: string[] = []
|
|
1153
|
-
|
|
1154
|
-
for await (const entry of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
1155
|
-
if (shouldExclude(entry)) continue
|
|
1156
|
-
matches.push(entry)
|
|
1157
|
-
if (matches.length >= 200) break
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
if (matches.length === 0) return 'No files found.'
|
|
1161
|
-
|
|
1162
|
-
let result = matches.join('\n')
|
|
1163
|
-
if (matches.length >= 200) {
|
|
1164
|
-
result += '\n... (showing first 200 files)'
|
|
1165
|
-
}
|
|
1166
|
-
return result
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// ─── list_directory ─────────────────────────────────────────
|
|
1170
|
-
|
|
1171
|
-
function toolListDirectory(input: Record<string, unknown>): string {
|
|
1172
|
-
const dir = resolve((input.path as string) || '.')
|
|
1173
|
-
const pathErr = guardPath(dir)
|
|
1174
|
-
if (pathErr) return pathErr
|
|
1175
|
-
if (!existsSync(dir)) return `Error: not found: ${dir}`
|
|
1176
|
-
|
|
1177
|
-
const entries = readdirSync(dir, { withFileTypes: true })
|
|
1178
|
-
const lines = entries
|
|
1179
|
-
.sort((a, b) => {
|
|
1180
|
-
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
|
|
1181
|
-
return a.name.localeCompare(b.name)
|
|
1182
|
-
})
|
|
1183
|
-
.map((e) => {
|
|
1184
|
-
if (e.isDirectory()) return `d ${e.name}/`
|
|
1185
|
-
try {
|
|
1186
|
-
const stat = statSync(join(dir, e.name))
|
|
1187
|
-
const size = formatSize(stat.size)
|
|
1188
|
-
return `f ${e.name} ${size}`
|
|
1189
|
-
} catch {
|
|
1190
|
-
return `f ${e.name}`
|
|
1191
|
-
}
|
|
1192
|
-
})
|
|
1193
|
-
|
|
1194
|
-
return lines.join('\n')
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// ─── run_command: cross-platform shell ──────────────────────
|
|
1198
|
-
|
|
1199
|
-
async function toolRunCommand(input: Record<string, unknown>): Promise<string> {
|
|
1200
|
-
const cmdErr = requireString(input, 'command')
|
|
1201
|
-
if (cmdErr) return cmdErr
|
|
1202
|
-
const cmd = input.command as string
|
|
1203
|
-
const timeoutSec = Math.min(120, Math.max(5, (input.timeout as number) || 30))
|
|
1204
|
-
|
|
1205
|
-
const shell = getShell()
|
|
1206
|
-
const proc = Bun.spawn([...shell, cmd], {
|
|
1207
|
-
stdout: 'pipe',
|
|
1208
|
-
stderr: 'pipe',
|
|
1209
|
-
cwd: process.cwd(),
|
|
1210
|
-
})
|
|
1211
|
-
|
|
1212
|
-
const timer = setTimeout(() => proc.kill(), timeoutSec * 1000)
|
|
1213
|
-
// Drain both pipes concurrently to avoid deadlock (HIGH-1 fix)
|
|
1214
|
-
const [stdout, stderr] = await Promise.all([
|
|
1215
|
-
new Response(proc.stdout).text(),
|
|
1216
|
-
new Response(proc.stderr).text(),
|
|
1217
|
-
])
|
|
1218
|
-
const exitCode = await proc.exited
|
|
1219
|
-
clearTimeout(timer)
|
|
1220
|
-
|
|
1221
|
-
let result = ''
|
|
1222
|
-
if (stdout.trim()) result += stdout.trim()
|
|
1223
|
-
if (stderr.trim()) {
|
|
1224
|
-
result += (result ? '\n' : '') + 'STDERR:\n' + stderr.trim()
|
|
1225
|
-
}
|
|
1226
|
-
if (exitCode !== 0) {
|
|
1227
|
-
result += (result ? '\n' : '') + `Exit code: ${exitCode}`
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
return truncate(result || '(no output)')
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// ─── fetch_url: HTTP client ─────────────────────────────────
|
|
1234
|
-
|
|
1235
|
-
async function toolFetchUrl(input: Record<string, unknown>): Promise<string> {
|
|
1236
|
-
const url = input.url as string
|
|
1237
|
-
const method = (input.method as string) || 'GET'
|
|
1238
|
-
const headers = (input.headers as Record<string, string>) || {}
|
|
1239
|
-
const body = input.body as string | undefined
|
|
1240
|
-
|
|
1241
|
-
// URL validation
|
|
1242
|
-
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
1243
|
-
return 'Error: URL must start with http:// or https://'
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// SSRF protection: block private/internal hostnames
|
|
1247
|
-
const ssrfErr = checkSsrf(url)
|
|
1248
|
-
if (ssrfErr) return ssrfErr
|
|
1249
|
-
|
|
1250
|
-
try {
|
|
1251
|
-
const controller = new AbortController()
|
|
1252
|
-
const timeout = setTimeout(() => controller.abort(), 30_000)
|
|
1253
|
-
|
|
1254
|
-
const resp = await fetch(url, {
|
|
1255
|
-
method,
|
|
1256
|
-
redirect: 'manual', // prevent redirect-based SSRF
|
|
1257
|
-
headers: {
|
|
1258
|
-
'User-Agent': 'smolerclaw/1.0',
|
|
1259
|
-
'Accept': 'text/html, application/json, text/plain, */*',
|
|
1260
|
-
...headers,
|
|
1261
|
-
},
|
|
1262
|
-
body: body && method !== 'GET' && method !== 'HEAD' ? body : undefined,
|
|
1263
|
-
signal: controller.signal,
|
|
1264
|
-
})
|
|
1265
|
-
clearTimeout(timeout)
|
|
1266
|
-
|
|
1267
|
-
// Handle redirects manually (max 5 hops, re-check SSRF on each)
|
|
1268
|
-
if (resp.status >= 300 && resp.status < 400) {
|
|
1269
|
-
const location = resp.headers.get('location')
|
|
1270
|
-
if (!location) return `Status: ${resp.status} (redirect with no location header)`
|
|
1271
|
-
const redirErr = checkSsrf(location)
|
|
1272
|
-
if (redirErr) return `Redirect blocked: ${redirErr}`
|
|
1273
|
-
return `Status: ${resp.status} -> Redirect to: ${location}\n(Use fetch_url on the redirect target if needed)`
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
const status = `${resp.status} ${resp.statusText}`
|
|
1277
|
-
const contentType = resp.headers.get('content-type') || ''
|
|
1278
|
-
|
|
1279
|
-
if (method === 'HEAD') {
|
|
1280
|
-
const headerLines = [...resp.headers.entries()]
|
|
1281
|
-
.map(([k, v]) => `${k}: ${v}`)
|
|
1282
|
-
.join('\n')
|
|
1283
|
-
return `Status: ${status}\n${headerLines}`
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
// Check content-length before reading body
|
|
1287
|
-
const contentLength = resp.headers.get('content-length')
|
|
1288
|
-
if (contentLength && Number(contentLength) > MAX_OUTPUT * 2) {
|
|
1289
|
-
return `Status: ${status}\n\nError: response body too large (${contentLength} bytes). Max is ${MAX_OUTPUT * 2} bytes.`
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
const text = await resp.text()
|
|
1293
|
-
|
|
1294
|
-
// For HTML, extract readable text (strip tags)
|
|
1295
|
-
if (contentType.includes('text/html')) {
|
|
1296
|
-
const clean = stripHtml(text)
|
|
1297
|
-
return truncate(`Status: ${status}\n\n${clean}`)
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
return truncate(`Status: ${status}\n\n${text}`)
|
|
1301
|
-
} catch (err) {
|
|
1302
|
-
if (err instanceof Error && err.name === 'AbortError') {
|
|
1303
|
-
return 'Error: Request timed out after 30 seconds.'
|
|
1304
|
-
}
|
|
1305
|
-
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
/**
|
|
1310
|
-
* Block SSRF: reject URLs pointing to private/internal networks.
|
|
1311
|
-
*/
|
|
1312
|
-
function checkSsrf(urlStr: string): string | null {
|
|
1313
|
-
try {
|
|
1314
|
-
const parsed = new URL(urlStr)
|
|
1315
|
-
const host = parsed.hostname.toLowerCase()
|
|
1316
|
-
|
|
1317
|
-
// Block non-HTTP schemes
|
|
1318
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1319
|
-
return `Error: protocol ${parsed.protocol} is not allowed.`
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// Block private/reserved hostnames
|
|
1323
|
-
const blockedHostnames = [
|
|
1324
|
-
'localhost', '127.0.0.1', '::1', '0.0.0.0',
|
|
1325
|
-
'::ffff:127.0.0.1', '::ffff:0.0.0.0',
|
|
1326
|
-
]
|
|
1327
|
-
if (blockedHostnames.includes(host)) {
|
|
1328
|
-
return 'Error: requests to localhost are blocked for security.'
|
|
1329
|
-
}
|
|
1330
|
-
if (host.endsWith('.local') || host.endsWith('.internal')) {
|
|
1331
|
-
return 'Error: requests to internal hostnames are blocked.'
|
|
1332
|
-
}
|
|
1333
|
-
// Block cloud metadata endpoints
|
|
1334
|
-
if (host === 'metadata.google.internal' || host === 'metadata.gcp.internal') {
|
|
1335
|
-
return 'Error: requests to cloud metadata endpoints are blocked.'
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
// Block private IP ranges (decimal notation)
|
|
1339
|
-
const parts = host.split('.').map(Number)
|
|
1340
|
-
if (parts.length === 4 && parts.every((n) => !isNaN(n) && n >= 0 && n <= 255)) {
|
|
1341
|
-
if (parts[0] === 10) return 'Error: requests to private IPs (10.x) are blocked.'
|
|
1342
|
-
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return 'Error: requests to private IPs (172.16-31.x) are blocked.'
|
|
1343
|
-
if (parts[0] === 192 && parts[1] === 168) return 'Error: requests to private IPs (192.168.x) are blocked.'
|
|
1344
|
-
if (parts[0] === 169 && parts[1] === 254) return 'Error: requests to link-local/metadata IPs are blocked.'
|
|
1345
|
-
if (parts[0] === 0) return 'Error: requests to 0.x IPs are blocked.'
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
// Block IPv6-mapped IPv4 (::ffff:x.x.x.x)
|
|
1349
|
-
if (host.startsWith('::ffff:') || host.startsWith('[::ffff:')) {
|
|
1350
|
-
return 'Error: requests to IPv6-mapped IPv4 addresses are blocked.'
|
|
1351
|
-
}
|
|
1352
|
-
} catch {
|
|
1353
|
-
return 'Error: invalid URL.'
|
|
1354
|
-
}
|
|
1355
|
-
return null
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/**
|
|
1359
|
-
* Strip HTML tags and extract readable text.
|
|
1360
|
-
* Simple heuristic — not a full parser.
|
|
1361
|
-
*/
|
|
1362
|
-
function stripHtml(html: string): string {
|
|
1363
|
-
let text = html
|
|
1364
|
-
// Remove script and style blocks
|
|
1365
|
-
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
1366
|
-
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
1367
|
-
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
1368
|
-
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
|
1369
|
-
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
1370
|
-
// Convert block elements to newlines
|
|
1371
|
-
.replace(/<\/(p|div|h[1-6]|li|tr|br|hr)[^>]*>/gi, '\n')
|
|
1372
|
-
.replace(/<(br|hr)[^>]*\/?>/gi, '\n')
|
|
1373
|
-
// Strip remaining tags
|
|
1374
|
-
.replace(/<[^>]+>/g, ' ')
|
|
1375
|
-
// Decode common HTML entities
|
|
1376
|
-
.replace(/&/g, '&')
|
|
1377
|
-
.replace(/</g, '<')
|
|
1378
|
-
.replace(/>/g, '>')
|
|
1379
|
-
.replace(/"/g, '"')
|
|
1380
|
-
.replace(/'/g, "'")
|
|
1381
|
-
.replace(/ /g, ' ')
|
|
1382
|
-
// Collapse whitespace
|
|
1383
|
-
.replace(/[ \t]+/g, ' ')
|
|
1384
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
1385
|
-
.trim()
|
|
1386
|
-
|
|
1387
|
-
return text
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// ─── Helpers ─────────────────────────────────────────────────
|
|
1391
|
-
|
|
1392
|
-
function formatSearchResults(stdout: string, baseDir: string): string {
|
|
1393
|
-
const cwd = process.cwd()
|
|
1394
|
-
const cwdPrefix = cwd + sep
|
|
1395
|
-
const baseDirPrefix = baseDir + sep
|
|
1396
|
-
const lines = stdout.trim().split('\n')
|
|
1397
|
-
const relativized = lines.map((line) => {
|
|
1398
|
-
if (line.startsWith(cwdPrefix)) return '.' + line.slice(cwd.length).replace(/\\/g, '/')
|
|
1399
|
-
if (line.startsWith(baseDirPrefix)) return '.' + line.slice(baseDir.length).replace(/\\/g, '/')
|
|
1400
|
-
return line
|
|
1401
|
-
})
|
|
1402
|
-
|
|
1403
|
-
const count = relativized.length
|
|
1404
|
-
let result = relativized.slice(0, 100).join('\n')
|
|
1405
|
-
if (count > 100) result += `\n... (${count - 100} more matches)`
|
|
1406
|
-
return truncate(result)
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
function truncate(s: string): string {
|
|
1410
|
-
if (s.length <= MAX_OUTPUT) return s
|
|
1411
|
-
return s.slice(0, MAX_OUTPUT) + '\n... (output truncated)'
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
function formatSize(bytes: number): string {
|
|
1415
|
-
if (bytes < 1024) return `${bytes}B`
|
|
1416
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`
|
|
1417
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}M`
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
/**
|
|
1421
|
-
* Parse fuzzy date strings: "amanha", "em 3 dias", "sexta", "28/03", etc.
|
|
1422
|
-
*/
|
|
1423
|
-
function parseFuzzyDate(input: string): Date | null {
|
|
1424
|
-
const text = input.toLowerCase().trim()
|
|
1425
|
-
const now = new Date()
|
|
1426
|
-
|
|
1427
|
-
if (text === 'hoje') return now
|
|
1428
|
-
|
|
1429
|
-
if (text === 'amanha' || text === 'amanhã') {
|
|
1430
|
-
const d = new Date(now)
|
|
1431
|
-
d.setDate(d.getDate() + 1)
|
|
1432
|
-
return d
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// "em X dias"
|
|
1436
|
-
const daysMatch = text.match(/em\s+(\d+)\s*dias?/)
|
|
1437
|
-
if (daysMatch) {
|
|
1438
|
-
const d = new Date(now)
|
|
1439
|
-
d.setDate(d.getDate() + parseInt(daysMatch[1]))
|
|
1440
|
-
return d
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// "em X semanas"
|
|
1444
|
-
const weeksMatch = text.match(/em\s+(\d+)\s*semanas?/)
|
|
1445
|
-
if (weeksMatch) {
|
|
1446
|
-
const d = new Date(now)
|
|
1447
|
-
d.setDate(d.getDate() + parseInt(weeksMatch[1]) * 7)
|
|
1448
|
-
return d
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// Day of week: "segunda", "terca", "quarta", "quinta", "sexta", "sabado", "domingo"
|
|
1452
|
-
const weekdays: Record<string, number> = {
|
|
1453
|
-
domingo: 0, segunda: 1, terca: 2, terça: 2, quarta: 3,
|
|
1454
|
-
quinta: 4, sexta: 5, sabado: 6, sábado: 6,
|
|
1455
|
-
}
|
|
1456
|
-
for (const [name, dayNum] of Object.entries(weekdays)) {
|
|
1457
|
-
if (text.includes(name)) {
|
|
1458
|
-
const d = new Date(now)
|
|
1459
|
-
const diff = (dayNum - d.getDay() + 7) % 7 || 7
|
|
1460
|
-
d.setDate(d.getDate() + diff)
|
|
1461
|
-
return d
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
// "DD/MM" or "DD/MM/YYYY"
|
|
1466
|
-
const dateMatch = text.match(/(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\s*/)
|
|
1467
|
-
if (dateMatch) {
|
|
1468
|
-
const day = parseInt(dateMatch[1])
|
|
1469
|
-
const month = parseInt(dateMatch[2]) - 1
|
|
1470
|
-
const year = dateMatch[3]
|
|
1471
|
-
? parseInt(dateMatch[3]) + (dateMatch[3].length === 2 ? 2000 : 0)
|
|
1472
|
-
: now.getFullYear()
|
|
1473
|
-
const d = new Date(year, month, day)
|
|
1474
|
-
if (!isNaN(d.getTime())) return d
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Try parseTime from tasks (handles "18h", "em 30 min", etc.)
|
|
1478
|
-
return parseTime(text)
|
|
1479
|
-
}
|