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/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
- }