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/index.ts ADDED
@@ -0,0 +1,1431 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs, printHelp, getVersion } from './cli'
3
+ import { loadConfig, saveConfig, getConfigPath } from './config'
4
+ import { resolveAuth, refreshAuth, authLabel, type AuthResult } from './auth'
5
+ import { ClaudeProvider } from './claude'
6
+ import { SessionManager } from './session'
7
+ import { loadSkills, buildSystemPrompt, formatSkillList } from './skills'
8
+ import { TUI } from './tui'
9
+ import { TokenTracker } from './tokens'
10
+ import { exportToMarkdown } from './export'
11
+ import { resolveModel, formatModelList, modelDisplayName } from './models'
12
+ import { parseModelString, formatProviderList } from './providers'
13
+ import { OpenAICompatProvider } from './openai-provider'
14
+ import { gitDiff, gitStatus, gitStageAll, gitCommit, isGitRepo } from './git'
15
+ import { getPersona, formatPersonaList, type Persona } from './personas'
16
+ import { copyToClipboard } from './clipboard'
17
+ import { undoStack, registerPlugins, registerWindowsTools, TOOLS } from './tools'
18
+ import { loadPlugins, pluginsToTools, formatPluginList, getPluginDir } from './plugins'
19
+ import { formatApprovalPrompt, formatEditDiff } from './approval'
20
+ import { extractImages } from './images'
21
+ import { openApp, openFile, openUrl, getRunningApps, getSystemInfo, getDateTimeInfo, getOutlookEvents, getKnownApps } from './windows'
22
+ import { fetchNews, getNewsCategories, type NewsCategory } from './news'
23
+ import { generateBriefing } from './briefing'
24
+ import { initTasks, stopTasks, addTask, completeTask, removeTask, listTasks, formatTaskList, parseTime, type Task } from './tasks'
25
+ import { initPeople, addPerson, findPerson, listPeople, logInteraction, delegateTask, getDelegations, getPendingFollowUps, markFollowUpDone, formatPeopleList, formatPersonDetail, formatDelegationList, formatFollowUps, generatePeopleDashboard, type PersonGroup, type InteractionType } from './people'
26
+ import { initMemos, saveMemo, searchMemos, listMemos, deleteMemo, formatMemoList, formatMemoDetail, formatMemoTags } from './memos'
27
+ import { isFirstRunToday, markMorningDone, generateMorningBriefing } from './morning'
28
+ import { openEmailDraft, formatDraftPreview } from './email'
29
+ import { initPomodoro, startPomodoro, stopPomodoro, pomodoroStatus, stopPomodoroTimer } from './pomodoro'
30
+ import { initFinance, addTransaction, getMonthSummary, getRecentTransactions, removeTransaction } from './finance'
31
+ import { initDecisions, logDecision, searchDecisions, listDecisions, formatDecisionList, formatDecisionDetail } from './decisions'
32
+ import { initWorkflows, runWorkflow, listWorkflows, createWorkflow, deleteWorkflow, formatWorkflowList, type WorkflowStep } from './workflows'
33
+ import { initMonitor, startMonitor, stopMonitor, listMonitors, stopAllMonitors } from './monitor'
34
+ import { initInvestigations } from './investigate'
35
+ import { writeFileSync } from 'node:fs'
36
+ import { join } from 'node:path'
37
+ import type { Message, ToolCall } from './types'
38
+
39
+ async function main(): Promise<void> {
40
+ const cliArgs = parseArgs(process.argv.slice(2))
41
+
42
+ // ── Help / Version ───────────────────────────────────────
43
+ if (cliArgs.help) {
44
+ printHelp()
45
+ process.exit(0)
46
+ }
47
+ if (cliArgs.version) {
48
+ console.log(`smolerclaw v${getVersion()}`)
49
+ process.exit(0)
50
+ }
51
+
52
+ // ── Load config and auth ─────────────────────────────────
53
+ const config = loadConfig()
54
+ if (cliArgs.model) config.model = resolveModel(cliArgs.model)
55
+ if (cliArgs.maxTokens) config.maxTokens = cliArgs.maxTokens
56
+
57
+ let auth: AuthResult
58
+ try {
59
+ auth = resolveAuth(config.apiKey, config.authMode)
60
+ } catch (err) {
61
+ console.error('smolerclaw:', err instanceof Error ? err.message : err)
62
+ process.exit(1)
63
+ }
64
+
65
+ // Initialize provider based on model string
66
+ const { provider: providerType, model: providerModel } = parseModelString(config.model)
67
+ let claude: ClaudeProvider | OpenAICompatProvider
68
+
69
+ if (providerType === 'openai' || providerType === 'ollama') {
70
+ claude = new OpenAICompatProvider(providerType, providerModel, config.maxTokens)
71
+ } else {
72
+ const claudeProvider = new ClaudeProvider(auth.apiKey, config.model, config.maxTokens, config.toolApproval)
73
+
74
+ // Auto-refresh credentials on 401 so the session survives token expiration
75
+ claudeProvider.setAuthRefresh(() => {
76
+ const freshAuth = refreshAuth(config.apiKey, config.authMode)
77
+ if (freshAuth && freshAuth.apiKey !== auth.apiKey) {
78
+ auth = freshAuth
79
+ claudeProvider.updateApiKey(freshAuth.apiKey)
80
+ return true
81
+ }
82
+ return false
83
+ })
84
+
85
+ claude = claudeProvider
86
+ }
87
+ const sessionName = cliArgs.session || 'default'
88
+ const sessions = new SessionManager(config.dataDir)
89
+ if (cliArgs.session) sessions.switchTo(cliArgs.session)
90
+ const skills = loadSkills(config.skillsDir)
91
+ const systemPrompt = buildSystemPrompt(config.systemPrompt, skills, config.language)
92
+ const enableTools = !cliArgs.noTools
93
+
94
+ // Register Windows/business tools
95
+ registerWindowsTools()
96
+
97
+ // Load plugins
98
+ const pluginDir = getPluginDir(join(config.dataDir, '..'))
99
+ const plugins = loadPlugins(pluginDir)
100
+ if (plugins.length > 0) {
101
+ registerPlugins(plugins)
102
+ TOOLS.push(...pluginsToTools(plugins))
103
+ }
104
+
105
+ // ── Pipe mode: stdin is not a TTY ────────────────────────
106
+ const isPiped = !process.stdin.isTTY
107
+
108
+ if (cliArgs.print || isPiped) {
109
+ await runPrintMode(claude, sessions, systemPrompt, enableTools, cliArgs.prompt, isPiped)
110
+ process.exit(0)
111
+ }
112
+
113
+ // ── Interactive TUI mode ─────────────────────────────────
114
+ await runInteractive(claude, sessions, config, auth, skills, systemPrompt, enableTools, plugins, cliArgs.prompt)
115
+ }
116
+
117
+ // ─── Print Mode ───────────────────────────────────────────────
118
+
119
+ // Common provider interface for both Claude and OpenAI-compatible
120
+ type AnyProvider = { chat: ClaudeProvider['chat']; setModel: (m: string) => void; setApprovalCallback?: ClaudeProvider['setApprovalCallback']; setAutoApproveAll?: ClaudeProvider['setAutoApproveAll'] }
121
+
122
+ async function runPrintMode(
123
+ claude: AnyProvider,
124
+ sessions: SessionManager,
125
+ systemPrompt: string,
126
+ enableTools: boolean,
127
+ prompt?: string,
128
+ isPiped?: boolean,
129
+ ): Promise<void> {
130
+ let input = prompt || ''
131
+
132
+ // Read stdin if piped
133
+ if (isPiped) {
134
+ const stdinText = await readStdin()
135
+ input = input ? `${input}\n\n${stdinText}` : stdinText
136
+ }
137
+
138
+ if (!input.trim()) {
139
+ console.error('smolerclaw: no input provided')
140
+ process.exit(1)
141
+ }
142
+
143
+ const userMsg: Message = { role: 'user', content: input.trim(), timestamp: Date.now() }
144
+ sessions.addMessage(userMsg)
145
+
146
+ let fullText = ''
147
+ for await (const event of claude.chat(sessions.messages, systemPrompt, enableTools)) {
148
+ if (event.type === 'text') {
149
+ process.stdout.write(event.text)
150
+ fullText += event.text
151
+ } else if (event.type === 'error') {
152
+ console.error(`\nsmolerclaw error: ${event.error}`)
153
+ }
154
+ }
155
+
156
+ // Ensure trailing newline
157
+ if (fullText && !fullText.endsWith('\n')) {
158
+ process.stdout.write('\n')
159
+ }
160
+
161
+ const assistantMsg: Message = { role: 'assistant', content: fullText, timestamp: Date.now() }
162
+ sessions.addMessage(assistantMsg)
163
+ }
164
+
165
+ async function readStdin(): Promise<string> {
166
+ const chunks: Uint8Array[] = []
167
+ for await (const chunk of process.stdin) {
168
+ chunks.push(chunk)
169
+ }
170
+ return Buffer.concat(chunks).toString('utf-8')
171
+ }
172
+
173
+ // ─── Interactive Mode ─────────────────────────────────────────
174
+
175
+ async function runInteractive(
176
+ claude: AnyProvider,
177
+ sessions: SessionManager,
178
+ config: ReturnType<typeof loadConfig>,
179
+ auth: AuthResult,
180
+ skills: ReturnType<typeof loadSkills>,
181
+ systemPrompt: string,
182
+ enableTools: boolean,
183
+ plugins: ReturnType<typeof loadPlugins>,
184
+ initialPrompt?: string,
185
+ ): Promise<void> {
186
+ const tracker = new TokenTracker(config.model)
187
+ const tui = new TUI(config.model, sessions.session.name, authLabel(auth), config.dataDir)
188
+ let currentPersona = 'default'
189
+ let activeSystemPrompt = systemPrompt
190
+
191
+ // Initialize people, task, and memo systems
192
+ initPeople(config.dataDir)
193
+ initMemos(config.dataDir)
194
+ initFinance(config.dataDir)
195
+ initDecisions(config.dataDir)
196
+ initPomodoro((msg) => tui.showSystem(`\n*** ${msg} ***\n`))
197
+ initWorkflows(config.dataDir)
198
+ initInvestigations(config.dataDir)
199
+ initMonitor((msg) => tui.showSystem(`\n*** ${msg} ***\n`))
200
+ initTasks(config.dataDir, (task: Task) => {
201
+ tui.showSystem(`\n*** LEMBRETE: ${task.title} ***\n`)
202
+ })
203
+
204
+ // Wire tool approval callback
205
+ if (config.toolApproval !== 'auto' && claude.setApprovalCallback) {
206
+ claude.setApprovalCallback(async (toolName, input, riskLevel) => {
207
+ // Show diff preview for edit_file
208
+ if (toolName === 'edit_file' && input.old_text && input.new_text) {
209
+ const diffLines = formatEditDiff(String(input.old_text), String(input.new_text))
210
+ for (const line of diffLines) {
211
+ tui.showSystem(line)
212
+ }
213
+ }
214
+ const desc = formatApprovalPrompt(toolName, input)
215
+ const approved = await tui.promptApproval(desc)
216
+ if (tui._approveAllRequested) {
217
+ claude.setAutoApproveAll?.(true)
218
+ tui._approveAllRequested = false
219
+ }
220
+ return approved
221
+ })
222
+ }
223
+
224
+ // Restore existing messages
225
+ for (const msg of sessions.messages) {
226
+ if (msg.role === 'user') tui.addUserMessage(msg.content)
227
+ else tui.addAssistantMessage(msg.content)
228
+ }
229
+
230
+ let activeAbort: AbortController | null = null
231
+
232
+ async function handleSubmit(input: string): Promise<void> {
233
+ if (input.startsWith('/')) {
234
+ await handleCommand(input)
235
+ return
236
+ }
237
+
238
+ // Cost budget check
239
+ if (config.maxSessionCost > 0) {
240
+ const spent = tracker.totals.costCents
241
+ if (spent >= config.maxSessionCost) {
242
+ tui.showError(`Budget exceeded (~$${(spent / 100).toFixed(4)} / $${(config.maxSessionCost / 100).toFixed(4)}). Use /budget <cents> to increase or /clear to reset.`)
243
+ return
244
+ }
245
+ if (spent >= config.maxSessionCost * 0.8) {
246
+ tui.showSystem(`Budget: ${Math.round((spent / config.maxSessionCost) * 100)}% used`)
247
+ }
248
+ }
249
+
250
+ // Extract image attachments from input
251
+ const { text: cleanedInput, images } = extractImages(input)
252
+ const userMsg: Message = {
253
+ role: 'user',
254
+ content: cleanedInput,
255
+ images: images.length > 0 ? images.map((i) => ({ mediaType: i.mediaType, base64: i.base64 })) : undefined,
256
+ timestamp: Date.now(),
257
+ }
258
+ sessions.addMessage(userMsg)
259
+ tui.addUserMessage(images.length > 0 ? `${cleanedInput} (${images.length} image${images.length > 1 ? 's' : ''})` : cleanedInput)
260
+ tui.disableInput()
261
+
262
+ tui.startStream()
263
+ let fullText = ''
264
+ const toolCalls: ToolCall[] = []
265
+ let pendingToolInput: Record<string, unknown> = {}
266
+ let totalInput = 0
267
+ let totalOutput = 0
268
+ activeAbort = new AbortController()
269
+
270
+ try {
271
+ for await (const event of claude.chat(sessions.messages, activeSystemPrompt, enableTools)) {
272
+ if (activeAbort.signal.aborted) break
273
+
274
+ switch (event.type) {
275
+ case 'text':
276
+ tui.appendStream(event.text)
277
+ fullText += event.text
278
+ break
279
+
280
+ case 'tool_call':
281
+ tui.flushStream()
282
+ tui.showToolCall(event.name, event.input)
283
+ pendingToolInput = event.input as Record<string, unknown>
284
+ break
285
+
286
+ case 'tool_result':
287
+ tui.showToolResult(event.name, event.result)
288
+ toolCalls.push({
289
+ id: event.id,
290
+ name: event.name,
291
+ input: pendingToolInput,
292
+ result: event.result,
293
+ })
294
+ pendingToolInput = {}
295
+ tui.resetStreamBuffer()
296
+ break
297
+
298
+ case 'tool_blocked':
299
+ tui.showError(event.reason)
300
+ break
301
+
302
+ case 'usage':
303
+ totalInput += event.inputTokens
304
+ totalOutput += event.outputTokens
305
+ break
306
+
307
+ case 'error':
308
+ tui.showError(event.error)
309
+ break
310
+
311
+ case 'done':
312
+ break
313
+ }
314
+ }
315
+ } catch (err) {
316
+ if (!activeAbort.signal.aborted) {
317
+ tui.showError(err instanceof Error ? err.message : String(err))
318
+ }
319
+ }
320
+
321
+ activeAbort = null
322
+ tui.endStream()
323
+
324
+ // Track and display token usage
325
+ const usage = { inputTokens: totalInput, outputTokens: totalOutput }
326
+ const cost = tracker.add(usage)
327
+
328
+ if (totalInput > 0 || totalOutput > 0) {
329
+ tui.showUsage(tracker.formatUsage(usage))
330
+ tui.updateSessionCost(`~$${(tracker.totals.costCents / 100).toFixed(4)}`)
331
+ }
332
+
333
+ const assistantMsg: Message = {
334
+ role: 'assistant',
335
+ content: fullText,
336
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
337
+ usage: totalInput > 0 ? { inputTokens: totalInput, outputTokens: totalOutput, costCents: cost.totalCostCents } : undefined,
338
+ timestamp: Date.now(),
339
+ }
340
+ sessions.addMessage(assistantMsg)
341
+ sessions.trimHistory(config.maxHistory)
342
+ tui.enableInput()
343
+ }
344
+
345
+ async function handleCommand(input: string): Promise<void> {
346
+ const parts = input.slice(1).split(' ')
347
+ const cmd = parts[0].toLowerCase()
348
+ const args = parts.slice(1)
349
+
350
+ switch (cmd) {
351
+ case 'exit':
352
+ case 'quit':
353
+ case 'sair':
354
+ case 'q':
355
+ cleanup()
356
+ break
357
+
358
+ case 'clear':
359
+ case 'limpar':
360
+ sessions.clear()
361
+ tui.clearMessages()
362
+ tui.showSystem('Conversation cleared.')
363
+ break
364
+
365
+ case 'new':
366
+ case 'novo':
367
+ case 'nova': {
368
+ const name = args[0] || `s-${Date.now()}`
369
+ sessions.switchTo(name)
370
+ tui.clearMessages()
371
+ tui.updateSession(name)
372
+ tui.showSystem(`New session: ${name}`)
373
+ break
374
+ }
375
+
376
+ case 'load':
377
+ case 'carregar': {
378
+ const name = args[0]
379
+ if (!name) {
380
+ tui.showError('Usage: /load <name>')
381
+ break
382
+ }
383
+ sessions.switchTo(name)
384
+ tui.clearMessages()
385
+ for (const msg of sessions.messages) {
386
+ if (msg.role === 'user') tui.addUserMessage(msg.content)
387
+ else tui.addAssistantMessage(msg.content)
388
+ }
389
+ tui.updateSession(name)
390
+ tui.showSystem(`Loaded: ${name}`)
391
+ break
392
+ }
393
+
394
+ case 'sessions':
395
+ case 'sessoes':
396
+ case 'ls': {
397
+ const list = sessions.list()
398
+ if (list.length === 0) {
399
+ tui.showSystem('No saved sessions.')
400
+ break
401
+ }
402
+ const details = list.map((name) => {
403
+ const info = sessions.getInfo(name)
404
+ const marker = name === sessions.session.name ? ' *' : ' '
405
+ const age = info ? formatAge(info.updated) : ''
406
+ const msgs = info ? `${info.messageCount} msgs` : ''
407
+ return `${marker} ${name.padEnd(20)} ${msgs.padEnd(10)} ${age}`
408
+ })
409
+ tui.showSystem('Sessions:\n' + details.join('\n'))
410
+ break
411
+ }
412
+
413
+ case 'delete':
414
+ case 'deletar':
415
+ case 'rm': {
416
+ const name = args[0]
417
+ if (!name) {
418
+ tui.showError('Usage: /delete <name>')
419
+ break
420
+ }
421
+ if (sessions.delete(name)) {
422
+ tui.showSystem(`Deleted: ${name}`)
423
+ } else {
424
+ tui.showError(`Session not found: ${name}`)
425
+ }
426
+ break
427
+ }
428
+
429
+ case 'model':
430
+ case 'modelo': {
431
+ const m = args[0]
432
+ if (!m) {
433
+ tui.showSystem(formatModelList(config.model) + '\n\n' + formatProviderList())
434
+ break
435
+ }
436
+ const { provider, model: modelName } = parseModelString(m)
437
+ const resolved = provider === 'anthropic' ? resolveModel(modelName) : modelName
438
+ config.model = provider === 'anthropic' ? resolved : `${provider}:${resolved}`
439
+ saveConfig(config)
440
+ if (provider === 'anthropic') {
441
+ claude.setModel(resolved)
442
+ } else {
443
+ // For non-anthropic providers, show info but keep using claude for now
444
+ // Full provider switch requires restarting the provider instance
445
+ tui.showSystem(`Note: ${provider} provider selected. Restart smolerclaw for full provider switch.`)
446
+ }
447
+ tracker.setModel(resolved)
448
+ tui.updateModel(config.model)
449
+ tui.showSystem(`Model -> ${config.model}`)
450
+ break
451
+ }
452
+
453
+ case 'skills':
454
+ case 'habilidades': {
455
+ tui.showSystem(formatSkillList(skills))
456
+ break
457
+ }
458
+
459
+ case 'auth':
460
+ tui.showSystem(
461
+ `Auth: ${auth.source}` +
462
+ (auth.subscriptionType ? ` (${auth.subscriptionType})` : '') +
463
+ (auth.expiresAt
464
+ ? `\nExpires: ${new Date(auth.expiresAt).toLocaleString()}`
465
+ : ''),
466
+ )
467
+ break
468
+
469
+ case 'config':
470
+ tui.showSystem(`Config: ${getConfigPath()}`)
471
+ break
472
+
473
+ case 'export':
474
+ case 'exportar': {
475
+ const datePart = new Date().toISOString().split('T')[0]
476
+ const exportPath = args[0] || `smolerclaw-${sessions.session.name}-${datePart}.md`
477
+ try {
478
+ const md = exportToMarkdown(sessions.session)
479
+ writeFileSync(exportPath, md)
480
+ tui.showSystem(`Exported to: ${exportPath}`)
481
+ } catch (err) {
482
+ tui.showError(`Export failed: ${err instanceof Error ? err.message : err}`)
483
+ }
484
+ break
485
+ }
486
+
487
+ case 'cost':
488
+ case 'custo':
489
+ tui.showSystem(`Session: ${tracker.formatSession()}`)
490
+ break
491
+
492
+ case 'retry':
493
+ case 'repetir': {
494
+ const lastUserMsg = [...sessions.messages].reverse().find((m) => m.role === 'user')
495
+ if (!lastUserMsg) {
496
+ tui.showError('No previous message to retry.')
497
+ break
498
+ }
499
+ // Remove last exchange (assistant + user) via safe method that persists
500
+ const msgs = sessions.messages
501
+ let toPop = 0
502
+ if (msgs.length > 0 && msgs[msgs.length - 1].role === 'assistant') toPop++
503
+ if (msgs.length > toPop && msgs[msgs.length - 1 - toPop].role === 'user') toPop++
504
+ if (toPop > 0) sessions.popMessages(toPop)
505
+
506
+ tui.showSystem('Retrying...')
507
+ // Reconstruct input with image references if present
508
+ const retryInput = lastUserMsg.images?.length
509
+ ? lastUserMsg.content // images are stored in session, will be picked up again
510
+ : lastUserMsg.content
511
+ await handleSubmit(retryInput)
512
+ break
513
+ }
514
+
515
+ case 'help':
516
+ case 'ajuda':
517
+ case '?':
518
+ tui.showSystem(
519
+ [
520
+ 'Comandos / Commands (en | pt):',
521
+ ' /help /ajuda Mostrar ajuda',
522
+ ' /clear /limpar Limpar conversa',
523
+ ' /new /novo Nova sessao',
524
+ ' /load /carregar Carregar sessao',
525
+ ' /sessions /sessoes Listar sessoes',
526
+ ' /delete /deletar Deletar sessao',
527
+ ' /model /modelo Ver/trocar modelo',
528
+ ' /persona /modo Trocar modo (business, coder...)',
529
+ ' /export /exportar Exportar para markdown',
530
+ ' /copy /copiar Copiar ultima resposta',
531
+ ' /cost /custo Ver uso de tokens',
532
+ ' /retry /repetir Repetir ultima msg',
533
+ ' /undo /desfazer Desfazer alteracao',
534
+ ' /search /buscar Buscar na conversa',
535
+ ' /lang /idioma Definir idioma',
536
+ ' /commit /commitar Git commit com IA',
537
+ ' /exit /sair Sair',
538
+ '',
539
+ 'Negocios / Business:',
540
+ ' /briefing /resumo Briefing diario',
541
+ ' /news /noticias Radar de noticias',
542
+ ' /open /abrir Abrir app Windows',
543
+ ' /apps /programas Apps em execucao',
544
+ ' /sysinfo /sistema Recursos do sistema',
545
+ ' /calendar /agenda Calendario Outlook',
546
+ '',
547
+ 'Pessoas / People:',
548
+ ' /addperson /novapessoa Cadastrar pessoa',
549
+ ' /people /pessoas Listar todas',
550
+ ' /team /equipe Listar equipe',
551
+ ' /family /familia Listar familia',
552
+ ' /person /pessoa Detalhes de alguem',
553
+ ' /delegate /delegar Delegar tarefa',
554
+ ' /delegations /delegacoes Listar delegacoes',
555
+ ' /followups Follow-ups pendentes',
556
+ ' /dashboard /painel Painel geral',
557
+ '',
558
+ 'Monitor:',
559
+ ' /monitor /vigiar Monitorar processo (ex: /monitor nginx)',
560
+ ' /monitor stop <nome> Parar monitoramento',
561
+ '',
562
+ 'Workflows:',
563
+ ' /workflow /fluxo Listar workflows',
564
+ ' /workflow run <nome> Executar (ex: /workflow iniciar-dia)',
565
+ '',
566
+ 'Pomodoro:',
567
+ ' /pomodoro /foco Iniciar (ex: /foco revisar codigo)',
568
+ ' /pomodoro status Ver tempo restante',
569
+ ' /pomodoro stop Parar',
570
+ '',
571
+ 'Financas / Finance:',
572
+ ' /entrada <$> <cat> Registrar entrada',
573
+ ' /saida <$> <cat> Registrar saida',
574
+ ' /finance /balanco Resumo mensal',
575
+ '',
576
+ 'Decisoes / Decisions:',
577
+ ' /decisoes [busca] Listar/buscar decisoes',
578
+ '',
579
+ 'Email:',
580
+ ' /email /rascunho Rascunho (ex: /email joao@x.com oi | texto)',
581
+ '',
582
+ 'Memos / Notes:',
583
+ ' /memo /anotar Salvar memo (ex: /memo senha wifi #casa)',
584
+ ' /memos /notas Buscar memos (ex: /memos docker)',
585
+ ' /tags /memotags Listar tags',
586
+ ' /rmmemo /rmnota Remover memo',
587
+ '',
588
+ 'Investigacao / Investigation:',
589
+ ' /investigar /investigate Listar investigacoes',
590
+ ' /investigar <busca> Buscar por palavra-chave',
591
+ '',
592
+ 'Tarefas / Tasks:',
593
+ ' /task /tarefa Criar tarefa (ex: /tarefa 18h buscar pao)',
594
+ ' /tasks /tarefas Listar pendentes',
595
+ ' /done /feito Marcar como concluida',
596
+ ' /rmtask /rmtarefa Remover tarefa',
597
+ '',
598
+ 'Tab completes commands. Use \\ at end of line for multi-line.',
599
+ '',
600
+ 'Keys:',
601
+ ' Ctrl+C Cancel stream / exit',
602
+ ' Ctrl+D Exit',
603
+ ' Ctrl+L Redraw screen',
604
+ ' Up/Down Input history',
605
+ ' PgUp/PgDown Scroll messages',
606
+ ].join('\n'),
607
+ )
608
+ break
609
+
610
+ case 'commit':
611
+ case 'commitar': {
612
+ if (!await isGitRepo()) {
613
+ tui.showError('Not a git repository.')
614
+ break
615
+ }
616
+ const status = await gitStatus()
617
+ if (status === '(clean)') {
618
+ tui.showSystem('Nothing to commit — working tree clean.')
619
+ break
620
+ }
621
+ tui.showSystem('Changes:\n' + status)
622
+ tui.disableInput()
623
+
624
+ try {
625
+ // Get diff and generate commit message via AI
626
+ const diff = await gitDiff()
627
+ const commitPrompt = `Generate a concise git commit message for these changes. Use conventional commits format (feat:, fix:, refactor:, docs:, chore:, etc.). One line, max 72 chars. No quotes. Just the message.\n\nDiff:\n${diff.slice(0, 8000)}`
628
+
629
+ tui.startStream()
630
+ let commitMsg = ''
631
+ for await (const event of claude.chat(
632
+ [{ role: 'user', content: commitPrompt, timestamp: Date.now() }],
633
+ 'You generate git commit messages. Output ONLY the commit message, nothing else.',
634
+ false,
635
+ )) {
636
+ if (event.type === 'text') {
637
+ commitMsg += event.text
638
+ tui.appendStream(event.text)
639
+ } else if (event.type === 'error') {
640
+ tui.showError(event.error)
641
+ }
642
+ }
643
+ tui.endStream()
644
+
645
+ commitMsg = commitMsg.trim().replace(/^["']|["']$/g, '')
646
+
647
+ // Guard: don't stage/commit with empty message
648
+ if (!commitMsg) {
649
+ tui.showError('Failed to generate commit message. Aborting.')
650
+ break
651
+ }
652
+
653
+ // Stage and commit
654
+ await gitStageAll()
655
+ const result = await gitCommit(commitMsg)
656
+ if (result.ok) {
657
+ tui.showSystem(`Committed: ${commitMsg}`)
658
+ } else {
659
+ tui.showError(`Commit failed: ${result.output}`)
660
+ }
661
+ } catch (err) {
662
+ tui.showError(`Commit error: ${err instanceof Error ? err.message : String(err)}`)
663
+ }
664
+ tui.enableInput()
665
+ break
666
+ }
667
+
668
+ case 'persona':
669
+ case 'modo': {
670
+ const name = args[0]
671
+ if (!name) {
672
+ tui.showSystem(formatPersonaList(currentPersona))
673
+ break
674
+ }
675
+ const persona = getPersona(name)
676
+ if (!persona) {
677
+ tui.showError(`Unknown persona: ${name}. Try /persona to see options.`)
678
+ break
679
+ }
680
+ currentPersona = persona.name
681
+ if (persona.systemPrompt) {
682
+ activeSystemPrompt = buildSystemPrompt(persona.systemPrompt, skills, config.language)
683
+ } else {
684
+ activeSystemPrompt = systemPrompt
685
+ }
686
+ tui.showSystem(`Persona -> ${persona.name}: ${persona.description}`)
687
+ break
688
+ }
689
+
690
+ case 'copy':
691
+ case 'copiar': {
692
+ // Copy last assistant message to clipboard
693
+ const lastAssistant = [...sessions.messages].reverse().find((m) => m.role === 'assistant')
694
+ if (!lastAssistant) {
695
+ tui.showError('No assistant message to copy.')
696
+ break
697
+ }
698
+ const ok = await copyToClipboard(lastAssistant.content)
699
+ if (ok) {
700
+ tui.showSystem('Copied last response to clipboard.')
701
+ } else {
702
+ tui.showError('Failed to copy. Is xclip/pbcopy available?')
703
+ }
704
+ break
705
+ }
706
+
707
+ case 'ask':
708
+ case 'perguntar': {
709
+ const question = args.join(' ')
710
+ if (!question) {
711
+ tui.showError('Usage: /ask <question>')
712
+ break
713
+ }
714
+ tui.addUserMessage(`(ephemeral) ${question}`)
715
+ tui.disableInput()
716
+ tui.startStream()
717
+ let askText = ''
718
+ // Send as isolated message — not saved to session, no tools
719
+ for await (const event of claude.chat(
720
+ [{ role: 'user', content: question, timestamp: Date.now() }],
721
+ activeSystemPrompt,
722
+ false,
723
+ )) {
724
+ if (event.type === 'text') {
725
+ askText += event.text
726
+ tui.appendStream(event.text)
727
+ } else if (event.type === 'error') {
728
+ tui.showError(event.error)
729
+ } else if (event.type === 'usage') {
730
+ // Show usage inline but don't track in session
731
+ tui.showUsage(`${event.inputTokens} in / ${event.outputTokens} out (ephemeral)`)
732
+ }
733
+ }
734
+ tui.endStream()
735
+ tui.enableInput()
736
+ break
737
+ }
738
+
739
+ case 'fork': {
740
+ const forkName = args[0] || `fork-${Date.now()}`
741
+ sessions.fork(forkName)
742
+ tui.updateSession(forkName)
743
+ tui.showSystem(`Forked session -> ${forkName} (${sessions.messages.length} messages copied)`)
744
+ break
745
+ }
746
+
747
+ case 'plugins': {
748
+ tui.showSystem(formatPluginList(plugins))
749
+ break
750
+ }
751
+
752
+ case 'budget':
753
+ case 'orcamento': {
754
+ const val = args[0]
755
+ if (!val) {
756
+ const max = config.maxSessionCost
757
+ const spent = tracker.totals.costCents
758
+ if (max === 0) {
759
+ tui.showSystem(`Budget: unlimited (spent ~$${(spent / 100).toFixed(4)})`)
760
+ } else {
761
+ const pct = Math.round((spent / max) * 100)
762
+ tui.showSystem(`Budget: ~$${(spent / 100).toFixed(4)} / $${(max / 100).toFixed(4)} (${pct}%)`)
763
+ }
764
+ break
765
+ }
766
+ const cents = Number(val)
767
+ if (isNaN(cents) || cents < 0) {
768
+ tui.showError('Usage: /budget <cents> (e.g., /budget 50 for $0.50)')
769
+ break
770
+ }
771
+ config.maxSessionCost = cents
772
+ saveConfig(config)
773
+ tui.showSystem(cents === 0 ? 'Budget: unlimited' : `Budget set: $${(cents / 100).toFixed(2)}`)
774
+ break
775
+ }
776
+
777
+ case 'undo':
778
+ case 'desfazer': {
779
+ const peek = undoStack.peek()
780
+ if (!peek) {
781
+ tui.showError('Nothing to undo.')
782
+ break
783
+ }
784
+ const result = undoStack.undo()
785
+ if (result) {
786
+ tui.showSystem(result)
787
+ }
788
+ break
789
+ }
790
+
791
+ case 'search':
792
+ case 'buscar': {
793
+ const query = args.join(' ').toLowerCase()
794
+ if (!query) {
795
+ tui.showError('Usage: /search <text>')
796
+ break
797
+ }
798
+ const matches: string[] = []
799
+ for (const msg of sessions.messages) {
800
+ if (msg.content.toLowerCase().includes(query)) {
801
+ const preview = msg.content.slice(0, 100).replace(/\n/g, ' ')
802
+ const ts = new Date(msg.timestamp).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
803
+ matches.push(` [${msg.role} ${ts}] ${preview}${msg.content.length > 100 ? '...' : ''}`)
804
+ }
805
+ }
806
+ tui.showSystem(
807
+ matches.length > 0
808
+ ? `Found ${matches.length} match${matches.length > 1 ? 'es' : ''}:\n${matches.join('\n')}`
809
+ : `No matches for "${query}".`,
810
+ )
811
+ break
812
+ }
813
+
814
+ case 'lang':
815
+ case 'language':
816
+ case 'idioma': {
817
+ const lang = args[0]
818
+ if (!lang) {
819
+ tui.showSystem(`Language: ${config.language} (auto = match user's language)`)
820
+ break
821
+ }
822
+ config.language = lang
823
+ saveConfig(config)
824
+ tui.showSystem(`Language -> ${lang}`)
825
+ break
826
+ }
827
+
828
+ // ── Business assistant commands ──────────────────────
829
+
830
+ case 'briefing':
831
+ case 'resumo': {
832
+ tui.showSystem('Carregando briefing...')
833
+ tui.disableInput()
834
+ try {
835
+ const briefing = await generateBriefing()
836
+ tui.showSystem(briefing)
837
+ } catch (err) {
838
+ tui.showError(`Briefing falhou: ${err instanceof Error ? err.message : String(err)}`)
839
+ }
840
+ tui.enableInput()
841
+ break
842
+ }
843
+
844
+ case 'news':
845
+ case 'noticias': {
846
+ const category = args[0] as NewsCategory | undefined
847
+ const validCats: NewsCategory[] = ['business', 'tech', 'finance', 'brazil', 'world']
848
+ if (category && !validCats.includes(category)) {
849
+ tui.showSystem(getNewsCategories())
850
+ break
851
+ }
852
+ tui.showSystem('Buscando noticias...')
853
+ tui.disableInput()
854
+ try {
855
+ const news = await fetchNews(category ? [category] : undefined)
856
+ tui.showSystem(news)
857
+ } catch (err) {
858
+ tui.showError(`Falha ao buscar noticias: ${err instanceof Error ? err.message : String(err)}`)
859
+ }
860
+ tui.enableInput()
861
+ break
862
+ }
863
+
864
+ case 'open':
865
+ case 'abrir': {
866
+ const appName = args.join(' ')
867
+ if (!appName) {
868
+ tui.showSystem(`Apps disponiveis: ${getKnownApps().join(', ')}\nUso: /open <app> ou /open <app> <arquivo>`)
869
+ break
870
+ }
871
+ // Check if second arg looks like a file path
872
+ const appArg = args.length > 1 ? args.slice(1).join(' ') : undefined
873
+ const result = await openApp(args[0], appArg)
874
+ tui.showSystem(result)
875
+ break
876
+ }
877
+
878
+ case 'openfile':
879
+ case 'abrirarquivo': {
880
+ const filePath = args.join(' ')
881
+ if (!filePath) {
882
+ tui.showError('Uso: /openfile <caminho>')
883
+ break
884
+ }
885
+ const result = await openFile(filePath)
886
+ tui.showSystem(result)
887
+ break
888
+ }
889
+
890
+ case 'openurl': {
891
+ const url = args[0]
892
+ if (!url) {
893
+ tui.showError('Uso: /openurl <url>')
894
+ break
895
+ }
896
+ const result = await openUrl(url)
897
+ tui.showSystem(result)
898
+ break
899
+ }
900
+
901
+ case 'apps':
902
+ case 'programas': {
903
+ tui.disableInput()
904
+ try {
905
+ const result = await getRunningApps()
906
+ tui.showSystem(result)
907
+ } catch (err) {
908
+ tui.showError(`Apps: ${err instanceof Error ? err.message : String(err)}`)
909
+ }
910
+ tui.enableInput()
911
+ break
912
+ }
913
+
914
+ case 'sysinfo':
915
+ case 'sistema': {
916
+ tui.disableInput()
917
+ try {
918
+ const result = await getSystemInfo()
919
+ tui.showSystem(result)
920
+ } catch (err) {
921
+ tui.showError(`Sysinfo: ${err instanceof Error ? err.message : String(err)}`)
922
+ }
923
+ tui.enableInput()
924
+ break
925
+ }
926
+
927
+ case 'calendar':
928
+ case 'calendario':
929
+ case 'agenda':
930
+ case 'cal': {
931
+ tui.disableInput()
932
+ try {
933
+ const dateInfo = await getDateTimeInfo()
934
+ const events = await getOutlookEvents()
935
+ tui.showSystem(`${dateInfo}\n\n--- Agenda ---\n${events}`)
936
+ } catch (err) {
937
+ tui.showError(`Calendar: ${err instanceof Error ? err.message : String(err)}`)
938
+ }
939
+ tui.enableInput()
940
+ break
941
+ }
942
+
943
+ // ── Monitor commands ───────────────────────────────────
944
+
945
+ case 'monitor':
946
+ case 'vigiar': {
947
+ const sub = args[0]?.toLowerCase()
948
+ if (!sub || sub === 'list' || sub === 'listar') {
949
+ tui.showSystem(listMonitors())
950
+ } else if (sub === 'stop' || sub === 'parar') {
951
+ const name = args[1]
952
+ if (!name) { tui.showError('Uso: /monitor stop <processo>'); break }
953
+ tui.showSystem(stopMonitor(name))
954
+ } else {
955
+ // Start monitoring
956
+ const intervalSec = parseInt(args[1]) || 60
957
+ tui.showSystem(startMonitor(sub, intervalSec))
958
+ }
959
+ break
960
+ }
961
+
962
+ // ── Workflow commands ──────────────────────────────────
963
+
964
+ case 'workflow':
965
+ case 'fluxo': {
966
+ const sub = args[0]?.toLowerCase()
967
+ if (!sub || sub === 'list' || sub === 'listar') {
968
+ tui.showSystem(formatWorkflowList())
969
+ } else if (sub === 'run' || sub === 'rodar') {
970
+ const name = args[1]
971
+ if (!name) {
972
+ tui.showError('Uso: /workflow run <nome>')
973
+ break
974
+ }
975
+ tui.disableInput()
976
+ try {
977
+ const result = await runWorkflow(name, (msg) => tui.showSystem(msg))
978
+ tui.showSystem(result)
979
+ } catch (err) {
980
+ tui.showError(`Workflow: ${err instanceof Error ? err.message : String(err)}`)
981
+ }
982
+ tui.enableInput()
983
+ } else if (sub === 'delete' || sub === 'deletar') {
984
+ const name = args[1]
985
+ if (!name) { tui.showError('Uso: /workflow delete <nome>'); break }
986
+ if (deleteWorkflow(name)) {
987
+ tui.showSystem(`Workflow removido: ${name}`)
988
+ } else {
989
+ tui.showError(`Workflow nao encontrado: ${name}`)
990
+ }
991
+ } else {
992
+ // Treat as "run <name>"
993
+ tui.disableInput()
994
+ try {
995
+ const result = await runWorkflow(sub, (msg) => tui.showSystem(msg))
996
+ tui.showSystem(result)
997
+ } catch (err) {
998
+ tui.showError(`Workflow: ${err instanceof Error ? err.message : String(err)}`)
999
+ }
1000
+ tui.enableInput()
1001
+ }
1002
+ break
1003
+ }
1004
+
1005
+ // ── Pomodoro commands ─────────────────────────────────
1006
+
1007
+ case 'pomodoro':
1008
+ case 'foco': {
1009
+ const sub = args[0]?.toLowerCase()
1010
+ if (sub === 'stop' || sub === 'parar') {
1011
+ tui.showSystem(stopPomodoro())
1012
+ } else if (sub === 'status') {
1013
+ tui.showSystem(pomodoroStatus())
1014
+ } else if (!sub) {
1015
+ tui.showSystem(pomodoroStatus())
1016
+ } else {
1017
+ // Start with label
1018
+ const label = args.join(' ')
1019
+ const workMin = 25
1020
+ const breakMin = 5
1021
+ tui.showSystem(startPomodoro(label, workMin, breakMin))
1022
+ }
1023
+ break
1024
+ }
1025
+
1026
+ // ── Finance commands ────────────────────────────────────
1027
+
1028
+ case 'entrada':
1029
+ case 'income': {
1030
+ // /entrada 500 salario descricao
1031
+ const amount = parseFloat(args[0])
1032
+ if (isNaN(amount) || args.length < 3) {
1033
+ tui.showSystem('Uso: /entrada <valor> <categoria> <descricao>')
1034
+ break
1035
+ }
1036
+ const tx = addTransaction('entrada', amount, args[1], args.slice(2).join(' '))
1037
+ tui.showSystem(`+ R$ ${tx.amount.toFixed(2)} (${tx.category}) — ${tx.description}`)
1038
+ break
1039
+ }
1040
+
1041
+ case 'saida':
1042
+ case 'expense': {
1043
+ const amount = parseFloat(args[0])
1044
+ if (isNaN(amount) || args.length < 3) {
1045
+ tui.showSystem('Uso: /saida <valor> <categoria> <descricao>')
1046
+ break
1047
+ }
1048
+ const tx = addTransaction('saida', amount, args[1], args.slice(2).join(' '))
1049
+ tui.showSystem(`- R$ ${tx.amount.toFixed(2)} (${tx.category}) — ${tx.description}`)
1050
+ break
1051
+ }
1052
+
1053
+ case 'finance':
1054
+ case 'financas':
1055
+ case 'balanco': {
1056
+ const sub = args[0]
1057
+ if (sub === 'recent' || sub === 'recentes') {
1058
+ tui.showSystem(getRecentTransactions())
1059
+ } else {
1060
+ tui.showSystem(getMonthSummary() + '\n\n' + getRecentTransactions(5))
1061
+ }
1062
+ break
1063
+ }
1064
+
1065
+ // ── Decision commands ───────────────────────────────────
1066
+
1067
+ case 'decisions':
1068
+ case 'decisoes': {
1069
+ const query = args.join(' ')
1070
+ if (query) {
1071
+ const results = searchDecisions(query)
1072
+ tui.showSystem(formatDecisionList(results))
1073
+ } else {
1074
+ tui.showSystem(formatDecisionList(listDecisions()))
1075
+ }
1076
+ break
1077
+ }
1078
+
1079
+ // ── Investigation commands ─────────────────────────────
1080
+
1081
+ case 'investigar':
1082
+ case 'investigate':
1083
+ case 'investigacoes': {
1084
+ const query = args.join(' ')
1085
+ if (query) {
1086
+ const { searchInvestigations, formatInvestigationList } = await import('./investigate')
1087
+ tui.showSystem(formatInvestigationList(searchInvestigations(query)))
1088
+ } else {
1089
+ const { listInvestigations, formatInvestigationList } = await import('./investigate')
1090
+ tui.showSystem(formatInvestigationList(listInvestigations()))
1091
+ }
1092
+ break
1093
+ }
1094
+
1095
+ // ── Email command ──────────────────────────────────────
1096
+
1097
+ case 'email':
1098
+ case 'rascunho': {
1099
+ // Quick email: /email to@addr.com assunto | corpo
1100
+ const text = args.join(' ')
1101
+ if (!text) {
1102
+ tui.showSystem('Uso: /email <destinatario> <assunto> | <corpo>\nOu peca a IA: "escreve um email para joao@email.com cobrando o relatorio"')
1103
+ break
1104
+ }
1105
+ // Parse: first word is email, rest before | is subject, after | is body
1106
+ const emailAddr = args[0]
1107
+ const restText = args.slice(1).join(' ')
1108
+ const pipeIdx = restText.indexOf('|')
1109
+ if (pipeIdx === -1) {
1110
+ tui.showSystem('Formato: /email <destinatario> <assunto> | <corpo>\nUse | para separar assunto do corpo.')
1111
+ break
1112
+ }
1113
+ const subject = restText.slice(0, pipeIdx).trim()
1114
+ const body = restText.slice(pipeIdx + 1).trim()
1115
+ if (!subject || !body) {
1116
+ tui.showError('Assunto e corpo sao obrigatorios.')
1117
+ break
1118
+ }
1119
+ const draft = { to: emailAddr, subject, body }
1120
+ tui.showSystem(formatDraftPreview(draft))
1121
+ tui.disableInput()
1122
+ try {
1123
+ const result = await openEmailDraft(draft)
1124
+ tui.showSystem(result)
1125
+ } catch (err) {
1126
+ tui.showError(`Email: ${err instanceof Error ? err.message : String(err)}`)
1127
+ }
1128
+ tui.enableInput()
1129
+ break
1130
+ }
1131
+
1132
+ // ── Memo commands ─────────────────────────────────────
1133
+
1134
+ case 'memo':
1135
+ case 'anotar':
1136
+ case 'note': {
1137
+ const text = args.join(' ')
1138
+ if (!text) {
1139
+ // Show recent memos
1140
+ const memos = listMemos()
1141
+ tui.showSystem(formatMemoList(memos))
1142
+ break
1143
+ }
1144
+ const memo = saveMemo(text)
1145
+ const tagStr = memo.tags.length > 0 ? ` [${memo.tags.map((t: string) => '#' + t).join(' ')}]` : ''
1146
+ tui.showSystem(`Memo salvo${tagStr} {${memo.id}}`)
1147
+ break
1148
+ }
1149
+
1150
+ case 'memos':
1151
+ case 'notas': {
1152
+ const query = args.join(' ')
1153
+ if (query) {
1154
+ const results = searchMemos(query)
1155
+ tui.showSystem(formatMemoList(results))
1156
+ } else {
1157
+ const memos = listMemos()
1158
+ tui.showSystem(formatMemoList(memos))
1159
+ }
1160
+ break
1161
+ }
1162
+
1163
+ case 'memotags':
1164
+ case 'tags': {
1165
+ tui.showSystem(formatMemoTags())
1166
+ break
1167
+ }
1168
+
1169
+ case 'rmmemo':
1170
+ case 'rmnota': {
1171
+ const id = args[0]
1172
+ if (!id) {
1173
+ tui.showError('Uso: /rmmemo <id>')
1174
+ break
1175
+ }
1176
+ if (deleteMemo(id)) {
1177
+ tui.showSystem('Memo removido.')
1178
+ } else {
1179
+ tui.showError(`Memo nao encontrado: ${id}`)
1180
+ }
1181
+ break
1182
+ }
1183
+
1184
+ // ── People management commands ────────────────────────
1185
+
1186
+ case 'people':
1187
+ case 'pessoas':
1188
+ case 'equipe':
1189
+ case 'team':
1190
+ case 'familia':
1191
+ case 'family':
1192
+ case 'contato':
1193
+ case 'contatos':
1194
+ case 'contacts': {
1195
+ const groupMap: Record<string, PersonGroup> = {
1196
+ equipe: 'equipe', team: 'equipe',
1197
+ familia: 'familia', family: 'familia',
1198
+ contato: 'contato', contatos: 'contato', contacts: 'contato',
1199
+ }
1200
+ const groupFilter = groupMap[cmd] || args[0] as PersonGroup | undefined
1201
+ const people = listPeople(groupFilter)
1202
+ tui.showSystem(formatPeopleList(people))
1203
+ break
1204
+ }
1205
+
1206
+ case 'person':
1207
+ case 'pessoa': {
1208
+ const ref = args.join(' ')
1209
+ if (!ref) {
1210
+ tui.showError('Uso: /person <nome>')
1211
+ break
1212
+ }
1213
+ const person = findPerson(ref)
1214
+ if (!person) {
1215
+ tui.showError(`Pessoa nao encontrada: "${ref}"`)
1216
+ break
1217
+ }
1218
+ tui.showSystem(formatPersonDetail(person))
1219
+ break
1220
+ }
1221
+
1222
+ case 'addperson':
1223
+ case 'addpessoa':
1224
+ case 'novapessoa': {
1225
+ // /addperson <group> <name> [role]
1226
+ const group = args[0] as PersonGroup
1227
+ const validGroups: PersonGroup[] = ['equipe', 'familia', 'contato']
1228
+ if (!group || !validGroups.includes(group)) {
1229
+ tui.showSystem('Uso: /addperson <equipe|familia|contato> <nome> [papel]\nEx: /addperson equipe Joao dev frontend')
1230
+ break
1231
+ }
1232
+ const nameAndRole = args.slice(1).join(' ')
1233
+ if (!nameAndRole) {
1234
+ tui.showError('Nome obrigatorio. Ex: /addperson equipe Joao dev frontend')
1235
+ break
1236
+ }
1237
+ // Split name from role at first comma if present
1238
+ const [pName, ...roleParts] = nameAndRole.split(',')
1239
+ const pRole = roleParts.join(',').trim() || undefined
1240
+ const newPerson = addPerson(pName.trim(), group, pRole)
1241
+ tui.showSystem(`Adicionado: ${newPerson.name} (${group}) [${newPerson.id}]`)
1242
+ break
1243
+ }
1244
+
1245
+ case 'delegate':
1246
+ case 'delegar': {
1247
+ // /delegate <person> <task>
1248
+ const personName = args[0]
1249
+ if (!personName || args.length < 2) {
1250
+ tui.showSystem('Uso: /delegate <pessoa> <tarefa>\nEx: /delegate Joao revisar relatorio')
1251
+ break
1252
+ }
1253
+ const taskText = args.slice(1).join(' ')
1254
+ const delegation = delegateTask(personName, taskText)
1255
+ if (!delegation) {
1256
+ tui.showError(`Pessoa nao encontrada: "${personName}"`)
1257
+ break
1258
+ }
1259
+ tui.showSystem(`Delegado para ${personName}: "${taskText}" [${delegation.id}]`)
1260
+ break
1261
+ }
1262
+
1263
+ case 'delegations':
1264
+ case 'delegacoes':
1265
+ case 'delegados': {
1266
+ const personRef = args[0]
1267
+ const delegations = getDelegations(personRef)
1268
+ tui.showSystem(formatDelegationList(delegations))
1269
+ break
1270
+ }
1271
+
1272
+ case 'followups': {
1273
+ const followUps = getPendingFollowUps()
1274
+ tui.showSystem(formatFollowUps(followUps))
1275
+ break
1276
+ }
1277
+
1278
+ case 'dashboard':
1279
+ case 'painel': {
1280
+ tui.showSystem(generatePeopleDashboard())
1281
+ break
1282
+ }
1283
+
1284
+ // ── Task/reminder commands ────────────────────────────
1285
+
1286
+ case 'task':
1287
+ case 'tarefa': {
1288
+ const text = args.join(' ')
1289
+ if (!text) {
1290
+ // Show pending tasks
1291
+ const tasks = listTasks()
1292
+ tui.showSystem(formatTaskList(tasks))
1293
+ break
1294
+ }
1295
+
1296
+ // Parse time from the text (look for time patterns)
1297
+ const dueTime = parseTime(text)
1298
+
1299
+ // Remove time-related parts from the title
1300
+ let title = text
1301
+ .replace(/\b(para\s+(as\s+)?)?\d{1,2}\s*[h:]\s*\d{0,2}\b/gi, '')
1302
+ .replace(/\b(em\s+\d+\s*(min|minutos?|h|horas?))\b/gi, '')
1303
+ .replace(/\b(amanha|amanhã)\b/gi, '')
1304
+ .replace(/\s{2,}/g, ' ')
1305
+ .trim()
1306
+
1307
+ // If title became empty, use the original text
1308
+ if (!title) title = text
1309
+
1310
+ const task = addTask(title, dueTime || undefined)
1311
+ const dueStr = dueTime
1312
+ ? ` — lembrete: ${dueTime.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`
1313
+ : ''
1314
+ tui.showSystem(`Tarefa criada: "${task.title}"${dueStr} [${task.id}]`)
1315
+ break
1316
+ }
1317
+
1318
+ case 'tasks':
1319
+ case 'tarefas': {
1320
+ const showAll = args[0] === 'all' || args[0] === 'todas'
1321
+ const tasks = listTasks(showAll)
1322
+ tui.showSystem(formatTaskList(tasks))
1323
+ break
1324
+ }
1325
+
1326
+ case 'done':
1327
+ case 'feito':
1328
+ case 'concluido': {
1329
+ const ref = args.join(' ')
1330
+ if (!ref) {
1331
+ tui.showError('Uso: /done <id ou parte do titulo>')
1332
+ break
1333
+ }
1334
+ const task = completeTask(ref)
1335
+ if (task) {
1336
+ tui.showSystem(`Concluida: "${task.title}"`)
1337
+ } else {
1338
+ tui.showError(`Tarefa nao encontrada: "${ref}"`)
1339
+ }
1340
+ break
1341
+ }
1342
+
1343
+ case 'rmtask':
1344
+ case 'rmtarefa': {
1345
+ const ref = args.join(' ')
1346
+ if (!ref) {
1347
+ tui.showError('Uso: /rmtask <id ou parte do titulo>')
1348
+ break
1349
+ }
1350
+ const removed = removeTask(ref)
1351
+ if (removed) {
1352
+ tui.showSystem('Tarefa removida.')
1353
+ } else {
1354
+ tui.showError(`Tarefa nao encontrada: "${ref}"`)
1355
+ }
1356
+ break
1357
+ }
1358
+
1359
+ default:
1360
+ tui.showError(`Unknown command: /${cmd}. Try /help`)
1361
+ }
1362
+ }
1363
+
1364
+ function cleanup(): void {
1365
+ stopTasks()
1366
+ stopPomodoroTimer()
1367
+ stopAllMonitors()
1368
+ tui.stop()
1369
+ process.exit(0)
1370
+ }
1371
+
1372
+ process.on('SIGINT', cleanup)
1373
+ process.on('SIGTERM', cleanup)
1374
+
1375
+ tui.start({
1376
+ onSubmit: handleSubmit,
1377
+ onCancel: () => {
1378
+ activeAbort?.abort()
1379
+ tui.endStream()
1380
+ tui.showSystem('Cancelled.')
1381
+ tui.enableInput()
1382
+ },
1383
+ onExit: cleanup,
1384
+ })
1385
+
1386
+ const authInfo = auth.source === 'subscription'
1387
+ ? `Authenticated via Claude ${auth.subscriptionType} subscription.`
1388
+ : 'Authenticated via API key.'
1389
+ tui.showSystem(`smolerclaw v${getVersion()} — the micro AI assistant.\n${authInfo}\nType /ajuda for commands.`)
1390
+
1391
+ // Morning briefing — first run of the day
1392
+ if (isFirstRunToday(config.dataDir)) {
1393
+ try {
1394
+ const briefing = await generateMorningBriefing()
1395
+ tui.showSystem(briefing)
1396
+ markMorningDone()
1397
+ } catch {
1398
+ // Don't block startup if briefing fails
1399
+ }
1400
+ }
1401
+
1402
+ // Auto-submit initial prompt if provided
1403
+ if (initialPrompt) {
1404
+ await handleSubmit(initialPrompt)
1405
+ }
1406
+ }
1407
+
1408
+ function formatAge(timestamp: number): string {
1409
+ if (!timestamp || timestamp <= 0) return ''
1410
+ const diff = Date.now() - timestamp
1411
+ if (diff < 0) return ''
1412
+ const mins = Math.floor(diff / 60_000)
1413
+ if (mins < 1) return 'just now'
1414
+ if (mins < 60) return `${mins}m ago`
1415
+ const hours = Math.floor(mins / 60)
1416
+ if (hours < 24) return `${hours}h ago`
1417
+ const days = Math.floor(hours / 24)
1418
+ if (days > 365) return `${Math.floor(days / 365)}y ago`
1419
+ return `${days}d ago`
1420
+ }
1421
+
1422
+ main().catch((err) => {
1423
+ // Ensure terminal is restored on crash
1424
+ try {
1425
+ process.stdin.setRawMode?.(false)
1426
+ process.stdout.write('\x1b[?1049l') // exit alt screen
1427
+ process.stdout.write('\x1b[?25h') // show cursor
1428
+ } catch { /* best effort */ }
1429
+ console.error('Fatal:', err)
1430
+ process.exit(1)
1431
+ })