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
package/src/news.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * News radar — fetches headlines from RSS feeds and web sources.
3
+ * Categories: business, tech, finance, brazil, world.
4
+ */
5
+
6
+ // ─── RSS Feed Sources ───────────────────────────────────────
7
+
8
+ interface NewsSource {
9
+ name: string
10
+ url: string
11
+ category: NewsCategory
12
+ }
13
+
14
+ export type NewsCategory = 'business' | 'tech' | 'finance' | 'brazil' | 'world'
15
+
16
+ const FEEDS: readonly NewsSource[] = [
17
+ // Business & Economy
18
+ { name: 'InfoMoney', url: 'https://www.infomoney.com.br/feed/', category: 'finance' },
19
+ { name: 'Valor Economico', url: 'https://pox.globo.com/rss/valor/', category: 'business' },
20
+ { name: 'Bloomberg Linea BR', url: 'https://www.bloomberglinea.com.br/feed/', category: 'finance' },
21
+
22
+ // Tech
23
+ { name: 'TechCrunch', url: 'https://techcrunch.com/feed/', category: 'tech' },
24
+ { name: 'Hacker News (best)', url: 'https://hnrss.org/best', category: 'tech' },
25
+ { name: 'The Verge', url: 'https://www.theverge.com/rss/index.xml', category: 'tech' },
26
+
27
+ // Brazil
28
+ { name: 'G1', url: 'https://g1.globo.com/rss/g1/', category: 'brazil' },
29
+ { name: 'Folha', url: 'https://feeds.folha.uol.com.br/folha/cotidiano/rss091.xml', category: 'brazil' },
30
+
31
+ // World
32
+ { name: 'BBC World', url: 'https://feeds.bbci.co.uk/news/world/rss.xml', category: 'world' },
33
+ { name: 'Reuters', url: 'https://www.reutersagency.com/feed/', category: 'world' },
34
+ ]
35
+
36
+ // ─── Constants ──────────────────────────────────────────────
37
+
38
+ const MAX_BODY_BYTES = 2 * 1024 * 1024 // 2 MB max per feed
39
+ const MAX_ITEMS_PER_FEED = 10
40
+ const FETCH_TIMEOUT_MS = 10_000
41
+
42
+ // ─── RSS Parser (minimal, no dependencies) ──────────────────
43
+
44
+ interface NewsItem {
45
+ title: string
46
+ link: string
47
+ source: string
48
+ category: NewsCategory
49
+ pubDate?: Date
50
+ }
51
+
52
+ /**
53
+ * Parse RSS/Atom XML to extract news items.
54
+ * Minimal parser — no dependency needed.
55
+ */
56
+ function parseRss(xml: string, source: string, category: NewsCategory): NewsItem[] {
57
+ const items: NewsItem[] = []
58
+
59
+ // Try RSS <item> format
60
+ const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi
61
+ let match: RegExpExecArray | null
62
+
63
+ while ((match = itemRegex.exec(xml)) !== null) {
64
+ const block = match[1]
65
+ const item = parseBlock(block, source, category)
66
+ if (item) items.push(item)
67
+ if (items.length >= MAX_ITEMS_PER_FEED) break
68
+ }
69
+
70
+ // If no <item>, try Atom <entry>
71
+ if (items.length === 0) {
72
+ const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi
73
+ while ((match = entryRegex.exec(xml)) !== null) {
74
+ const block = match[1]
75
+ const item = parseBlock(block, source, category)
76
+ if (item) items.push(item)
77
+ if (items.length >= MAX_ITEMS_PER_FEED) break
78
+ }
79
+ }
80
+
81
+ return items
82
+ }
83
+
84
+ function parseBlock(block: string, source: string, category: NewsCategory): NewsItem | null {
85
+ const title = extractTag(block, 'title')
86
+ if (!title) return null
87
+
88
+ const rawLink = extractTag(block, 'link') || extractAtomLink(block)
89
+ const link = sanitizeLink(rawLink)
90
+ const pubDateStr = extractTag(block, 'pubDate') || extractTag(block, 'published') || extractTag(block, 'updated')
91
+
92
+ let pubDate: Date | undefined
93
+ if (pubDateStr) {
94
+ const d = new Date(pubDateStr)
95
+ pubDate = isNaN(d.getTime()) ? undefined : d
96
+ }
97
+
98
+ return {
99
+ title: cleanHtml(title),
100
+ link,
101
+ source,
102
+ category,
103
+ pubDate,
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Validate a link is a safe HTTP(S) URL.
109
+ * Rejects javascript:, data:, and other schemes.
110
+ */
111
+ function sanitizeLink(link: string | null): string {
112
+ if (!link) return ''
113
+ const trimmed = link.trim()
114
+ if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) {
115
+ return trimmed
116
+ }
117
+ return '' // reject non-HTTP links
118
+ }
119
+
120
+ /**
121
+ * Escape regex special characters in a string.
122
+ */
123
+ function escapeRegex(s: string): string {
124
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
125
+ }
126
+
127
+ function extractTag(xml: string, tag: string): string | null {
128
+ const escaped = escapeRegex(tag)
129
+
130
+ // Handle CDATA
131
+ const cdataRegex = new RegExp(`<${escaped}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${escaped}>`, 'i')
132
+ const cdataMatch = cdataRegex.exec(xml)
133
+ if (cdataMatch) return cdataMatch[1].trim()
134
+
135
+ // Plain text
136
+ const regex = new RegExp(`<${escaped}[^>]*>([\\s\\S]*?)</${escaped}>`, 'i')
137
+ const match = regex.exec(xml)
138
+ return match ? match[1].trim() : null
139
+ }
140
+
141
+ function extractAtomLink(xml: string): string | null {
142
+ const regex = /<link[^>]+href="([^"]+)"[^>]*\/?>/i
143
+ const match = regex.exec(xml)
144
+ return match ? match[1] : null
145
+ }
146
+
147
+ function cleanHtml(text: string): string {
148
+ return text
149
+ .replace(/<[^>]+>/g, '')
150
+ .replace(/&amp;/g, '&')
151
+ .replace(/&lt;/g, '<')
152
+ .replace(/&gt;/g, '>')
153
+ .replace(/&quot;/g, '"')
154
+ .replace(/&#39;/g, "'")
155
+ .replace(/&nbsp;/g, ' ')
156
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
157
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
158
+ .trim()
159
+ }
160
+
161
+ // ─── Public API ─────────────────────────────────────────────
162
+
163
+ /**
164
+ * Fetch news from all or filtered categories.
165
+ * Returns formatted text output.
166
+ */
167
+ export async function fetchNews(
168
+ categories?: NewsCategory[],
169
+ maxPerSource = 5,
170
+ ): Promise<string> {
171
+ // Validate maxPerSource
172
+ const cappedMax = Math.max(1, Math.min(maxPerSource, MAX_ITEMS_PER_FEED))
173
+
174
+ // Guard against empty categories array
175
+ if (categories && categories.length === 0) {
176
+ return getNewsCategories()
177
+ }
178
+
179
+ const feeds = categories
180
+ ? FEEDS.filter((f) => categories.includes(f.category))
181
+ : FEEDS
182
+
183
+ const results = await Promise.allSettled(
184
+ feeds.map((feed) => fetchFeed(feed, cappedMax)),
185
+ )
186
+
187
+ const allItems: NewsItem[] = []
188
+ const errors: string[] = []
189
+
190
+ for (let i = 0; i < results.length; i++) {
191
+ const result = results[i]
192
+ if (result.status === 'fulfilled') {
193
+ allItems.push(...result.value)
194
+ } else {
195
+ errors.push(`${feeds[i].name}: ${summarizeError(result.reason)}`)
196
+ }
197
+ }
198
+
199
+ if (allItems.length === 0) {
200
+ return errors.length > 0
201
+ ? `Nenhuma noticia encontrada.\nFalhas: ${errors.join(', ')}`
202
+ : 'Nenhuma noticia encontrada.'
203
+ }
204
+
205
+ // Sort by date (newest first)
206
+ allItems.sort((a, b) => {
207
+ const da = a.pubDate?.getTime() || 0
208
+ const db = b.pubDate?.getTime() || 0
209
+ return db - da
210
+ })
211
+
212
+ return formatNews(allItems, errors)
213
+ }
214
+
215
+ function summarizeError(err: unknown): string {
216
+ if (err instanceof Error) {
217
+ if (err.name === 'AbortError') return 'timeout'
218
+ return err.message.slice(0, 80)
219
+ }
220
+ return 'unreachable'
221
+ }
222
+
223
+ async function fetchFeed(source: NewsSource, maxItems: number): Promise<NewsItem[]> {
224
+ const controller = new AbortController()
225
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
226
+
227
+ try {
228
+ const resp = await fetch(source.url, {
229
+ signal: controller.signal,
230
+ headers: {
231
+ 'User-Agent': 'smolerclaw/1.0 (news-radar)',
232
+ 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml',
233
+ },
234
+ })
235
+ clearTimeout(timeout)
236
+
237
+ if (!resp.ok) return []
238
+
239
+ // Check content-length before reading body
240
+ const contentLength = resp.headers.get('content-length')
241
+ if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
242
+ return [] // skip oversized feeds
243
+ }
244
+
245
+ // Read body with size cap
246
+ const reader = resp.body?.getReader()
247
+ if (!reader) return []
248
+
249
+ const chunks: Uint8Array[] = []
250
+ let totalBytes = 0
251
+
252
+ while (true) {
253
+ const { done, value } = await reader.read()
254
+ if (done) break
255
+ totalBytes += value.byteLength
256
+ if (totalBytes > MAX_BODY_BYTES) {
257
+ reader.cancel()
258
+ return [] // body too large
259
+ }
260
+ chunks.push(value)
261
+ }
262
+
263
+ const xml = new TextDecoder().decode(Buffer.concat(chunks))
264
+ const items = parseRss(xml, source.name, source.category)
265
+ return items.slice(0, maxItems)
266
+ } catch (err) {
267
+ clearTimeout(timeout)
268
+ // Log errors for debugging but don't crash
269
+ if (process.env.DEBUG) {
270
+ console.error(`[news] ${source.name}: ${err instanceof Error ? err.message : err}`)
271
+ }
272
+ return []
273
+ }
274
+ }
275
+
276
+ function formatNews(items: NewsItem[], errors: string[]): string {
277
+ const categoryLabels: Record<NewsCategory, string> = {
278
+ business: 'Negocios',
279
+ tech: 'Tecnologia',
280
+ finance: 'Financas',
281
+ brazil: 'Brasil',
282
+ world: 'Mundo',
283
+ }
284
+
285
+ // Group by category (immutable approach)
286
+ const grouped = new Map<NewsCategory, NewsItem[]>()
287
+ for (const item of items) {
288
+ const existing = grouped.get(item.category) || []
289
+ grouped.set(item.category, [...existing, item])
290
+ }
291
+
292
+ const sections: string[] = []
293
+ const categoryOrder: NewsCategory[] = ['finance', 'business', 'tech', 'brazil', 'world']
294
+
295
+ for (const cat of categoryOrder) {
296
+ const catItems = grouped.get(cat)
297
+ if (!catItems || catItems.length === 0) continue
298
+
299
+ const label = categoryLabels[cat]
300
+ const lines = catItems.slice(0, 8).map((item) => {
301
+ const time = item.pubDate
302
+ ? item.pubDate.toLocaleTimeString('pt-BR', {
303
+ hour: '2-digit',
304
+ minute: '2-digit',
305
+ timeZone: 'America/Sao_Paulo',
306
+ })
307
+ : ''
308
+ const timeStr = time ? `[${time}]` : ''
309
+ return ` ${timeStr} ${item.title} (${item.source})`
310
+ })
311
+
312
+ sections.push(`--- ${label} ---\n${lines.join('\n')}`)
313
+ }
314
+
315
+ let output = sections.join('\n\n')
316
+
317
+ if (errors.length > 0) {
318
+ output += `\n\n(Fontes indisponiveis: ${errors.join(', ')})`
319
+ }
320
+
321
+ return output
322
+ }
323
+
324
+ /**
325
+ * Get list of available categories.
326
+ */
327
+ export function getNewsCategories(): string {
328
+ return 'Categorias: business, tech, finance, brazil, world\nUso: /news [categoria]'
329
+ }
@@ -0,0 +1,127 @@
1
+ import type { Message, ChatEvent, ToolApprovalMode } from './types'
2
+ import type { ApprovalCallback } from './approval'
3
+ import type { LLMProvider } from './providers'
4
+
5
+ const OPENAI_BASE = 'https://api.openai.com/v1'
6
+ const OLLAMA_BASE = 'http://localhost:11434/v1'
7
+
8
+ /**
9
+ * OpenAI-compatible provider.
10
+ * Works with OpenAI API, Ollama (local), and any OpenAI-compatible endpoint.
11
+ */
12
+ export class OpenAICompatProvider implements LLMProvider {
13
+ readonly name: string
14
+ private apiKey: string
15
+ private baseUrl: string
16
+ private model: string
17
+ private maxTokens: number
18
+ private approvalMode: ToolApprovalMode = 'auto'
19
+ private approvalCallback: ApprovalCallback | null = null
20
+ private autoApproveAll = false
21
+
22
+ constructor(
23
+ provider: 'openai' | 'ollama',
24
+ model: string,
25
+ maxTokens: number,
26
+ ) {
27
+ this.name = provider
28
+ this.model = model
29
+ this.maxTokens = maxTokens
30
+
31
+ if (provider === 'ollama') {
32
+ this.apiKey = 'ollama' // Ollama doesn't need a real key
33
+ this.baseUrl = process.env.OLLAMA_BASE_URL || OLLAMA_BASE
34
+ } else {
35
+ this.apiKey = process.env.OPENAI_API_KEY || ''
36
+ this.baseUrl = process.env.OPENAI_BASE_URL || OPENAI_BASE
37
+ }
38
+ }
39
+
40
+ setModel(model: string): void { this.model = model }
41
+ setApprovalMode(mode: ToolApprovalMode): void { this.approvalMode = mode }
42
+ setApprovalCallback(cb: ApprovalCallback): void { this.approvalCallback = cb }
43
+ setAutoApproveAll(value: boolean): void { this.autoApproveAll = value }
44
+
45
+ async *chat(
46
+ messages: Message[],
47
+ systemPrompt: string,
48
+ enableTools = true,
49
+ ): AsyncGenerator<ChatEvent> {
50
+ if (!this.apiKey && this.name !== 'ollama') {
51
+ yield { type: 'error', error: `No API key found. Set OPENAI_API_KEY env var.` }
52
+ return
53
+ }
54
+
55
+ const apiMessages = [
56
+ { role: 'system', content: systemPrompt },
57
+ ...messages.map((m) => ({ role: m.role, content: m.content })),
58
+ ]
59
+
60
+ try {
61
+ const resp = await fetch(`${this.baseUrl}/chat/completions`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ 'Authorization': `Bearer ${this.apiKey}`,
66
+ },
67
+ body: JSON.stringify({
68
+ model: this.model,
69
+ messages: apiMessages,
70
+ max_tokens: this.maxTokens,
71
+ stream: true,
72
+ }),
73
+ })
74
+
75
+ if (!resp.ok) {
76
+ const err = await resp.text()
77
+ yield { type: 'error', error: `${this.name} API error ${resp.status}: ${err.slice(0, 200)}` }
78
+ return
79
+ }
80
+
81
+ if (!resp.body) {
82
+ yield { type: 'error', error: 'No response body' }
83
+ return
84
+ }
85
+
86
+ const reader = resp.body.getReader()
87
+ const decoder = new TextDecoder()
88
+ let buffer = ''
89
+ let inputEstimate = systemPrompt.length + messages.reduce((s, m) => s + m.content.length, 0)
90
+ let outputChars = 0
91
+
92
+ while (true) {
93
+ const { done, value } = await reader.read()
94
+ if (done) break
95
+
96
+ buffer += decoder.decode(value, { stream: true })
97
+ const lines = buffer.split('\n')
98
+ buffer = lines.pop() || ''
99
+
100
+ for (const line of lines) {
101
+ if (!line.startsWith('data: ')) continue
102
+ const data = line.slice(6).trim()
103
+ if (data === '[DONE]') continue
104
+
105
+ try {
106
+ const parsed = JSON.parse(data)
107
+ const delta = parsed.choices?.[0]?.delta
108
+ if (delta?.content) {
109
+ yield { type: 'text', text: delta.content }
110
+ outputChars += delta.content.length
111
+ }
112
+ } catch { /* skip malformed SSE */ }
113
+ }
114
+ }
115
+
116
+ // Estimate token usage (rough: 4 chars per token)
117
+ yield {
118
+ type: 'usage',
119
+ inputTokens: Math.ceil(inputEstimate / 3.5),
120
+ outputTokens: Math.ceil(outputChars / 3.5),
121
+ }
122
+ yield { type: 'done' }
123
+ } catch (err) {
124
+ yield { type: 'error', error: err instanceof Error ? err.message : String(err) }
125
+ }
126
+ }
127
+ }