smolerclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/release.yml +67 -0
  3. package/bun.lock +33 -0
  4. package/dist/index.js +321 -0
  5. package/dist/tinyclaw.exe +0 -0
  6. package/install.ps1 +119 -0
  7. package/package.json +25 -0
  8. package/skills/business.md +77 -0
  9. package/skills/default.md +77 -0
  10. package/src/ansi.ts +164 -0
  11. package/src/approval.ts +74 -0
  12. package/src/auth.ts +125 -0
  13. package/src/briefing.ts +52 -0
  14. package/src/claude.ts +267 -0
  15. package/src/cli.ts +137 -0
  16. package/src/clipboard.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/context-window.ts +190 -0
  19. package/src/context.ts +125 -0
  20. package/src/decisions.ts +122 -0
  21. package/src/email.ts +123 -0
  22. package/src/errors.ts +78 -0
  23. package/src/export.ts +82 -0
  24. package/src/finance.ts +148 -0
  25. package/src/git.ts +62 -0
  26. package/src/history.ts +100 -0
  27. package/src/images.ts +68 -0
  28. package/src/index.ts +1431 -0
  29. package/src/investigate.ts +415 -0
  30. package/src/markdown.ts +125 -0
  31. package/src/memos.ts +191 -0
  32. package/src/models.ts +94 -0
  33. package/src/monitor.ts +169 -0
  34. package/src/morning.ts +108 -0
  35. package/src/news.ts +329 -0
  36. package/src/openai-provider.ts +127 -0
  37. package/src/people.ts +472 -0
  38. package/src/personas.ts +99 -0
  39. package/src/platform.ts +84 -0
  40. package/src/plugins.ts +125 -0
  41. package/src/pomodoro.ts +169 -0
  42. package/src/providers.ts +70 -0
  43. package/src/retry.ts +108 -0
  44. package/src/session.ts +128 -0
  45. package/src/skills.ts +102 -0
  46. package/src/tasks.ts +418 -0
  47. package/src/tokens.ts +102 -0
  48. package/src/tool-safety.ts +100 -0
  49. package/src/tools.ts +1479 -0
  50. package/src/tui.ts +693 -0
  51. package/src/types.ts +55 -0
  52. package/src/undo.ts +83 -0
  53. package/src/windows.ts +299 -0
  54. package/src/workflows.ts +197 -0
  55. package/tests/ansi.test.ts +58 -0
  56. package/tests/approval.test.ts +43 -0
  57. package/tests/briefing.test.ts +10 -0
  58. package/tests/cli.test.ts +53 -0
  59. package/tests/context-window.test.ts +83 -0
  60. package/tests/images.test.ts +28 -0
  61. package/tests/memos.test.ts +116 -0
  62. package/tests/models.test.ts +34 -0
  63. package/tests/news.test.ts +13 -0
  64. package/tests/path-guard.test.ts +37 -0
  65. package/tests/people.test.ts +204 -0
  66. package/tests/skills.test.ts +35 -0
  67. package/tests/ssrf.test.ts +80 -0
  68. package/tests/tasks.test.ts +152 -0
  69. package/tests/tokens.test.ts +44 -0
  70. package/tests/tool-safety.test.ts +55 -0
  71. package/tests/windows-security.test.ts +59 -0
  72. package/tests/windows.test.ts +20 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,52 @@
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 ADDED
@@ -0,0 +1,267 @@
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
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+
4
+ export interface CliArgs {
5
+ help: boolean
6
+ version: boolean
7
+ model?: string
8
+ session?: string
9
+ maxTokens?: number
10
+ noTools: boolean
11
+ print: boolean
12
+ prompt?: string
13
+ }
14
+
15
+ /**
16
+ * Parse CLI arguments. Zero dependencies.
17
+ */
18
+ export function parseArgs(argv: string[]): CliArgs {
19
+ const args: CliArgs = {
20
+ help: false,
21
+ version: false,
22
+ noTools: false,
23
+ print: false,
24
+ }
25
+
26
+ const positional: string[] = []
27
+ let i = 0
28
+
29
+ while (i < argv.length) {
30
+ const arg = argv[i]
31
+
32
+ switch (arg) {
33
+ case '-h':
34
+ case '--help':
35
+ args.help = true
36
+ break
37
+
38
+ case '-v':
39
+ case '--version':
40
+ args.version = true
41
+ break
42
+
43
+ case '-m':
44
+ case '--model':
45
+ args.model = argv[++i]
46
+ if (!args.model) die('--model requires a value')
47
+ break
48
+
49
+ case '-s':
50
+ case '--session':
51
+ args.session = argv[++i]
52
+ if (!args.session) die('--session requires a value')
53
+ break
54
+
55
+ case '--max-tokens':
56
+ const n = Number(argv[++i])
57
+ if (!n || n <= 0) die('--max-tokens requires a positive number')
58
+ args.maxTokens = n
59
+ break
60
+
61
+ case '--no-tools':
62
+ args.noTools = true
63
+ break
64
+
65
+ case '-p':
66
+ case '--print':
67
+ args.print = true
68
+ break
69
+
70
+ default:
71
+ if (arg.startsWith('-')) {
72
+ die(`Unknown option: ${arg}. Try --help`)
73
+ }
74
+ positional.push(arg)
75
+ }
76
+ i++
77
+ }
78
+
79
+ if (positional.length > 0) {
80
+ args.prompt = positional.join(' ')
81
+ }
82
+
83
+ return args
84
+ }
85
+
86
+ // BUILD_VERSION is injected at compile time via --define.
87
+ // Falls back to reading package.json at runtime (dev mode).
88
+ declare const BUILD_VERSION: string | undefined
89
+
90
+ export function getVersion(): string {
91
+ if (typeof BUILD_VERSION !== 'undefined') return BUILD_VERSION
92
+ try {
93
+ const pkgPath = join(dirname(import.meta.dir), 'package.json')
94
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
95
+ return pkg.version || '0.0.0'
96
+ } catch {
97
+ return '0.0.0'
98
+ }
99
+ }
100
+
101
+ export function printHelp(): void {
102
+ const version = getVersion()
103
+ console.log(`smolerclaw v${version} — the micro AI assistant
104
+
105
+ Usage:
106
+ smolerclaw [options] [prompt]
107
+
108
+ Options:
109
+ -h, --help Show this help
110
+ -v, --version Show version
111
+ -m, --model <name> Override model (e.g. claude-sonnet-4-20250514)
112
+ -s, --session <name> Start with a specific session
113
+ --max-tokens <n> Override max tokens per response
114
+ --no-tools Disable tool use for this session
115
+ -p, --print Print response and exit (no TUI)
116
+
117
+ Examples:
118
+ smolerclaw Interactive TUI mode
119
+ smolerclaw "explain this error" Launch TUI with initial prompt
120
+ smolerclaw -p "what is 2+2" Print answer and exit
121
+ echo "review" | smolerclaw -p Pipe input, print response
122
+ smolerclaw -m claude-sonnet-4-20250514 -s work
123
+
124
+ Commands (inside TUI):
125
+ /help Show commands /clear Clear conversation
126
+ /new New session /load Load session
127
+ /model Show/set model /persona Switch mode
128
+ /briefing Daily briefing /news News radar
129
+ /task Create task /tasks List tasks
130
+ /open Open Windows app /calendar Outlook calendar
131
+ /export Export markdown /exit Quit`)
132
+ }
133
+
134
+ function die(msg: string): never {
135
+ console.error(`smolerclaw: ${msg}`)
136
+ process.exit(2)
137
+ }
@@ -0,0 +1,27 @@
1
+ import { IS_WINDOWS, IS_MAC } from './platform'
2
+
3
+ /**
4
+ * Copy text to system clipboard. Cross-platform.
5
+ */
6
+ export async function copyToClipboard(text: string): Promise<boolean> {
7
+ try {
8
+ const cmd = IS_WINDOWS
9
+ ? ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input']
10
+ : IS_MAC
11
+ ? ['pbcopy']
12
+ : ['xclip', '-selection', 'clipboard']
13
+
14
+ const proc = Bun.spawn(cmd, {
15
+ stdin: 'pipe',
16
+ stdout: 'pipe',
17
+ stderr: 'pipe',
18
+ })
19
+
20
+ proc.stdin.write(text)
21
+ proc.stdin.end()
22
+ const code = await proc.exited
23
+ return code === 0
24
+ } catch {
25
+ return false
26
+ }
27
+ }
package/src/config.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { IS_WINDOWS } from './platform'
5
+ import type { TinyClawConfig } from './types'
6
+
7
+ const HOME = homedir()
8
+
9
+ // Platform-aware directories
10
+ const CONFIG_DIR = IS_WINDOWS
11
+ ? join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'smolerclaw')
12
+ : join(HOME, '.config', 'smolerclaw')
13
+
14
+ const DATA_DIR = IS_WINDOWS
15
+ ? join(process.env.LOCALAPPDATA || join(HOME, 'AppData', 'Local'), 'smolerclaw')
16
+ : join(HOME, '.local', 'share', 'smolerclaw')
17
+
18
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
19
+
20
+ const DEFAULTS: TinyClawConfig = {
21
+ apiKey: '',
22
+ authMode: 'auto',
23
+ model: 'claude-haiku-4-5-20251001',
24
+ maxTokens: 4096,
25
+ maxHistory: 50,
26
+ systemPrompt: '',
27
+ skillsDir: './skills',
28
+ dataDir: DATA_DIR,
29
+ toolApproval: 'auto',
30
+ language: 'auto',
31
+ maxSessionCost: 0,
32
+ }
33
+
34
+ function ensureDir(dir: string): void {
35
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
36
+ }
37
+
38
+ export function loadConfig(): TinyClawConfig {
39
+ ensureDir(CONFIG_DIR)
40
+ ensureDir(DATA_DIR)
41
+ ensureDir(join(DATA_DIR, 'sessions'))
42
+
43
+ // Migrate from old Linux-style paths on Windows if they exist
44
+ if (IS_WINDOWS) {
45
+ migrateOldPaths()
46
+ }
47
+
48
+ if (!existsSync(CONFIG_FILE)) {
49
+ writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
50
+ return { ...DEFAULTS }
51
+ }
52
+
53
+ let raw: Record<string, unknown>
54
+ try {
55
+ raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
56
+ } catch {
57
+ // Config file corrupted — reset to defaults
58
+ writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
59
+ return { ...DEFAULTS }
60
+ }
61
+ return { ...DEFAULTS, ...raw }
62
+ }
63
+
64
+ export function saveConfig(config: TinyClawConfig): void {
65
+ ensureDir(CONFIG_DIR)
66
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
67
+ }
68
+
69
+ export function getConfigPath(): string {
70
+ return CONFIG_FILE
71
+ }
72
+
73
+ export function getDataDir(): string {
74
+ return DATA_DIR
75
+ }
76
+
77
+ /** One-time migration from old ~/.config/smolerclaw paths on Windows */
78
+ function migrateOldPaths(): void {
79
+ const oldConfig = join(HOME, '.config', 'smolerclaw', 'config.json')
80
+ if (existsSync(oldConfig) && !existsSync(CONFIG_FILE)) {
81
+ try {
82
+ const data = readFileSync(oldConfig, 'utf-8')
83
+ ensureDir(CONFIG_DIR)
84
+ writeFileSync(CONFIG_FILE, data)
85
+ } catch { /* best effort */ }
86
+ }
87
+ }