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.
- package/dist/README.md +159 -0
- package/package.json +11 -3
- package/.github/workflows/ci.yml +0 -30
- package/.github/workflows/release.yml +0 -67
- package/bun.lock +0 -33
- package/install.ps1 +0 -119
- package/skills/business.md +0 -77
- package/skills/default.md +0 -77
- package/src/ansi.ts +0 -164
- package/src/approval.ts +0 -74
- package/src/auth.ts +0 -125
- package/src/briefing.ts +0 -52
- package/src/claude.ts +0 -267
- package/src/cli.ts +0 -137
- package/src/clipboard.ts +0 -27
- package/src/config.ts +0 -87
- package/src/context-window.ts +0 -190
- package/src/context.ts +0 -125
- package/src/decisions.ts +0 -122
- package/src/email.ts +0 -123
- package/src/errors.ts +0 -78
- package/src/export.ts +0 -82
- package/src/finance.ts +0 -148
- package/src/git.ts +0 -62
- package/src/history.ts +0 -100
- package/src/images.ts +0 -68
- package/src/index.ts +0 -1431
- package/src/investigate.ts +0 -415
- package/src/markdown.ts +0 -125
- package/src/memos.ts +0 -191
- package/src/models.ts +0 -94
- package/src/monitor.ts +0 -169
- package/src/morning.ts +0 -108
- package/src/news.ts +0 -329
- package/src/openai-provider.ts +0 -127
- package/src/people.ts +0 -472
- package/src/personas.ts +0 -99
- package/src/platform.ts +0 -84
- package/src/plugins.ts +0 -125
- package/src/pomodoro.ts +0 -169
- package/src/providers.ts +0 -70
- package/src/retry.ts +0 -108
- package/src/session.ts +0 -128
- package/src/skills.ts +0 -102
- package/src/tasks.ts +0 -418
- package/src/tokens.ts +0 -102
- package/src/tool-safety.ts +0 -100
- package/src/tools.ts +0 -1479
- package/src/tui.ts +0 -693
- package/src/types.ts +0 -55
- package/src/undo.ts +0 -83
- package/src/windows.ts +0 -299
- package/src/workflows.ts +0 -197
- package/tests/ansi.test.ts +0 -58
- package/tests/approval.test.ts +0 -43
- package/tests/briefing.test.ts +0 -10
- package/tests/cli.test.ts +0 -53
- package/tests/context-window.test.ts +0 -83
- package/tests/images.test.ts +0 -28
- package/tests/memos.test.ts +0 -116
- package/tests/models.test.ts +0 -34
- package/tests/news.test.ts +0 -13
- package/tests/path-guard.test.ts +0 -37
- package/tests/people.test.ts +0 -204
- package/tests/skills.test.ts +0 -35
- package/tests/ssrf.test.ts +0 -80
- package/tests/tasks.test.ts +0 -152
- package/tests/tokens.test.ts +0 -44
- package/tests/tool-safety.test.ts +0 -55
- package/tests/windows-security.test.ts +0 -59
- package/tests/windows.test.ts +0 -20
- package/tsconfig.json +0 -19
package/src/email.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email draft system — generate drafts and open in Outlook.
|
|
3
|
-
* Uses mailto: URI for cross-platform or Outlook COM on Windows.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { IS_WINDOWS } from './platform'
|
|
7
|
-
|
|
8
|
-
export interface EmailDraft {
|
|
9
|
-
to: string
|
|
10
|
-
subject: string
|
|
11
|
-
body: string
|
|
12
|
-
cc?: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Open an email draft in the default mail client.
|
|
17
|
-
* On Windows, tries Outlook COM first, then falls back to mailto:.
|
|
18
|
-
*/
|
|
19
|
-
export async function openEmailDraft(draft: EmailDraft): Promise<string> {
|
|
20
|
-
if (IS_WINDOWS) {
|
|
21
|
-
return openInOutlook(draft)
|
|
22
|
-
}
|
|
23
|
-
return openMailto(draft)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Open draft via Outlook COM (Windows only).
|
|
28
|
-
* Creates a new mail item with fields pre-filled.
|
|
29
|
-
*/
|
|
30
|
-
async function openInOutlook(draft: EmailDraft): Promise<string> {
|
|
31
|
-
// Escape single quotes for PowerShell
|
|
32
|
-
const to = draft.to.replace(/'/g, "''")
|
|
33
|
-
const subject = draft.subject.replace(/'/g, "''")
|
|
34
|
-
const body = draft.body.replace(/'/g, "''").replace(/\n/g, '`n')
|
|
35
|
-
const cc = draft.cc?.replace(/'/g, "''") || ''
|
|
36
|
-
|
|
37
|
-
const cmd = [
|
|
38
|
-
'try {',
|
|
39
|
-
' $outlook = New-Object -ComObject Outlook.Application -ErrorAction Stop',
|
|
40
|
-
' $mail = $outlook.CreateItem(0)', // olMailItem = 0
|
|
41
|
-
` $mail.To = '${to}'`,
|
|
42
|
-
` $mail.Subject = '${subject}'`,
|
|
43
|
-
` $mail.Body = '${body}'`,
|
|
44
|
-
cc ? ` $mail.CC = '${cc}'` : '',
|
|
45
|
-
' $mail.Display()',
|
|
46
|
-
' "Email aberto no Outlook."',
|
|
47
|
-
'} catch {',
|
|
48
|
-
' "Outlook nao disponivel. Usando mailto..."',
|
|
49
|
-
'}',
|
|
50
|
-
].filter(Boolean).join('\n')
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const proc = Bun.spawn(
|
|
54
|
-
['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
|
|
55
|
-
{ stdout: 'pipe', stderr: 'pipe' },
|
|
56
|
-
)
|
|
57
|
-
const timer = setTimeout(() => proc.kill(), 15_000)
|
|
58
|
-
const [stdout] = await Promise.all([
|
|
59
|
-
new Response(proc.stdout).text(),
|
|
60
|
-
new Response(proc.stderr).text(),
|
|
61
|
-
])
|
|
62
|
-
await proc.exited
|
|
63
|
-
clearTimeout(timer)
|
|
64
|
-
|
|
65
|
-
const result = stdout.trim()
|
|
66
|
-
|
|
67
|
-
// If Outlook failed, fall back to mailto
|
|
68
|
-
if (result.includes('mailto')) {
|
|
69
|
-
return openMailto(draft)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return result || 'Email aberto no Outlook.'
|
|
73
|
-
} catch {
|
|
74
|
-
return openMailto(draft)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Open draft via mailto: URI (cross-platform fallback).
|
|
80
|
-
*/
|
|
81
|
-
async function openMailto(draft: EmailDraft): Promise<string> {
|
|
82
|
-
const params: string[] = []
|
|
83
|
-
if (draft.subject) params.push(`subject=${encodeURIComponent(draft.subject)}`)
|
|
84
|
-
if (draft.body) params.push(`body=${encodeURIComponent(draft.body)}`)
|
|
85
|
-
if (draft.cc) params.push(`cc=${encodeURIComponent(draft.cc)}`)
|
|
86
|
-
|
|
87
|
-
const mailto = `mailto:${encodeURIComponent(draft.to)}${params.length ? '?' + params.join('&') : ''}`
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
const openCmd = IS_WINDOWS
|
|
91
|
-
? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process '${mailto}'`]
|
|
92
|
-
: ['xdg-open', mailto]
|
|
93
|
-
|
|
94
|
-
const proc = Bun.spawn(openCmd, { stdout: 'pipe', stderr: 'pipe' })
|
|
95
|
-
const timer = setTimeout(() => proc.kill(), 10_000)
|
|
96
|
-
await Promise.all([
|
|
97
|
-
new Response(proc.stdout).text(),
|
|
98
|
-
new Response(proc.stderr).text(),
|
|
99
|
-
])
|
|
100
|
-
await proc.exited
|
|
101
|
-
clearTimeout(timer)
|
|
102
|
-
|
|
103
|
-
return 'Email aberto no cliente de email padrao.'
|
|
104
|
-
} catch (err) {
|
|
105
|
-
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Format a draft for preview in the TUI.
|
|
111
|
-
*/
|
|
112
|
-
export function formatDraftPreview(draft: EmailDraft): string {
|
|
113
|
-
const lines = [
|
|
114
|
-
'--- Rascunho de Email ---',
|
|
115
|
-
`Para: ${draft.to}`,
|
|
116
|
-
]
|
|
117
|
-
if (draft.cc) lines.push(`CC: ${draft.cc}`)
|
|
118
|
-
lines.push(`Assunto: ${draft.subject}`)
|
|
119
|
-
lines.push('')
|
|
120
|
-
lines.push(draft.body)
|
|
121
|
-
lines.push('------------------------')
|
|
122
|
-
return lines.join('\n')
|
|
123
|
-
}
|
package/src/errors.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Translate Anthropic API errors to actionable user messages.
|
|
3
|
-
*/
|
|
4
|
-
export function humanizeError(err: unknown): string {
|
|
5
|
-
if (!(err instanceof Error)) return String(err)
|
|
6
|
-
|
|
7
|
-
const status = (err as { status?: number }).status
|
|
8
|
-
const msg = err.message
|
|
9
|
-
|
|
10
|
-
// HTTP status-based errors
|
|
11
|
-
if (status) {
|
|
12
|
-
switch (status) {
|
|
13
|
-
case 400:
|
|
14
|
-
if (msg.includes('context_length') || msg.includes('too many tokens')) {
|
|
15
|
-
return 'Message too long for the model\'s context window. Try /clear to start fresh or use a shorter prompt.'
|
|
16
|
-
}
|
|
17
|
-
return `Bad request: ${extractDetail(msg)}`
|
|
18
|
-
|
|
19
|
-
case 401:
|
|
20
|
-
return 'Authentication failed. Your API key or subscription token may be expired.\n' +
|
|
21
|
-
'Try: Set ANTHROPIC_API_KEY env var, or run `claude` to refresh subscription credentials.'
|
|
22
|
-
|
|
23
|
-
case 403:
|
|
24
|
-
return 'Access denied. Your API key may not have permission for this model.\n' +
|
|
25
|
-
'Try: /model haiku (uses a more accessible model).'
|
|
26
|
-
|
|
27
|
-
case 404:
|
|
28
|
-
return `Model not found. The model "${extractModel(msg)}" may not exist or be unavailable.\n` +
|
|
29
|
-
'Try: /model to see available models.'
|
|
30
|
-
|
|
31
|
-
case 429:
|
|
32
|
-
return 'Rate limited. Too many requests in a short period.\n' +
|
|
33
|
-
'The request will be retried automatically. If this persists, wait a minute.'
|
|
34
|
-
|
|
35
|
-
case 500:
|
|
36
|
-
case 502:
|
|
37
|
-
case 503:
|
|
38
|
-
return 'Anthropic API is temporarily unavailable. Retrying automatically...'
|
|
39
|
-
|
|
40
|
-
case 529:
|
|
41
|
-
return 'Anthropic API is overloaded. Retrying with backoff...'
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Network errors
|
|
46
|
-
const lower = msg.toLowerCase()
|
|
47
|
-
if (lower.includes('econnrefused') || lower.includes('enotfound')) {
|
|
48
|
-
return 'Cannot connect to Anthropic API. Check your internet connection.'
|
|
49
|
-
}
|
|
50
|
-
if (lower.includes('etimedout') || lower.includes('socket hang up')) {
|
|
51
|
-
return 'Connection to Anthropic API timed out. Retrying...'
|
|
52
|
-
}
|
|
53
|
-
if (lower.includes('econnreset')) {
|
|
54
|
-
return 'Connection was reset. This usually recovers automatically.'
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Subscription-specific
|
|
58
|
-
if (lower.includes('expired') || lower.includes('invalid_api_key')) {
|
|
59
|
-
return 'Your authentication has expired. Run `claude` to refresh, or set a new ANTHROPIC_API_KEY.'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Default: return original with prefix
|
|
63
|
-
return msg
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function extractDetail(msg: string): string {
|
|
67
|
-
// Try to extract the "detail" or "message" field from API error JSON
|
|
68
|
-
try {
|
|
69
|
-
const match = msg.match(/"message"\s*:\s*"([^"]+)"/)
|
|
70
|
-
if (match) return match[1]
|
|
71
|
-
} catch { /* ignore */ }
|
|
72
|
-
return msg.length > 200 ? msg.slice(0, 200) + '...' : msg
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function extractModel(msg: string): string {
|
|
76
|
-
const match = msg.match(/model[:\s]+"?([a-z0-9-]+)"?/i)
|
|
77
|
-
return match ? match[1] : 'unknown'
|
|
78
|
-
}
|
package/src/export.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|