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.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/release.yml +67 -0
- package/bun.lock +33 -0
- package/dist/index.js +321 -0
- package/dist/tinyclaw.exe +0 -0
- package/install.ps1 +119 -0
- package/package.json +25 -0
- package/skills/business.md +77 -0
- package/skills/default.md +77 -0
- package/src/ansi.ts +164 -0
- package/src/approval.ts +74 -0
- package/src/auth.ts +125 -0
- package/src/briefing.ts +52 -0
- package/src/claude.ts +267 -0
- package/src/cli.ts +137 -0
- package/src/clipboard.ts +27 -0
- package/src/config.ts +87 -0
- package/src/context-window.ts +190 -0
- package/src/context.ts +125 -0
- package/src/decisions.ts +122 -0
- package/src/email.ts +123 -0
- package/src/errors.ts +78 -0
- package/src/export.ts +82 -0
- package/src/finance.ts +148 -0
- package/src/git.ts +62 -0
- package/src/history.ts +100 -0
- package/src/images.ts +68 -0
- package/src/index.ts +1431 -0
- package/src/investigate.ts +415 -0
- package/src/markdown.ts +125 -0
- package/src/memos.ts +191 -0
- package/src/models.ts +94 -0
- package/src/monitor.ts +169 -0
- package/src/morning.ts +108 -0
- package/src/news.ts +329 -0
- package/src/openai-provider.ts +127 -0
- package/src/people.ts +472 -0
- package/src/personas.ts +99 -0
- package/src/platform.ts +84 -0
- package/src/plugins.ts +125 -0
- package/src/pomodoro.ts +169 -0
- package/src/providers.ts +70 -0
- package/src/retry.ts +108 -0
- package/src/session.ts +128 -0
- package/src/skills.ts +102 -0
- package/src/tasks.ts +418 -0
- package/src/tokens.ts +102 -0
- package/src/tool-safety.ts +100 -0
- package/src/tools.ts +1479 -0
- package/src/tui.ts +693 -0
- package/src/types.ts +55 -0
- package/src/undo.ts +83 -0
- package/src/windows.ts +299 -0
- package/src/workflows.ts +197 -0
- package/tests/ansi.test.ts +58 -0
- package/tests/approval.test.ts +43 -0
- package/tests/briefing.test.ts +10 -0
- package/tests/cli.test.ts +53 -0
- package/tests/context-window.test.ts +83 -0
- package/tests/images.test.ts +28 -0
- package/tests/memos.test.ts +116 -0
- package/tests/models.test.ts +34 -0
- package/tests/news.test.ts +13 -0
- package/tests/path-guard.test.ts +37 -0
- package/tests/people.test.ts +204 -0
- package/tests/skills.test.ts +35 -0
- package/tests/ssrf.test.ts +80 -0
- package/tests/tasks.test.ts +152 -0
- package/tests/tokens.test.ts +44 -0
- package/tests/tool-safety.test.ts +55 -0
- package/tests/windows-security.test.ts +59 -0
- package/tests/windows.test.ts +20 -0
- 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
|
+
}
|