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/ansi.ts DELETED
@@ -1,164 +0,0 @@
1
- // ─── ANSI Escape Helpers ─────────────────────────────────────
2
- const ESC = '\x1b'
3
- const CSI = `${ESC}[`
4
-
5
- // Detect NO_COLOR / TERM=dumb for graceful degradation
6
- export const NO_COLOR = !!(process.env.NO_COLOR || process.env.TERM === 'dumb')
7
-
8
- function esc(code: string): string {
9
- return NO_COLOR ? '' : code
10
- }
11
-
12
- export const A = {
13
- altOn: esc(`${CSI}?1049h`),
14
- altOff: esc(`${CSI}?1049l`),
15
- clear: `${CSI}2J`, // always need cursor control
16
- clearLine: `${CSI}2K`,
17
- hide: `${CSI}?25l`,
18
- show: `${CSI}?25h`,
19
- to: (r: number, c: number) => `${CSI}${r};${c}H`,
20
- bold: esc(`${CSI}1m`),
21
- dim: esc(`${CSI}2m`),
22
- italic: esc(`${CSI}3m`),
23
- underline: esc(`${CSI}4m`),
24
- reset: esc(`${CSI}0m`),
25
- inv: esc(`${CSI}7m`),
26
- fg: (n: number) => esc(`${CSI}38;5;${n}m`),
27
- bg: (n: number) => esc(`${CSI}48;5;${n}m`),
28
- }
29
-
30
- export { CSI }
31
-
32
- // ─── Theme ───────────────────────────────────────────────────
33
- export const C = {
34
- user: A.fg(75),
35
- ai: A.fg(114),
36
- tool: A.fg(215),
37
- err: A.fg(196),
38
- sys: A.fg(245),
39
- prompt: A.fg(220),
40
- code: A.fg(180),
41
- heading: A.fg(75),
42
- link: A.fg(39),
43
- quote: A.fg(245),
44
- }
45
-
46
- // ─── Utilities ───────────────────────────────────────────────
47
-
48
- export function w(s: string): void {
49
- process.stdout.write(s)
50
- }
51
-
52
- const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g
53
-
54
- export function stripAnsi(s: string): string {
55
- return s.replace(ANSI_RE, '')
56
- }
57
-
58
- /**
59
- * Get the display width of a character.
60
- * CJK and emoji occupy 2 columns; most others occupy 1.
61
- */
62
- export function charWidth(ch: string): number {
63
- const code = ch.codePointAt(0) || 0
64
- // CJK Unified Ideographs, CJK Compatibility, Hangul, Fullwidth forms
65
- if (
66
- (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
67
- (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || // CJK
68
- (code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
69
- (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
70
- (code >= 0xfe10 && code <= 0xfe6f) || // CJK Compatibility Forms
71
- (code >= 0xff01 && code <= 0xff60) || // Fullwidth
72
- (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth
73
- (code >= 0x1f300 && code <= 0x1fbff) || // Emoji & misc symbols
74
- (code >= 0x20000 && code <= 0x2ffff) // CJK Extension B+
75
- ) {
76
- return 2
77
- }
78
- return 1
79
- }
80
-
81
- /**
82
- * Get the visible display width of a string (non-ANSI, width-aware).
83
- */
84
- export function visibleLength(s: string): number {
85
- const plain = stripAnsi(s)
86
- let width = 0
87
- for (const ch of plain) {
88
- width += charWidth(ch)
89
- }
90
- return width
91
- }
92
-
93
- /**
94
- * Get the display width of a string's first N characters.
95
- */
96
- export function displayWidth(s: string, charCount: number): number {
97
- let width = 0
98
- let i = 0
99
- for (const ch of s) {
100
- if (i >= charCount) break
101
- width += charWidth(ch)
102
- i++
103
- }
104
- return width
105
- }
106
-
107
- /**
108
- * Word-wrap text to maxWidth, preserving ANSI escape codes.
109
- * Lines are split on word boundaries. Continuation lines get 2-space indent.
110
- */
111
- export function wrapText(text: string, maxWidth: number): string[] {
112
- if (maxWidth < 10) maxWidth = 10
113
- const results: string[] = []
114
-
115
- for (const rawLine of text.split('\n')) {
116
- const plainLen = visibleLength(rawLine)
117
- if (plainLen <= maxWidth) {
118
- results.push(rawLine)
119
- continue
120
- }
121
-
122
- // For ANSI-containing lines, we need to track visible position
123
- // Simple approach: work with plain text for wrapping decisions,
124
- // but rebuild output by walking the original with ANSI codes
125
- const plain = stripAnsi(rawLine)
126
- const words = plain.split(' ')
127
- const lines: string[] = []
128
- let current = ''
129
-
130
- for (const word of words) {
131
- const testLen = current.length + (current ? 1 : 0) + word.length
132
- if (testLen > maxWidth && current) {
133
- lines.push(current)
134
- current = ' ' + word
135
- } else {
136
- current += (current ? ' ' : '') + word
137
- }
138
- }
139
- if (current) lines.push(current)
140
-
141
- // If the original had ANSI codes and we're wrapping to plain text,
142
- // re-apply the dominant ANSI prefix from the original line
143
- const ansiPrefix = extractAnsiPrefix(rawLine)
144
- for (let i = 0; i < lines.length; i++) {
145
- if (ansiPrefix && i === 0) {
146
- lines[i] = ansiPrefix + lines[i] + A.reset
147
- } else if (ansiPrefix) {
148
- lines[i] = ansiPrefix + lines[i] + A.reset
149
- }
150
- }
151
-
152
- results.push(...lines)
153
- }
154
-
155
- return results
156
- }
157
-
158
- /**
159
- * Extract leading ANSI escape sequences from a string.
160
- */
161
- function extractAnsiPrefix(s: string): string {
162
- const match = s.match(/^(\x1b\[[0-9;]*[a-zA-Z])+/)
163
- return match ? match[0] : ''
164
- }
package/src/approval.ts DELETED
@@ -1,74 +0,0 @@
1
- import type { ToolApprovalMode } from './types'
2
-
3
- export type ApprovalCallback = (toolName: string, input: Record<string, unknown>, riskLevel: string) => Promise<boolean>
4
-
5
- /**
6
- * Determines whether a tool call needs user approval.
7
- */
8
- export function needsApproval(
9
- mode: ToolApprovalMode,
10
- toolName: string,
11
- riskLevel: string,
12
- ): boolean {
13
- if (mode === 'auto') return false
14
- if (riskLevel === 'safe') return false
15
-
16
- if (mode === 'confirm-writes') {
17
- // Only confirm write operations and commands
18
- return ['write_file', 'edit_file', 'run_command'].includes(toolName)
19
- }
20
-
21
- if (mode === 'confirm-all') {
22
- return riskLevel !== 'safe'
23
- }
24
-
25
- return false
26
- }
27
-
28
- /**
29
- * Format a tool call for approval display.
30
- */
31
- export function formatApprovalPrompt(toolName: string, input: Record<string, unknown>): string {
32
- switch (toolName) {
33
- case 'write_file':
34
- return `Write file: ${input.path}`
35
- case 'edit_file':
36
- return `Edit file: ${input.path}`
37
- case 'run_command': {
38
- const cmd = String(input.command || '')
39
- return `Run: ${cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd}`
40
- }
41
- default:
42
- return `${toolName}: ${JSON.stringify(input).slice(0, 60)}`
43
- }
44
- }
45
-
46
- /**
47
- * Generate a colored diff preview for edit_file operations.
48
- * Returns lines ready for TUI display.
49
- */
50
- export function formatEditDiff(oldText: string, newText: string, maxLines: number = 20): string[] {
51
- const oldLines = oldText.split('\n')
52
- const newLines = newText.split('\n')
53
- const lines: string[] = []
54
-
55
- // Show removed lines (red)
56
- const shownOld = oldLines.slice(0, maxLines)
57
- for (const line of shownOld) {
58
- lines.push(` \x1b[31m- ${line}\x1b[0m`)
59
- }
60
- if (oldLines.length > maxLines) {
61
- lines.push(` \x1b[2m ... (${oldLines.length - maxLines} more removed)\x1b[0m`)
62
- }
63
-
64
- // Show added lines (green)
65
- const shownNew = newLines.slice(0, maxLines)
66
- for (const line of shownNew) {
67
- lines.push(` \x1b[32m+ ${line}\x1b[0m`)
68
- }
69
- if (newLines.length > maxLines) {
70
- lines.push(` \x1b[2m ... (${newLines.length - maxLines} more added)\x1b[0m`)
71
- }
72
-
73
- return lines
74
- }
package/src/auth.ts DELETED
@@ -1,125 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { homedir } from 'node:os'
3
- import { join } from 'node:path'
4
-
5
- const CRED_PATH = join(homedir(), '.claude', '.credentials.json')
6
-
7
- interface ClaudeOAuth {
8
- accessToken: string
9
- refreshToken: string
10
- expiresAt: number
11
- subscriptionType: string
12
- rateLimitTier: string
13
- }
14
-
15
- interface ClaudeCredentials {
16
- claudeAiOauth?: ClaudeOAuth
17
- }
18
-
19
- export interface AuthResult {
20
- apiKey: string
21
- source: 'api-key' | 'subscription'
22
- subscriptionType?: string
23
- expiresAt?: number
24
- }
25
-
26
- /**
27
- * Resolve authentication in priority order:
28
- * 1. ANTHROPIC_API_KEY env var
29
- * 2. Claude Code subscription (OAuth token from ~/.claude/.credentials.json)
30
- * 3. apiKey from smolerclaw config file
31
- *
32
- * authMode overrides: "api-key" skips subscription, "subscription" skips api-key.
33
- */
34
- export function resolveAuth(
35
- configApiKey: string,
36
- authMode: 'auto' | 'api-key' | 'subscription' = 'auto',
37
- ): AuthResult {
38
- if (authMode === 'subscription') {
39
- const sub = trySubscription()
40
- if (sub) return sub
41
- throw new Error(
42
- 'Claude Code credentials not found or expired.\n' +
43
- 'Run `claude` to refresh, then restart smolerclaw.',
44
- )
45
- }
46
-
47
- if (authMode === 'api-key') {
48
- const key = process.env.ANTHROPIC_API_KEY || configApiKey
49
- if (key) return { apiKey: key, source: 'api-key' }
50
- throw new Error(
51
- 'No API key found.\n' +
52
- 'Set ANTHROPIC_API_KEY env var or add apiKey to config.',
53
- )
54
- }
55
-
56
- // auto mode: try all sources in order
57
-
58
- // 1. Explicit env var always wins
59
- if (process.env.ANTHROPIC_API_KEY) {
60
- return { apiKey: process.env.ANTHROPIC_API_KEY, source: 'api-key' }
61
- }
62
-
63
- // 2. Claude Code subscription
64
- const sub = trySubscription()
65
- if (sub) return sub
66
-
67
- // 3. Config file API key
68
- if (configApiKey) {
69
- return { apiKey: configApiKey, source: 'api-key' }
70
- }
71
-
72
- throw new Error(
73
- 'No authentication found.\n' +
74
- 'Options:\n' +
75
- ' 1. Install Claude Code with a Pro/Max subscription (auto-detected)\n' +
76
- ' 2. Set ANTHROPIC_API_KEY env var\n' +
77
- ' 3. Add apiKey to ~/.config/smolerclaw/config.json',
78
- )
79
- }
80
-
81
- function trySubscription(): AuthResult | null {
82
- if (!existsSync(CRED_PATH)) return null
83
-
84
- try {
85
- const raw: ClaudeCredentials = JSON.parse(readFileSync(CRED_PATH, 'utf-8'))
86
- const oauth = raw.claudeAiOauth
87
- if (!oauth?.accessToken) return null
88
-
89
- // Check expiration with 60s buffer
90
- if (Date.now() > oauth.expiresAt - 60_000) return null
91
-
92
- return {
93
- apiKey: oauth.accessToken,
94
- source: 'subscription',
95
- subscriptionType: oauth.subscriptionType,
96
- expiresAt: oauth.expiresAt,
97
- }
98
- } catch {
99
- return null
100
- }
101
- }
102
-
103
- /**
104
- * Re-read credentials from disk. Useful when the OAuth token expires
105
- * mid-session — Claude Code auto-refreshes it, so a re-read often works.
106
- * Returns null if no valid credentials are found.
107
- */
108
- export function refreshAuth(
109
- configApiKey: string,
110
- authMode: 'auto' | 'api-key' | 'subscription' = 'auto',
111
- ): AuthResult | null {
112
- try {
113
- return resolveAuth(configApiKey, authMode)
114
- } catch {
115
- return null
116
- }
117
- }
118
-
119
- /** Human-readable label for the TUI header */
120
- export function authLabel(auth: AuthResult): string {
121
- if (auth.source === 'subscription') {
122
- return `sub:${auth.subscriptionType || 'pro'}`
123
- }
124
- return 'api-key'
125
- }
package/src/briefing.ts DELETED
@@ -1,52 +0,0 @@
1
- /**
2
- * Daily briefing — morning summary combining calendar, system, and news.
3
- */
4
-
5
- import { getDateTimeInfo, getOutlookEvents, getSystemInfo } from './windows'
6
- import { fetchNews } from './news'
7
- import { IS_WINDOWS } from './platform'
8
-
9
- /**
10
- * Generate a daily briefing with date, calendar, system, and top news.
11
- */
12
- export async function generateBriefing(): Promise<string> {
13
- const sections: string[] = []
14
-
15
- // Header
16
- sections.push('=== BRIEFING DIARIO ===')
17
-
18
- // Date & time
19
- const dateInfo = await getDateTimeInfo()
20
- sections.push(dateInfo)
21
-
22
- // Calendar (Windows only, non-blocking)
23
- if (IS_WINDOWS) {
24
- try {
25
- const events = await getOutlookEvents()
26
- sections.push(`\n--- Agenda ---\n${events}`)
27
- } catch {
28
- sections.push('\n--- Agenda ---\nOutlook nao disponivel.')
29
- }
30
- }
31
-
32
- // System status
33
- if (IS_WINDOWS) {
34
- try {
35
- const sys = await getSystemInfo()
36
- sections.push(`\n--- Sistema ---\n${sys}`)
37
- } catch {
38
- // Skip system info on error
39
- }
40
- }
41
-
42
- // Top news (limited to 3 per source for briefing)
43
- try {
44
- const news = await fetchNews(['finance', 'business', 'tech'], 3)
45
- sections.push(`\n${news}`)
46
- } catch {
47
- sections.push('\n--- Noticias ---\nFalha ao carregar noticias.')
48
- }
49
-
50
- sections.push('\n======================')
51
- return sections.join('\n')
52
- }
package/src/claude.ts DELETED
@@ -1,267 +0,0 @@
1
- import Anthropic from '@anthropic-ai/sdk'
2
- import type { Message, ChatEvent, ToolApprovalMode } from './types'
3
- import { TOOLS, executeTool } from './tools'
4
- import { withRetry } from './retry'
5
- import { trimToContextWindow, compressToolResults, estimateTokens, needsSummary, buildSummaryRequest, summarizationPrompt } from './context-window'
6
- import { assessToolRisk } from './tool-safety'
7
- import { humanizeError } from './errors'
8
- import { needsApproval, type ApprovalCallback } from './approval'
9
-
10
- export class ClaudeProvider {
11
- private client: Anthropic
12
- private approvalMode: ToolApprovalMode
13
- private approvalCallback: ApprovalCallback | null = null
14
- private autoApproveAll = false
15
- private onAuthExpired: (() => boolean) | null = null
16
-
17
- constructor(
18
- apiKey: string,
19
- private model: string,
20
- private maxTokens: number,
21
- approvalMode: ToolApprovalMode = 'auto',
22
- ) {
23
- this.client = new Anthropic({ apiKey })
24
- this.approvalMode = approvalMode
25
- }
26
-
27
- /** Replace the API key and recreate the client (used after auth refresh) */
28
- updateApiKey(newKey: string): void {
29
- this.client = new Anthropic({ apiKey: newKey })
30
- }
31
-
32
- /** Register a callback that fires on 401 to attempt credential refresh */
33
- setAuthRefresh(cb: () => boolean): void {
34
- this.onAuthExpired = cb
35
- }
36
-
37
- setModel(model: string): void {
38
- this.model = model
39
- }
40
-
41
- setApprovalMode(mode: ToolApprovalMode): void {
42
- this.approvalMode = mode
43
- }
44
-
45
- setApprovalCallback(cb: ApprovalCallback): void {
46
- this.approvalCallback = cb
47
- }
48
-
49
- setAutoApproveAll(value: boolean): void {
50
- this.autoApproveAll = value
51
- }
52
-
53
- async *chat(
54
- messages: Message[],
55
- systemPrompt: string,
56
- enableTools = true,
57
- ): AsyncGenerator<ChatEvent> {
58
- let processed = compressToolResults(messages)
59
- const systemTokens = estimateTokens(systemPrompt)
60
-
61
- // Auto-summary when context is getting large
62
- if (needsSummary(processed, this.model, systemTokens)) {
63
- const req = buildSummaryRequest(processed, this.model, systemTokens)
64
- if (req) {
65
- try {
66
- const summaryText = await this.generateSummary(req.toSummarize)
67
- const summaryMsg: Message = {
68
- role: 'assistant',
69
- content: `[Conversation summary]\n${summaryText}`,
70
- timestamp: Date.now(),
71
- }
72
- processed = [
73
- { role: 'user', content: 'Continue from this summary of our earlier conversation.', timestamp: Date.now() },
74
- summaryMsg,
75
- ...req.toKeep,
76
- ]
77
- } catch {
78
- // Fallback to simple trim if summary fails
79
- }
80
- }
81
- }
82
-
83
- const trimmed = trimToContextWindow(processed, this.model, systemTokens)
84
- const apiMessages = toApiMessages(trimmed)
85
- const tools = enableTools ? TOOLS : undefined
86
-
87
- try {
88
- yield* this.streamLoop(apiMessages, systemPrompt, tools)
89
- } catch (err) {
90
- yield { type: 'error', error: humanizeError(err) }
91
- }
92
- }
93
-
94
- private async generateSummary(messages: Message[]): Promise<string> {
95
- const prompt = summarizationPrompt(messages)
96
- const resp = await this.client.messages.create({
97
- model: this.model,
98
- max_tokens: 1024,
99
- messages: [{ role: 'user', content: prompt }],
100
- })
101
- const textBlock = resp.content.find((b) => b.type === 'text')
102
- return textBlock?.type === 'text' ? textBlock.text : 'Summary unavailable.'
103
- }
104
-
105
- private async *streamLoop(
106
- messages: Anthropic.MessageParam[],
107
- system: string,
108
- tools?: Anthropic.Tool[],
109
- ): AsyncGenerator<ChatEvent> {
110
- const MAX_TOOL_ROUNDS = 25
111
- const convo = [...messages]
112
- let round = 0
113
-
114
- while (round++ < MAX_TOOL_ROUNDS) {
115
- let stream: ReturnType<typeof this.client.messages.stream>
116
-
117
- try {
118
- stream = await withRetry(
119
- async () => {
120
- return this.client.messages.stream({
121
- model: this.model,
122
- max_tokens: this.maxTokens,
123
- system,
124
- messages: convo,
125
- ...(tools?.length ? { tools } : {}),
126
- })
127
- },
128
- {
129
- onAuthExpired: this.onAuthExpired ?? undefined,
130
- },
131
- )
132
- } catch (err) {
133
- yield { type: 'error', error: humanizeError(err) }
134
- return
135
- }
136
-
137
- for await (const event of stream) {
138
- if (
139
- event.type === 'content_block_delta' &&
140
- event.delta.type === 'text_delta'
141
- ) {
142
- yield { type: 'text', text: event.delta.text }
143
- }
144
- }
145
-
146
- const final = await stream.finalMessage()
147
-
148
- if (final.usage) {
149
- yield {
150
- type: 'usage',
151
- inputTokens: final.usage.input_tokens,
152
- outputTokens: final.usage.output_tokens,
153
- }
154
- }
155
-
156
- if (final.stop_reason !== 'tool_use') {
157
- yield { type: 'done' }
158
- return
159
- }
160
-
161
- const toolBlocks = final.content.filter(
162
- (b: Anthropic.ContentBlock): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
163
- )
164
-
165
- convo.push({ role: 'assistant', content: final.content })
166
-
167
- const toolResults: Anthropic.ToolResultBlockParam[] = []
168
- for (const tc of toolBlocks) {
169
- const input = tc.input as Record<string, unknown>
170
- const risk = assessToolRisk(tc.name, input)
171
-
172
- // Block dangerous operations always
173
- if (risk.level === 'dangerous') {
174
- yield { type: 'tool_blocked', id: tc.id, name: tc.name, reason: `Blocked dangerous operation: ${risk.reason}` }
175
- toolResults.push({
176
- type: 'tool_result',
177
- tool_use_id: tc.id,
178
- content: `Error: Operation blocked for safety. Reason: ${risk.reason}. This command appears dangerous and was not executed.`,
179
- })
180
- continue
181
- }
182
-
183
- // Check if approval is needed
184
- if (!this.autoApproveAll && needsApproval(this.approvalMode, tc.name, risk.level) && this.approvalCallback) {
185
- yield { type: 'tool_call', id: tc.id, name: tc.name, input: tc.input }
186
- const approved = await this.approvalCallback(tc.name, input, risk.level)
187
- if (!approved) {
188
- yield { type: 'tool_blocked', id: tc.id, name: tc.name, reason: 'Rejected by user' }
189
- toolResults.push({
190
- type: 'tool_result',
191
- tool_use_id: tc.id,
192
- content: 'Error: User rejected this operation.',
193
- })
194
- continue
195
- }
196
- // Approved — execute (tool_call already yielded above)
197
- const result = await executeTool(tc.name, input)
198
- yield { type: 'tool_result', id: tc.id, name: tc.name, result }
199
- toolResults.push({ type: 'tool_result', tool_use_id: tc.id, content: result })
200
- continue
201
- }
202
-
203
- // Auto-approved — execute normally
204
- yield { type: 'tool_call', id: tc.id, name: tc.name, input: tc.input }
205
- const result = await executeTool(tc.name, input)
206
- yield { type: 'tool_result', id: tc.id, name: tc.name, result }
207
- toolResults.push({ type: 'tool_result', tool_use_id: tc.id, content: result })
208
- }
209
-
210
- convo.push({ role: 'user', content: toolResults })
211
- }
212
-
213
- yield { type: 'error', error: `Stopped after ${MAX_TOOL_ROUNDS} tool rounds to prevent runaway execution.` }
214
- }
215
- }
216
-
217
- function toApiMessages(messages: Message[]): Anthropic.MessageParam[] {
218
- const result: Anthropic.MessageParam[] = []
219
-
220
- for (const msg of messages) {
221
- if (msg.role === 'user') {
222
- if (msg.images?.length) {
223
- // Build multi-modal content with images + text
224
- const content: Anthropic.ContentBlockParam[] = msg.images.map((img) => ({
225
- type: 'image' as const,
226
- source: {
227
- type: 'base64' as const,
228
- media_type: img.mediaType,
229
- data: img.base64,
230
- },
231
- }))
232
- content.push({ type: 'text', text: msg.content })
233
- result.push({ role: 'user', content })
234
- } else {
235
- result.push({ role: 'user', content: msg.content })
236
- }
237
- } else if (msg.role === 'assistant') {
238
- if (msg.toolCalls?.length) {
239
- const content: Anthropic.ContentBlockParam[] = []
240
- if (msg.content) {
241
- content.push({ type: 'text', text: msg.content })
242
- }
243
- for (const tc of msg.toolCalls) {
244
- content.push({
245
- type: 'tool_use',
246
- id: tc.id,
247
- name: tc.name,
248
- input: tc.input,
249
- })
250
- }
251
- result.push({ role: 'assistant', content })
252
- result.push({
253
- role: 'user',
254
- content: msg.toolCalls.map((tc) => ({
255
- type: 'tool_result' as const,
256
- tool_use_id: tc.id,
257
- content: tc.result,
258
- })),
259
- })
260
- } else {
261
- result.push({ role: 'assistant', content: msg.content })
262
- }
263
- }
264
- }
265
-
266
- return result
267
- }