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/export.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { Session, Message } from './types'
2
+
3
+ interface ExportOptions {
4
+ includeToolCalls?: boolean
5
+ includeTimestamps?: boolean
6
+ }
7
+
8
+ /**
9
+ * Export a session to a clean markdown document.
10
+ */
11
+ export function exportToMarkdown(session: Session, opts: ExportOptions = {}): string {
12
+ const { includeToolCalls = true, includeTimestamps = true } = opts
13
+ const lines: string[] = []
14
+
15
+ lines.push(`# smolerclaw session: ${session.name}`)
16
+ lines.push(`Created: ${new Date(session.created).toLocaleString()}`)
17
+ lines.push('')
18
+ lines.push('---')
19
+ lines.push('')
20
+
21
+ for (const msg of session.messages) {
22
+ const ts = includeTimestamps
23
+ ? ` (${new Date(msg.timestamp).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })})`
24
+ : ''
25
+
26
+ if (msg.role === 'user') {
27
+ lines.push(`## You${ts}`)
28
+ lines.push('')
29
+ lines.push(msg.content)
30
+ lines.push('')
31
+ } else {
32
+ lines.push(`## Claude${ts}`)
33
+ lines.push('')
34
+ lines.push(msg.content)
35
+
36
+ if (includeToolCalls && msg.toolCalls?.length) {
37
+ lines.push('')
38
+ for (const tc of msg.toolCalls) {
39
+ const inputSummary = formatToolInput(tc.name, tc.input)
40
+ lines.push(`> **Tool:** \`${tc.name}\`${inputSummary}`)
41
+ const resultPreview = tc.result.split('\n').slice(0, 5).join('\n')
42
+ if (resultPreview.trim()) {
43
+ lines.push('> ```')
44
+ for (const rl of resultPreview.split('\n')) {
45
+ lines.push(`> ${rl}`)
46
+ }
47
+ lines.push('> ```')
48
+ }
49
+ }
50
+ }
51
+
52
+ if (msg.usage) {
53
+ lines.push('')
54
+ lines.push(`*Tokens: ${msg.usage.inputTokens} in / ${msg.usage.outputTokens} out (~$${(msg.usage.costCents / 100).toFixed(4)})*`)
55
+ }
56
+
57
+ lines.push('')
58
+ }
59
+
60
+ lines.push('---')
61
+ lines.push('')
62
+ }
63
+
64
+ return lines.join('\n')
65
+ }
66
+
67
+ function formatToolInput(name: string, input: Record<string, unknown>): string {
68
+ switch (name) {
69
+ case 'read_file':
70
+ case 'write_file':
71
+ case 'edit_file':
72
+ return input.path ? ` \`${input.path}\`` : ''
73
+ case 'search_files':
74
+ return input.pattern ? ` \`/${input.pattern}/\`` : ''
75
+ case 'find_files':
76
+ return input.pattern ? ` \`${input.pattern}\`` : ''
77
+ case 'run_command':
78
+ return input.command ? ` \`${input.command}\`` : ''
79
+ default:
80
+ return ''
81
+ }
82
+ }
package/src/finance.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Simple personal finance tracker — income/expense by category.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ // ─── Types ──────────────────────────────────────────────────
9
+
10
+ export interface Transaction {
11
+ id: string
12
+ type: 'entrada' | 'saida'
13
+ amount: number // always positive
14
+ category: string
15
+ description: string
16
+ date: string // ISO date
17
+ }
18
+
19
+ // ─── Storage ────────────────────────────────────────────────
20
+
21
+ let _dataDir = ''
22
+ let _transactions: Transaction[] = []
23
+
24
+ const DATA_FILE = () => join(_dataDir, 'finance.json')
25
+
26
+ function save(): void {
27
+ writeFileSync(DATA_FILE(), JSON.stringify(_transactions, null, 2))
28
+ }
29
+
30
+ function load(): void {
31
+ const file = DATA_FILE()
32
+ if (!existsSync(file)) { _transactions = []; return }
33
+ try { _transactions = JSON.parse(readFileSync(file, 'utf-8')) }
34
+ catch { _transactions = [] }
35
+ }
36
+
37
+ // ─── Init ───────────────────────────────────────────────────
38
+
39
+ export function initFinance(dataDir: string): void {
40
+ _dataDir = dataDir
41
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
42
+ load()
43
+ }
44
+
45
+ // ─── Operations ─────────────────────────────────────────────
46
+
47
+ export function addTransaction(
48
+ type: 'entrada' | 'saida',
49
+ amount: number,
50
+ category: string,
51
+ description: string,
52
+ ): Transaction {
53
+ const tx: Transaction = {
54
+ id: genId(),
55
+ type,
56
+ amount: Math.abs(amount),
57
+ category: category.toLowerCase().trim(),
58
+ description: description.trim(),
59
+ date: new Date().toISOString(),
60
+ }
61
+ _transactions = [..._transactions, tx]
62
+ save()
63
+ return tx
64
+ }
65
+
66
+ export function removeTransaction(id: string): boolean {
67
+ const idx = _transactions.findIndex((t) => t.id === id)
68
+ if (idx === -1) return false
69
+ _transactions = [..._transactions.slice(0, idx), ..._transactions.slice(idx + 1)]
70
+ save()
71
+ return true
72
+ }
73
+
74
+ // ─── Reports ────────────────────────────────────────────────
75
+
76
+ export function getMonthSummary(year?: number, month?: number): string {
77
+ const now = new Date()
78
+ const y = year || now.getFullYear()
79
+ const m = month !== undefined ? month : now.getMonth()
80
+
81
+ const monthTx = _transactions.filter((t) => {
82
+ const d = new Date(t.date)
83
+ return d.getFullYear() === y && d.getMonth() === m
84
+ })
85
+
86
+ if (monthTx.length === 0) {
87
+ return `Nenhuma transacao em ${formatMonth(m)}/${y}.`
88
+ }
89
+
90
+ const income = monthTx.filter((t) => t.type === 'entrada').reduce((s, t) => s + t.amount, 0)
91
+ const expenses = monthTx.filter((t) => t.type === 'saida').reduce((s, t) => s + t.amount, 0)
92
+ const balance = income - expenses
93
+
94
+ // Group expenses by category
95
+ const byCategory = new Map<string, number>()
96
+ for (const tx of monthTx.filter((t) => t.type === 'saida')) {
97
+ byCategory.set(tx.category, (byCategory.get(tx.category) || 0) + tx.amount)
98
+ }
99
+
100
+ const lines: string[] = [
101
+ `--- Resumo ${formatMonth(m)}/${y} ---`,
102
+ `Entradas: R$ ${income.toFixed(2)}`,
103
+ `Saidas: R$ ${expenses.toFixed(2)}`,
104
+ `Saldo: R$ ${balance.toFixed(2)} ${balance >= 0 ? '' : '(NEGATIVO)'}`,
105
+ ]
106
+
107
+ if (byCategory.size > 0) {
108
+ lines.push('')
109
+ lines.push('Saidas por categoria:')
110
+ const sorted = [...byCategory.entries()].sort((a, b) => b[1] - a[1])
111
+ for (const [cat, total] of sorted) {
112
+ const pct = expenses > 0 ? Math.round((total / expenses) * 100) : 0
113
+ lines.push(` ${cat.padEnd(15)} R$ ${total.toFixed(2)} (${pct}%)`)
114
+ }
115
+ }
116
+
117
+ return lines.join('\n')
118
+ }
119
+
120
+ export function getRecentTransactions(limit = 10): string {
121
+ const recent = [..._transactions]
122
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
123
+ .slice(0, limit)
124
+
125
+ if (recent.length === 0) return 'Nenhuma transacao registrada.'
126
+
127
+ const lines = recent.map((t) => {
128
+ const date = new Date(t.date).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
129
+ const sign = t.type === 'entrada' ? '+' : '-'
130
+ return ` [${date}] ${sign} R$ ${t.amount.toFixed(2)} ${t.category} — ${t.description} [${t.id}]`
131
+ })
132
+
133
+ return `Transacoes recentes:\n${lines.join('\n')}`
134
+ }
135
+
136
+ // ─── Helpers ────────────────────────────────────────────────
137
+
138
+ function genId(): string {
139
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
140
+ let id = ''
141
+ for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)]
142
+ return id
143
+ }
144
+
145
+ function formatMonth(m: number): string {
146
+ const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
147
+ return names[m] || String(m + 1)
148
+ }
package/src/git.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Git helper functions.
3
+ * SECURITY: All git commands use Bun.spawn with args array (no shell interpolation).
4
+ */
5
+
6
+ async function exec(...args: string[]): Promise<{ stdout: string; stderr: string; ok: boolean }> {
7
+ const proc = Bun.spawn(args, {
8
+ stdout: 'pipe',
9
+ stderr: 'pipe',
10
+ cwd: process.cwd(),
11
+ })
12
+ const [stdout, stderr] = await Promise.all([
13
+ new Response(proc.stdout).text(),
14
+ new Response(proc.stderr).text(),
15
+ ])
16
+ const code = await proc.exited
17
+ return { stdout: stdout.trim(), stderr: stderr.trim(), ok: code === 0 }
18
+ }
19
+
20
+ export async function gitDiff(): Promise<string> {
21
+ const staged = await exec('git', 'diff', '--cached')
22
+ const unstaged = await exec('git', 'diff')
23
+ const untracked = await exec('git', 'ls-files', '--others', '--exclude-standard')
24
+
25
+ const parts: string[] = []
26
+ if (staged.stdout) parts.push('=== STAGED ===\n' + staged.stdout)
27
+ if (unstaged.stdout) parts.push('=== UNSTAGED ===\n' + unstaged.stdout)
28
+ if (untracked.stdout) parts.push('=== UNTRACKED ===\n' + untracked.stdout)
29
+
30
+ return parts.join('\n\n') || '(no changes)'
31
+ }
32
+
33
+ export async function gitStatus(): Promise<string> {
34
+ const result = await exec('git', 'status', '--short')
35
+ return result.ok ? (result.stdout || '(clean)') : result.stderr
36
+ }
37
+
38
+ export async function gitStageAll(): Promise<boolean> {
39
+ const result = await exec('git', 'add', '-A')
40
+ return result.ok
41
+ }
42
+
43
+ export async function gitCommit(message: string): Promise<{ ok: boolean; output: string }> {
44
+ // SAFE: message passed as separate arg, never interpolated into shell string
45
+ const result = await exec('git', 'commit', '-m', message)
46
+ return { ok: result.ok, output: result.stdout || result.stderr }
47
+ }
48
+
49
+ export async function gitPush(): Promise<{ ok: boolean; output: string }> {
50
+ const result = await exec('git', 'push')
51
+ return { ok: result.ok, output: result.stdout || result.stderr }
52
+ }
53
+
54
+ export async function gitLog(n: number = 5): Promise<string> {
55
+ const result = await exec('git', 'log', '--oneline', `-${n}`)
56
+ return result.ok ? result.stdout : result.stderr
57
+ }
58
+
59
+ export async function isGitRepo(): Promise<boolean> {
60
+ const result = await exec('git', 'rev-parse', '--is-inside-work-tree')
61
+ return result.ok && result.stdout === 'true'
62
+ }
package/src/history.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ const MAX_ENTRIES = 500
4
+
5
+ /**
6
+ * Input history ring buffer with persistence.
7
+ * Navigate with prev() / next(), persist to disk.
8
+ */
9
+ export class InputHistory {
10
+ private entries: string[] = []
11
+ private cursor = -1
12
+ private pending = ''
13
+
14
+ constructor(private filePath: string) {
15
+ this.load()
16
+ }
17
+
18
+ /**
19
+ * Add an entry to history. Deduplicates consecutive identical entries.
20
+ */
21
+ add(entry: string): void {
22
+ const trimmed = entry.trim()
23
+ if (!trimmed) return
24
+
25
+ // Remove duplicate if it's the last entry
26
+ if (this.entries.length > 0 && this.entries[this.entries.length - 1] === trimmed) {
27
+ return
28
+ }
29
+
30
+ this.entries.push(trimmed)
31
+
32
+ // Evict oldest entries
33
+ if (this.entries.length > MAX_ENTRIES) {
34
+ this.entries = this.entries.slice(-MAX_ENTRIES)
35
+ }
36
+
37
+ this.cursor = -1
38
+ this.pending = ''
39
+ this.save()
40
+ }
41
+
42
+ /**
43
+ * Navigate backward (Up arrow). Returns the previous entry.
44
+ * On first call, saves the current input as "pending".
45
+ */
46
+ prev(currentInput: string): string | null {
47
+ if (this.entries.length === 0) return null
48
+
49
+ if (this.cursor === -1) {
50
+ this.pending = currentInput
51
+ this.cursor = this.entries.length - 1
52
+ } else if (this.cursor > 0) {
53
+ this.cursor--
54
+ } else {
55
+ return this.entries[0] // Already at oldest
56
+ }
57
+
58
+ return this.entries[this.cursor]
59
+ }
60
+
61
+ /**
62
+ * Navigate forward (Down arrow). Returns the next entry,
63
+ * or the pending input when reaching the end.
64
+ */
65
+ next(): string {
66
+ if (this.cursor === -1) return this.pending
67
+
68
+ if (this.cursor < this.entries.length - 1) {
69
+ this.cursor++
70
+ return this.entries[this.cursor]
71
+ }
72
+
73
+ // Past the end — return to pending input
74
+ this.cursor = -1
75
+ return this.pending
76
+ }
77
+
78
+ /**
79
+ * Reset navigation state (e.g., after submitting).
80
+ */
81
+ reset(): void {
82
+ this.cursor = -1
83
+ this.pending = ''
84
+ }
85
+
86
+ private load(): void {
87
+ try {
88
+ if (existsSync(this.filePath)) {
89
+ const content = readFileSync(this.filePath, 'utf-8')
90
+ this.entries = content.split('\n').filter(Boolean).slice(-MAX_ENTRIES)
91
+ }
92
+ } catch { /* ignore */ }
93
+ }
94
+
95
+ private save(): void {
96
+ try {
97
+ writeFileSync(this.filePath, this.entries.join('\n') + '\n')
98
+ } catch { /* ignore */ }
99
+ }
100
+ }
package/src/images.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { extname, resolve } from 'node:path'
3
+
4
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp'])
5
+ const MAX_IMAGE_SIZE = 20 * 1024 * 1024 // 20 MB
6
+
7
+ export interface ImageAttachment {
8
+ path: string
9
+ mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
10
+ base64: string
11
+ }
12
+
13
+ /**
14
+ * Extract image file paths from user input text.
15
+ * Returns the cleaned text (paths removed) and extracted images.
16
+ */
17
+ export function extractImages(input: string): { text: string; images: ImageAttachment[] } {
18
+ const images: ImageAttachment[] = []
19
+ const words = input.split(/\s+/)
20
+ const textParts: string[] = []
21
+
22
+ for (const word of words) {
23
+ const cleaned = word.replace(/^["']|["']$/g, '') // strip quotes
24
+ const ext = extname(cleaned).toLowerCase()
25
+
26
+ if (IMAGE_EXTENSIONS.has(ext)) {
27
+ const fullPath = resolve(cleaned)
28
+ if (existsSync(fullPath)) {
29
+ try {
30
+ const size = statSync(fullPath).size
31
+ if (size > MAX_IMAGE_SIZE) {
32
+ textParts.push(`[image too large: ${cleaned}]`)
33
+ continue
34
+ }
35
+
36
+ const data = readFileSync(fullPath)
37
+ const base64 = data.toString('base64')
38
+ const mediaType = extToMediaType(ext)
39
+
40
+ images.push({ path: fullPath, mediaType, base64 })
41
+ textParts.push(`[image: ${cleaned}]`)
42
+ } catch {
43
+ textParts.push(word) // keep original if we can't read it
44
+ }
45
+ } else {
46
+ textParts.push(word) // not a valid path, keep as text
47
+ }
48
+ } else {
49
+ textParts.push(word)
50
+ }
51
+ }
52
+
53
+ return {
54
+ text: textParts.join(' '),
55
+ images,
56
+ }
57
+ }
58
+
59
+ function extToMediaType(ext: string): ImageAttachment['mediaType'] {
60
+ switch (ext) {
61
+ case '.png': return 'image/png'
62
+ case '.jpg':
63
+ case '.jpeg': return 'image/jpeg'
64
+ case '.gif': return 'image/gif'
65
+ case '.webp': return 'image/webp'
66
+ default: return 'image/png'
67
+ }
68
+ }