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.
Files changed (72) hide show
  1. package/dist/README.md +159 -0
  2. package/package.json +11 -3
  3. package/.github/workflows/ci.yml +0 -30
  4. package/.github/workflows/release.yml +0 -67
  5. package/bun.lock +0 -33
  6. package/install.ps1 +0 -119
  7. package/skills/business.md +0 -77
  8. package/skills/default.md +0 -77
  9. package/src/ansi.ts +0 -164
  10. package/src/approval.ts +0 -74
  11. package/src/auth.ts +0 -125
  12. package/src/briefing.ts +0 -52
  13. package/src/claude.ts +0 -267
  14. package/src/cli.ts +0 -137
  15. package/src/clipboard.ts +0 -27
  16. package/src/config.ts +0 -87
  17. package/src/context-window.ts +0 -190
  18. package/src/context.ts +0 -125
  19. package/src/decisions.ts +0 -122
  20. package/src/email.ts +0 -123
  21. package/src/errors.ts +0 -78
  22. package/src/export.ts +0 -82
  23. package/src/finance.ts +0 -148
  24. package/src/git.ts +0 -62
  25. package/src/history.ts +0 -100
  26. package/src/images.ts +0 -68
  27. package/src/index.ts +0 -1431
  28. package/src/investigate.ts +0 -415
  29. package/src/markdown.ts +0 -125
  30. package/src/memos.ts +0 -191
  31. package/src/models.ts +0 -94
  32. package/src/monitor.ts +0 -169
  33. package/src/morning.ts +0 -108
  34. package/src/news.ts +0 -329
  35. package/src/openai-provider.ts +0 -127
  36. package/src/people.ts +0 -472
  37. package/src/personas.ts +0 -99
  38. package/src/platform.ts +0 -84
  39. package/src/plugins.ts +0 -125
  40. package/src/pomodoro.ts +0 -169
  41. package/src/providers.ts +0 -70
  42. package/src/retry.ts +0 -108
  43. package/src/session.ts +0 -128
  44. package/src/skills.ts +0 -102
  45. package/src/tasks.ts +0 -418
  46. package/src/tokens.ts +0 -102
  47. package/src/tool-safety.ts +0 -100
  48. package/src/tools.ts +0 -1479
  49. package/src/tui.ts +0 -693
  50. package/src/types.ts +0 -55
  51. package/src/undo.ts +0 -83
  52. package/src/windows.ts +0 -299
  53. package/src/workflows.ts +0 -197
  54. package/tests/ansi.test.ts +0 -58
  55. package/tests/approval.test.ts +0 -43
  56. package/tests/briefing.test.ts +0 -10
  57. package/tests/cli.test.ts +0 -53
  58. package/tests/context-window.test.ts +0 -83
  59. package/tests/images.test.ts +0 -28
  60. package/tests/memos.test.ts +0 -116
  61. package/tests/models.test.ts +0 -34
  62. package/tests/news.test.ts +0 -13
  63. package/tests/path-guard.test.ts +0 -37
  64. package/tests/people.test.ts +0 -204
  65. package/tests/skills.test.ts +0 -35
  66. package/tests/ssrf.test.ts +0 -80
  67. package/tests/tasks.test.ts +0 -152
  68. package/tests/tokens.test.ts +0 -44
  69. package/tests/tool-safety.test.ts +0 -55
  70. package/tests/windows-security.test.ts +0 -59
  71. package/tests/windows.test.ts +0 -20
  72. 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(/&amp;/g, '&')
1377
- .replace(/&lt;/g, '<')
1378
- .replace(/&gt;/g, '>')
1379
- .replace(/&quot;/g, '"')
1380
- .replace(/&#39;/g, "'")
1381
- .replace(/&nbsp;/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
- }