smolerclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/release.yml +67 -0
  3. package/bun.lock +33 -0
  4. package/dist/index.js +321 -0
  5. package/dist/tinyclaw.exe +0 -0
  6. package/install.ps1 +119 -0
  7. package/package.json +25 -0
  8. package/skills/business.md +77 -0
  9. package/skills/default.md +77 -0
  10. package/src/ansi.ts +164 -0
  11. package/src/approval.ts +74 -0
  12. package/src/auth.ts +125 -0
  13. package/src/briefing.ts +52 -0
  14. package/src/claude.ts +267 -0
  15. package/src/cli.ts +137 -0
  16. package/src/clipboard.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/context-window.ts +190 -0
  19. package/src/context.ts +125 -0
  20. package/src/decisions.ts +122 -0
  21. package/src/email.ts +123 -0
  22. package/src/errors.ts +78 -0
  23. package/src/export.ts +82 -0
  24. package/src/finance.ts +148 -0
  25. package/src/git.ts +62 -0
  26. package/src/history.ts +100 -0
  27. package/src/images.ts +68 -0
  28. package/src/index.ts +1431 -0
  29. package/src/investigate.ts +415 -0
  30. package/src/markdown.ts +125 -0
  31. package/src/memos.ts +191 -0
  32. package/src/models.ts +94 -0
  33. package/src/monitor.ts +169 -0
  34. package/src/morning.ts +108 -0
  35. package/src/news.ts +329 -0
  36. package/src/openai-provider.ts +127 -0
  37. package/src/people.ts +472 -0
  38. package/src/personas.ts +99 -0
  39. package/src/platform.ts +84 -0
  40. package/src/plugins.ts +125 -0
  41. package/src/pomodoro.ts +169 -0
  42. package/src/providers.ts +70 -0
  43. package/src/retry.ts +108 -0
  44. package/src/session.ts +128 -0
  45. package/src/skills.ts +102 -0
  46. package/src/tasks.ts +418 -0
  47. package/src/tokens.ts +102 -0
  48. package/src/tool-safety.ts +100 -0
  49. package/src/tools.ts +1479 -0
  50. package/src/tui.ts +693 -0
  51. package/src/types.ts +55 -0
  52. package/src/undo.ts +83 -0
  53. package/src/windows.ts +299 -0
  54. package/src/workflows.ts +197 -0
  55. package/tests/ansi.test.ts +58 -0
  56. package/tests/approval.test.ts +43 -0
  57. package/tests/briefing.test.ts +10 -0
  58. package/tests/cli.test.ts +53 -0
  59. package/tests/context-window.test.ts +83 -0
  60. package/tests/images.test.ts +28 -0
  61. package/tests/memos.test.ts +116 -0
  62. package/tests/models.test.ts +34 -0
  63. package/tests/news.test.ts +13 -0
  64. package/tests/path-guard.test.ts +37 -0
  65. package/tests/people.test.ts +204 -0
  66. package/tests/skills.test.ts +35 -0
  67. package/tests/ssrf.test.ts +80 -0
  68. package/tests/tasks.test.ts +152 -0
  69. package/tests/tokens.test.ts +44 -0
  70. package/tests/tool-safety.test.ts +55 -0
  71. package/tests/windows-security.test.ts +59 -0
  72. package/tests/windows.test.ts +20 -0
  73. package/tsconfig.json +19 -0
package/src/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
+ }
@@ -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
+ }
@@ -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
+ }