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/plugins.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import type Anthropic from '@anthropic-ai/sdk'
|
|
4
|
+
import { getShell } from './platform'
|
|
5
|
+
|
|
6
|
+
export interface Plugin {
|
|
7
|
+
name: string
|
|
8
|
+
description: string
|
|
9
|
+
inputSchema: Record<string, unknown>
|
|
10
|
+
command: string // shell command template with {{input.field}} placeholders
|
|
11
|
+
source: string // file path
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load plugins from a directory.
|
|
16
|
+
* Each .json file defines one tool.
|
|
17
|
+
*
|
|
18
|
+
* Schema:
|
|
19
|
+
* {
|
|
20
|
+
* "name": "my_tool",
|
|
21
|
+
* "description": "What it does",
|
|
22
|
+
* "input_schema": { "type": "object", "properties": {...}, "required": [...] },
|
|
23
|
+
* "command": "curl -s https://api.example.com/{{input.query}}"
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
export function loadPlugins(pluginDir: string): Plugin[] {
|
|
27
|
+
if (!existsSync(pluginDir)) return []
|
|
28
|
+
|
|
29
|
+
const plugins: Plugin[] = []
|
|
30
|
+
const files = readdirSync(pluginDir).filter((f) => f.endsWith('.json'))
|
|
31
|
+
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(readFileSync(join(pluginDir, file), 'utf-8'))
|
|
35
|
+
|
|
36
|
+
// Validate required fields
|
|
37
|
+
if (!raw.name || !raw.description || !raw.command) continue
|
|
38
|
+
if (typeof raw.name !== 'string' || typeof raw.command !== 'string') continue
|
|
39
|
+
|
|
40
|
+
// Sanitize: reject commands with obvious injection patterns
|
|
41
|
+
if (raw.command.includes('$(') || raw.command.includes('`')) {
|
|
42
|
+
continue // skip dangerous command templates
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
plugins.push({
|
|
46
|
+
name: raw.name,
|
|
47
|
+
description: raw.description,
|
|
48
|
+
inputSchema: raw.input_schema || { type: 'object', properties: {}, required: [] },
|
|
49
|
+
command: raw.command,
|
|
50
|
+
source: join(pluginDir, file),
|
|
51
|
+
})
|
|
52
|
+
} catch {
|
|
53
|
+
// Skip invalid JSON files
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return plugins
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert loaded plugins to Anthropic tool definitions.
|
|
62
|
+
*/
|
|
63
|
+
export function pluginsToTools(plugins: Plugin[]): Anthropic.Tool[] {
|
|
64
|
+
return plugins.map((p) => ({
|
|
65
|
+
name: p.name,
|
|
66
|
+
description: p.description,
|
|
67
|
+
input_schema: p.inputSchema as Anthropic.Tool['input_schema'],
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Execute a plugin command by interpolating inputs.
|
|
73
|
+
*/
|
|
74
|
+
export async function executePlugin(
|
|
75
|
+
plugin: Plugin,
|
|
76
|
+
input: Record<string, unknown>,
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
// Interpolate {{input.field}} placeholders
|
|
79
|
+
let cmd = plugin.command
|
|
80
|
+
for (const [key, value] of Object.entries(input)) {
|
|
81
|
+
const safeValue = String(value).replace(/[;&|`$()]/g, '') // basic sanitization
|
|
82
|
+
cmd = cmd.replace(new RegExp(`\\{\\{input\\.${key}\\}\\}`, 'g'), safeValue)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Remove any remaining unresolved placeholders
|
|
86
|
+
cmd = cmd.replace(/\{\{input\.\w+\}\}/g, '')
|
|
87
|
+
|
|
88
|
+
const shell = getShell()
|
|
89
|
+
const proc = Bun.spawn([...shell, cmd], {
|
|
90
|
+
stdout: 'pipe',
|
|
91
|
+
stderr: 'pipe',
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => proc.kill(), 30_000)
|
|
96
|
+
const [stdout, stderr] = await Promise.all([
|
|
97
|
+
new Response(proc.stdout).text(),
|
|
98
|
+
new Response(proc.stderr).text(),
|
|
99
|
+
])
|
|
100
|
+
const code = await proc.exited
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
|
|
103
|
+
let result = stdout.trim()
|
|
104
|
+
if (stderr.trim()) result += (result ? '\n' : '') + stderr.trim()
|
|
105
|
+
if (code !== 0) result += (result ? '\n' : '') + `Exit code: ${code}`
|
|
106
|
+
|
|
107
|
+
return result || '(no output)'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Format plugin list for display.
|
|
112
|
+
*/
|
|
113
|
+
export function formatPluginList(plugins: Plugin[]): string {
|
|
114
|
+
if (plugins.length === 0) return 'No plugins loaded. Add .json files to ~/.config/smolerclaw/plugins/'
|
|
115
|
+
return 'Plugins:\n' + plugins.map((p) => ` ${p.name} — ${p.description}`).join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get or create the plugins directory.
|
|
120
|
+
*/
|
|
121
|
+
export function getPluginDir(configDir: string): string {
|
|
122
|
+
const dir = join(configDir, 'plugins')
|
|
123
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
124
|
+
return dir
|
|
125
|
+
}
|
package/src/pomodoro.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pomodoro timer — focus sessions with break notifications.
|
|
3
|
+
* 25 min work / 5 min break cycle with Windows toast notifications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IS_WINDOWS } from './platform'
|
|
7
|
+
|
|
8
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
interface PomodoroSession {
|
|
11
|
+
startedAt: number
|
|
12
|
+
durationMs: number
|
|
13
|
+
breakMs: number
|
|
14
|
+
label: string
|
|
15
|
+
type: 'work' | 'break'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PomodoroCallback = (message: string) => void
|
|
19
|
+
|
|
20
|
+
// ─── State ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let _session: PomodoroSession | null = null
|
|
23
|
+
let _timer: ReturnType<typeof setTimeout> | null = null
|
|
24
|
+
let _onNotify: PomodoroCallback | null = null
|
|
25
|
+
let _cycleCount = 0
|
|
26
|
+
|
|
27
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function initPomodoro(onNotify: PomodoroCallback): void {
|
|
30
|
+
_onNotify = onNotify
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function startPomodoro(
|
|
34
|
+
label = 'foco',
|
|
35
|
+
workMinutes = 25,
|
|
36
|
+
breakMinutes = 5,
|
|
37
|
+
): string {
|
|
38
|
+
if (_session) {
|
|
39
|
+
return `Pomodoro ja ativo: "${_session.label}" (${formatRemaining()}). Use /pomodoro stop para parar.`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_session = {
|
|
43
|
+
startedAt: Date.now(),
|
|
44
|
+
durationMs: workMinutes * 60_000,
|
|
45
|
+
breakMs: breakMinutes * 60_000,
|
|
46
|
+
label,
|
|
47
|
+
type: 'work',
|
|
48
|
+
}
|
|
49
|
+
_cycleCount++
|
|
50
|
+
|
|
51
|
+
scheduleNotification()
|
|
52
|
+
|
|
53
|
+
return `Pomodoro #${_cycleCount} iniciado: "${label}" (${workMinutes}min trabalho / ${breakMinutes}min pausa)`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stopPomodoro(): string {
|
|
57
|
+
if (!_session) return 'Nenhum pomodoro ativo.'
|
|
58
|
+
|
|
59
|
+
const label = _session.label
|
|
60
|
+
const elapsed = Math.floor((Date.now() - _session.startedAt) / 60_000)
|
|
61
|
+
clearTimer()
|
|
62
|
+
_session = null
|
|
63
|
+
|
|
64
|
+
return `Pomodoro parado: "${label}" (${elapsed}min decorridos)`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function pomodoroStatus(): string {
|
|
68
|
+
if (!_session) return 'Nenhum pomodoro ativo. Use /pomodoro <descricao> para iniciar.'
|
|
69
|
+
|
|
70
|
+
const remaining = formatRemaining()
|
|
71
|
+
const type = _session.type === 'work' ? 'Trabalhando' : 'Pausa'
|
|
72
|
+
|
|
73
|
+
return `${type}: "${_session.label}" — ${remaining} restante(s) (ciclo #${_cycleCount})`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isActive(): boolean {
|
|
77
|
+
return _session !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Internal ───────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function scheduleNotification(): void {
|
|
83
|
+
if (!_session) return
|
|
84
|
+
clearTimer()
|
|
85
|
+
|
|
86
|
+
const remaining = (_session.startedAt + _session.durationMs) - Date.now()
|
|
87
|
+
if (remaining <= 0) {
|
|
88
|
+
onPhaseEnd()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_timer = setTimeout(onPhaseEnd, remaining)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onPhaseEnd(): void {
|
|
96
|
+
if (!_session) return
|
|
97
|
+
|
|
98
|
+
if (_session.type === 'work') {
|
|
99
|
+
// Work phase ended — start break
|
|
100
|
+
const msg = `Pomodoro: "${_session.label}" concluido! Hora da pausa (${_session.breakMs / 60_000}min).`
|
|
101
|
+
fireToast('Pausa!', `"${_session.label}" concluido. Descanse ${_session.breakMs / 60_000} minutos.`)
|
|
102
|
+
_onNotify?.(msg)
|
|
103
|
+
|
|
104
|
+
_session = {
|
|
105
|
+
..._session,
|
|
106
|
+
type: 'break',
|
|
107
|
+
startedAt: Date.now(),
|
|
108
|
+
durationMs: _session.breakMs,
|
|
109
|
+
}
|
|
110
|
+
scheduleNotification()
|
|
111
|
+
} else {
|
|
112
|
+
// Break ended — notify and reset
|
|
113
|
+
const msg = 'Pausa concluida! Pronto para o proximo ciclo. Use /pomodoro para iniciar.'
|
|
114
|
+
fireToast('Volta ao trabalho!', 'Pausa concluida. Pronto para o proximo ciclo.')
|
|
115
|
+
_onNotify?.(msg)
|
|
116
|
+
clearTimer()
|
|
117
|
+
_session = null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clearTimer(): void {
|
|
122
|
+
if (_timer) {
|
|
123
|
+
clearTimeout(_timer)
|
|
124
|
+
_timer = null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatRemaining(): string {
|
|
129
|
+
if (!_session) return '0min'
|
|
130
|
+
const remaining = Math.max(0, (_session.startedAt + _session.durationMs) - Date.now())
|
|
131
|
+
const mins = Math.ceil(remaining / 60_000)
|
|
132
|
+
return `${mins}min`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function fireToast(title: string, body: string): Promise<void> {
|
|
136
|
+
if (!IS_WINDOWS) return
|
|
137
|
+
|
|
138
|
+
const safeTitle = title.replace(/'/g, "''")
|
|
139
|
+
const safeBody = body.replace(/'/g, "''")
|
|
140
|
+
|
|
141
|
+
const cmd = [
|
|
142
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
|
|
143
|
+
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null',
|
|
144
|
+
`$template = '<toast><visual><binding template="ToastText02"><text id="1">${safeTitle}</text><text id="2">${safeBody}</text></binding></visual><audio src="ms-winsoundevent:Notification.Reminder"/></toast>'`,
|
|
145
|
+
'$xml = New-Object Windows.Data.Xml.Dom.XmlDocument',
|
|
146
|
+
'$xml.LoadXml($template)',
|
|
147
|
+
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
148
|
+
'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("smolerclaw").Show($toast)',
|
|
149
|
+
].join('; ')
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const proc = Bun.spawn(
|
|
153
|
+
['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
|
|
154
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
155
|
+
)
|
|
156
|
+
const timer = setTimeout(() => proc.kill(), 10_000)
|
|
157
|
+
await Promise.all([
|
|
158
|
+
new Response(proc.stdout).text(),
|
|
159
|
+
new Response(proc.stderr).text(),
|
|
160
|
+
])
|
|
161
|
+
await proc.exited
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
} catch { /* best effort */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function stopPomodoroTimer(): void {
|
|
167
|
+
clearTimer()
|
|
168
|
+
_session = null
|
|
169
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ChatEvent, Message, ToolApprovalMode } from './types'
|
|
2
|
+
import type { ApprovalCallback } from './approval'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Abstract provider interface.
|
|
6
|
+
* All LLM providers implement this contract.
|
|
7
|
+
*/
|
|
8
|
+
export interface LLMProvider {
|
|
9
|
+
readonly name: string
|
|
10
|
+
setModel(model: string): void
|
|
11
|
+
setApprovalMode(mode: ToolApprovalMode): void
|
|
12
|
+
setApprovalCallback(cb: ApprovalCallback): void
|
|
13
|
+
setAutoApproveAll(value: boolean): void
|
|
14
|
+
chat(messages: Message[], systemPrompt: string, enableTools?: boolean): AsyncGenerator<ChatEvent>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect provider from model string.
|
|
19
|
+
* Convention: "provider:model" (e.g., "openai:gpt-4o", "ollama:llama3")
|
|
20
|
+
* Default: anthropic (no prefix needed).
|
|
21
|
+
*/
|
|
22
|
+
export function parseModelString(input: string): { provider: string; model: string } {
|
|
23
|
+
if (input.includes(':')) {
|
|
24
|
+
const [provider, ...rest] = input.split(':')
|
|
25
|
+
return { provider: provider.toLowerCase(), model: rest.join(':') }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Auto-detect from model name
|
|
29
|
+
const lower = input.toLowerCase()
|
|
30
|
+
if (lower.startsWith('gpt-') || lower.startsWith('o1') || lower.startsWith('o3')) {
|
|
31
|
+
return { provider: 'openai', model: input }
|
|
32
|
+
}
|
|
33
|
+
if (lower.startsWith('llama') || lower.startsWith('mistral') || lower.startsWith('codellama') || lower.startsWith('deepseek')) {
|
|
34
|
+
return { provider: 'ollama', model: input }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { provider: 'anthropic', model: input }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Available provider info for display.
|
|
42
|
+
*/
|
|
43
|
+
export const PROVIDER_INFO: Record<string, { name: string; envKey: string; description: string }> = {
|
|
44
|
+
anthropic: {
|
|
45
|
+
name: 'Anthropic',
|
|
46
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
47
|
+
description: 'Claude models (default)',
|
|
48
|
+
},
|
|
49
|
+
openai: {
|
|
50
|
+
name: 'OpenAI',
|
|
51
|
+
envKey: 'OPENAI_API_KEY',
|
|
52
|
+
description: 'GPT and o-series models',
|
|
53
|
+
},
|
|
54
|
+
ollama: {
|
|
55
|
+
name: 'Ollama',
|
|
56
|
+
envKey: '',
|
|
57
|
+
description: 'Local models via Ollama (no API key needed)',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatProviderList(): string {
|
|
62
|
+
const lines = ['Providers:']
|
|
63
|
+
for (const [key, info] of Object.entries(PROVIDER_INFO)) {
|
|
64
|
+
const keyInfo = info.envKey ? ` (${info.envKey})` : ' (local)'
|
|
65
|
+
lines.push(` ${key.padEnd(12)} ${info.description}${keyInfo}`)
|
|
66
|
+
}
|
|
67
|
+
lines.push('')
|
|
68
|
+
lines.push('Use: /model provider:model (e.g., /model openai:gpt-4o)')
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 529])
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_RETRIES = 3
|
|
4
|
+
const DEFAULT_BASE_DELAY_MS = 1000
|
|
5
|
+
|
|
6
|
+
interface RetryOptions {
|
|
7
|
+
maxRetries?: number
|
|
8
|
+
baseDelayMs?: number
|
|
9
|
+
signal?: AbortSignal
|
|
10
|
+
onRetry?: (attempt: number, waitMs: number, reason: string) => void
|
|
11
|
+
/** Called on 401 to attempt credential refresh. Return true if refresh succeeded. */
|
|
12
|
+
onAuthExpired?: () => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Retry a function with exponential backoff.
|
|
17
|
+
* Only retries on transient HTTP errors (429, 5xx).
|
|
18
|
+
*/
|
|
19
|
+
export async function withRetry<T>(
|
|
20
|
+
fn: () => Promise<T>,
|
|
21
|
+
opts: RetryOptions = {},
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES
|
|
24
|
+
const baseDelay = opts.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
|
|
25
|
+
|
|
26
|
+
let lastError: unknown
|
|
27
|
+
|
|
28
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return await fn()
|
|
31
|
+
} catch (err) {
|
|
32
|
+
lastError = err
|
|
33
|
+
|
|
34
|
+
if (opts.signal?.aborted) throw err
|
|
35
|
+
if (attempt >= maxRetries) throw err
|
|
36
|
+
|
|
37
|
+
// Handle auth expiration: try to refresh credentials once
|
|
38
|
+
if (isAuthError(err) && opts.onAuthExpired) {
|
|
39
|
+
const refreshed = opts.onAuthExpired()
|
|
40
|
+
if (refreshed) {
|
|
41
|
+
opts.onRetry?.(attempt + 1, 500, 'Auth refreshed, retrying...')
|
|
42
|
+
await sleep(500, opts.signal)
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!isRetryable(err)) throw err
|
|
48
|
+
|
|
49
|
+
const retryAfter = extractRetryAfter(err)
|
|
50
|
+
const waitMs = retryAfter ?? baseDelay * Math.pow(2, attempt)
|
|
51
|
+
|
|
52
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
53
|
+
opts.onRetry?.(attempt + 1, waitMs, reason)
|
|
54
|
+
|
|
55
|
+
await sleep(waitMs, opts.signal)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw lastError
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isAuthError(err: unknown): boolean {
|
|
63
|
+
if (!(err instanceof Error)) return false
|
|
64
|
+
const status = (err as { status?: number }).status
|
|
65
|
+
return status === 401
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRetryable(err: unknown): boolean {
|
|
69
|
+
if (!(err instanceof Error)) return false
|
|
70
|
+
|
|
71
|
+
// Anthropic SDK errors include a status property
|
|
72
|
+
const status = (err as { status?: number }).status
|
|
73
|
+
if (status && RETRYABLE_STATUS.has(status)) return true
|
|
74
|
+
|
|
75
|
+
// Network errors
|
|
76
|
+
const msg = err.message.toLowerCase()
|
|
77
|
+
if (msg.includes('econnreset') || msg.includes('econnrefused')) return true
|
|
78
|
+
if (msg.includes('etimedout') || msg.includes('socket hang up')) return true
|
|
79
|
+
if (msg.includes('overloaded')) return true
|
|
80
|
+
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractRetryAfter(err: unknown): number | null {
|
|
85
|
+
const headers = (err as { headers?: Record<string, string> }).headers
|
|
86
|
+
if (!headers) return null
|
|
87
|
+
|
|
88
|
+
const retryAfter = headers['retry-after']
|
|
89
|
+
if (!retryAfter) return null
|
|
90
|
+
|
|
91
|
+
const seconds = Number(retryAfter)
|
|
92
|
+
if (!isNaN(seconds) && seconds > 0) {
|
|
93
|
+
// Cap at 60 seconds to prevent hour-long sleeps
|
|
94
|
+
return Math.min(seconds, 60) * 1000
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const timer = setTimeout(resolve, ms)
|
|
103
|
+
signal?.addEventListener('abort', () => {
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
reject(new Error('Aborted'))
|
|
106
|
+
}, { once: true })
|
|
107
|
+
})
|
|
108
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { Session, Message } from './types'
|
|
4
|
+
|
|
5
|
+
export class SessionManager {
|
|
6
|
+
private sessionsDir: string
|
|
7
|
+
private current: Session
|
|
8
|
+
|
|
9
|
+
constructor(dataDir: string) {
|
|
10
|
+
this.sessionsDir = join(dataDir, 'sessions')
|
|
11
|
+
if (!existsSync(this.sessionsDir)) {
|
|
12
|
+
mkdirSync(this.sessionsDir, { recursive: true })
|
|
13
|
+
}
|
|
14
|
+
this.current = this.loadOrCreate('default')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get session(): Session {
|
|
18
|
+
return this.current
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get messages(): Message[] {
|
|
22
|
+
return this.current.messages
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addMessage(message: Message): void {
|
|
26
|
+
this.current.messages.push(message)
|
|
27
|
+
this.current.updated = Date.now()
|
|
28
|
+
this.save()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
trimHistory(maxHistory: number): void {
|
|
32
|
+
if (this.current.messages.length > maxHistory) {
|
|
33
|
+
this.current.messages = this.current.messages.slice(-maxHistory)
|
|
34
|
+
this.save()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clear(): void {
|
|
39
|
+
this.current.messages = []
|
|
40
|
+
this.current.updated = Date.now()
|
|
41
|
+
this.save()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove the last N messages and persist. Returns removed messages.
|
|
46
|
+
*/
|
|
47
|
+
popMessages(count: number): Message[] {
|
|
48
|
+
const removed = this.current.messages.splice(-count, count)
|
|
49
|
+
this.current.updated = Date.now()
|
|
50
|
+
this.save()
|
|
51
|
+
return removed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switchTo(name: string): Session {
|
|
55
|
+
this.current = this.loadOrCreate(name)
|
|
56
|
+
return this.current
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
list(): string[] {
|
|
60
|
+
if (!existsSync(this.sessionsDir)) return []
|
|
61
|
+
return readdirSync(this.sessionsDir)
|
|
62
|
+
.filter((f) => f.endsWith('.json'))
|
|
63
|
+
.map((f) => f.replace('.json', ''))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getInfo(name: string): { messageCount: number; updated: number } | null {
|
|
67
|
+
const path = join(this.sessionsDir, `${name}.json`)
|
|
68
|
+
if (!existsSync(path)) return null
|
|
69
|
+
try {
|
|
70
|
+
const data: Session = JSON.parse(readFileSync(path, 'utf-8'))
|
|
71
|
+
return { messageCount: data.messages.length, updated: data.updated }
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
delete(name: string): boolean {
|
|
78
|
+
const path = join(this.sessionsDir, `${name}.json`)
|
|
79
|
+
if (existsSync(path)) {
|
|
80
|
+
unlinkSync(path)
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fork the current session into a new one with a different name.
|
|
88
|
+
* Copies all messages. Returns the new session.
|
|
89
|
+
*/
|
|
90
|
+
fork(newName: string): Session {
|
|
91
|
+
const forked: Session = {
|
|
92
|
+
id: crypto.randomUUID(),
|
|
93
|
+
name: newName,
|
|
94
|
+
messages: [...this.current.messages],
|
|
95
|
+
created: Date.now(),
|
|
96
|
+
updated: Date.now(),
|
|
97
|
+
}
|
|
98
|
+
const path = join(this.sessionsDir, `${newName}.json`)
|
|
99
|
+
writeFileSync(path, JSON.stringify(forked, null, 2))
|
|
100
|
+
this.current = forked
|
|
101
|
+
return forked
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private loadOrCreate(name: string): Session {
|
|
105
|
+
const path = join(this.sessionsDir, `${name}.json`)
|
|
106
|
+
if (existsSync(path)) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
109
|
+
} catch {
|
|
110
|
+
// Corrupted session file — start fresh
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const session: Session = {
|
|
114
|
+
id: crypto.randomUUID(),
|
|
115
|
+
name,
|
|
116
|
+
messages: [],
|
|
117
|
+
created: Date.now(),
|
|
118
|
+
updated: Date.now(),
|
|
119
|
+
}
|
|
120
|
+
writeFileSync(path, JSON.stringify(session, null, 2))
|
|
121
|
+
return session
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private save(): void {
|
|
125
|
+
const path = join(this.sessionsDir, `${this.current.name}.json`)
|
|
126
|
+
writeFileSync(path, JSON.stringify(this.current, null, 2))
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { gatherContext } from './context'
|
|
4
|
+
|
|
5
|
+
export interface Skill {
|
|
6
|
+
name: string
|
|
7
|
+
content: string
|
|
8
|
+
source: 'global' | 'local'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load skills from a directory. Returns skills with source tag.
|
|
13
|
+
*/
|
|
14
|
+
function loadFromDir(dir: string, source: 'global' | 'local'): Skill[] {
|
|
15
|
+
if (!existsSync(dir)) return []
|
|
16
|
+
|
|
17
|
+
const skills: Skill[] = []
|
|
18
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
19
|
+
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
22
|
+
const content = readFileSync(join(dir, entry.name), 'utf-8')
|
|
23
|
+
skills.push({ name: entry.name.replace('.md', ''), content: content.trim(), source })
|
|
24
|
+
} else if (entry.isDirectory()) {
|
|
25
|
+
const skillFile = join(dir, entry.name, 'SKILL.md')
|
|
26
|
+
if (existsSync(skillFile)) {
|
|
27
|
+
const content = readFileSync(skillFile, 'utf-8')
|
|
28
|
+
skills.push({ name: entry.name, content: content.trim(), source })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return skills
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load skills from global dir + project-local .smolerclaw/skills/.
|
|
38
|
+
* Local skills override global skills with the same name.
|
|
39
|
+
*/
|
|
40
|
+
export function loadSkills(globalDir: string): Skill[] {
|
|
41
|
+
const globalSkills = loadFromDir(globalDir, 'global')
|
|
42
|
+
const localDir = join(process.cwd(), '.smolerclaw', 'skills')
|
|
43
|
+
const localSkills = loadFromDir(localDir, 'local')
|
|
44
|
+
|
|
45
|
+
// Merge: local overrides global by name
|
|
46
|
+
const merged = new Map<string, Skill>()
|
|
47
|
+
for (const s of globalSkills) merged.set(s.name, s)
|
|
48
|
+
for (const s of localSkills) merged.set(s.name, s) // override
|
|
49
|
+
|
|
50
|
+
return [...merged.values()]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format skill list for display with source labels.
|
|
55
|
+
*/
|
|
56
|
+
export function formatSkillList(skills: Skill[]): string {
|
|
57
|
+
if (skills.length === 0) return 'No skills loaded.'
|
|
58
|
+
return 'Skills:\n' + skills
|
|
59
|
+
.map((s) => ` ${s.name} [${s.source}]`)
|
|
60
|
+
.join('\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildSystemPrompt(
|
|
64
|
+
basePrompt: string,
|
|
65
|
+
skills: Skill[],
|
|
66
|
+
language: string = 'auto',
|
|
67
|
+
): string {
|
|
68
|
+
const parts: string[] = []
|
|
69
|
+
|
|
70
|
+
for (const skill of skills) {
|
|
71
|
+
parts.push(skill.content)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (language && language !== 'auto') {
|
|
75
|
+
const langNames: Record<string, string> = {
|
|
76
|
+
pt: 'Portuguese (Brazilian)',
|
|
77
|
+
en: 'English',
|
|
78
|
+
es: 'Spanish',
|
|
79
|
+
fr: 'French',
|
|
80
|
+
de: 'German',
|
|
81
|
+
it: 'Italian',
|
|
82
|
+
ja: 'Japanese',
|
|
83
|
+
ko: 'Korean',
|
|
84
|
+
zh: 'Chinese',
|
|
85
|
+
}
|
|
86
|
+
const langName = langNames[language] || language
|
|
87
|
+
parts.push(`## Language Override\nALWAYS respond in ${langName}. This is a hard requirement.`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
parts.push(
|
|
91
|
+
'---\n' +
|
|
92
|
+
'## Environment\n' +
|
|
93
|
+
'The user\'s current working directory and project info. Use this context when they ask about code or files.\n\n' +
|
|
94
|
+
gatherContext(),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (basePrompt) {
|
|
98
|
+
parts.push('## User Instructions\n' + basePrompt)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return parts.join('\n\n')
|
|
102
|
+
}
|