@curie-agent/tui 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 (78) hide show
  1. package/dist/src/agents-tab.d.ts +15 -0
  2. package/dist/src/agents-tab.d.ts.map +1 -0
  3. package/dist/src/agents-tab.js +32 -0
  4. package/dist/src/agents-tab.js.map +1 -0
  5. package/dist/src/approval-picker.d.ts +11 -0
  6. package/dist/src/approval-picker.d.ts.map +1 -0
  7. package/dist/src/approval-picker.js +14 -0
  8. package/dist/src/approval-picker.js.map +1 -0
  9. package/dist/src/channels-tab.d.ts +19 -0
  10. package/dist/src/channels-tab.d.ts.map +1 -0
  11. package/dist/src/channels-tab.js +37 -0
  12. package/dist/src/channels-tab.js.map +1 -0
  13. package/dist/src/chat-surface.d.ts +59 -0
  14. package/dist/src/chat-surface.d.ts.map +1 -0
  15. package/dist/src/chat-surface.js +408 -0
  16. package/dist/src/chat-surface.js.map +1 -0
  17. package/dist/src/effort-picker.d.ts +10 -0
  18. package/dist/src/effort-picker.d.ts.map +1 -0
  19. package/dist/src/effort-picker.js +30 -0
  20. package/dist/src/effort-picker.js.map +1 -0
  21. package/dist/src/footer.d.ts +17 -0
  22. package/dist/src/footer.d.ts.map +1 -0
  23. package/dist/src/footer.js +20 -0
  24. package/dist/src/footer.js.map +1 -0
  25. package/dist/src/header.d.ts +7 -0
  26. package/dist/src/header.d.ts.map +1 -0
  27. package/dist/src/header.js +7 -0
  28. package/dist/src/header.js.map +1 -0
  29. package/dist/src/index.d.ts +27 -0
  30. package/dist/src/index.d.ts.map +1 -0
  31. package/dist/src/index.js +19 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/init-wizard.d.ts +37 -0
  34. package/dist/src/init-wizard.d.ts.map +1 -0
  35. package/dist/src/init-wizard.js +507 -0
  36. package/dist/src/init-wizard.js.map +1 -0
  37. package/dist/src/kitt-indicator.d.ts +9 -0
  38. package/dist/src/kitt-indicator.d.ts.map +1 -0
  39. package/dist/src/kitt-indicator.js +41 -0
  40. package/dist/src/kitt-indicator.js.map +1 -0
  41. package/dist/src/mascot.d.ts +2 -0
  42. package/dist/src/mascot.d.ts.map +1 -0
  43. package/dist/src/mascot.js +6 -0
  44. package/dist/src/mascot.js.map +1 -0
  45. package/dist/src/mode-picker.d.ts +10 -0
  46. package/dist/src/mode-picker.d.ts.map +1 -0
  47. package/dist/src/mode-picker.js +29 -0
  48. package/dist/src/mode-picker.js.map +1 -0
  49. package/dist/src/projects-tab.d.ts +16 -0
  50. package/dist/src/projects-tab.d.ts.map +1 -0
  51. package/dist/src/projects-tab.js +76 -0
  52. package/dist/src/projects-tab.js.map +1 -0
  53. package/dist/src/slash-commands.d.ts +103 -0
  54. package/dist/src/slash-commands.d.ts.map +1 -0
  55. package/dist/src/slash-commands.js +985 -0
  56. package/dist/src/slash-commands.js.map +1 -0
  57. package/dist/src/stats-data.d.ts +15 -0
  58. package/dist/src/stats-data.d.ts.map +1 -0
  59. package/dist/src/stats-data.js +76 -0
  60. package/dist/src/stats-data.js.map +1 -0
  61. package/dist/src/stats-tab.d.ts +16 -0
  62. package/dist/src/stats-tab.d.ts.map +1 -0
  63. package/dist/src/stats-tab.js +103 -0
  64. package/dist/src/stats-tab.js.map +1 -0
  65. package/dist/src/status-line.d.ts +13 -0
  66. package/dist/src/status-line.d.ts.map +1 -0
  67. package/dist/src/status-line.js +31 -0
  68. package/dist/src/status-line.js.map +1 -0
  69. package/dist/src/tab-bar.d.ts +14 -0
  70. package/dist/src/tab-bar.d.ts.map +1 -0
  71. package/dist/src/tab-bar.js +22 -0
  72. package/dist/src/tab-bar.js.map +1 -0
  73. package/dist/src/waiting-dot.d.ts +7 -0
  74. package/dist/src/waiting-dot.d.ts.map +1 -0
  75. package/dist/src/waiting-dot.js +15 -0
  76. package/dist/src/waiting-dot.js.map +1 -0
  77. package/dist/tsconfig.tsbuildinfo +1 -0
  78. package/package.json +39 -0
@@ -0,0 +1,985 @@
1
+ import { pickNextSchedule, scheduleLabel } from '@curie-agent/core';
2
+ const THINKING_BUDGET_MAP = {
3
+ low: 2_000,
4
+ medium: 6_000,
5
+ high: 16_000,
6
+ max: 32_000,
7
+ auto: 0,
8
+ };
9
+ export const SLASH_COMMANDS = [
10
+ { name: 'status', description: 'Show version, model, and account info', usage: '/status' },
11
+ { name: 'help', description: 'Show all available commands', usage: '/help' },
12
+ { name: 'debug', description: 'Toggle debug logging', usage: '/debug [on|off]' },
13
+ { name: 'statusline', description: 'Toggle status line display', usage: '/statusline [on|off]' },
14
+ { name: 'theme', description: 'Change color theme', usage: '/theme <name>' },
15
+ { name: 'memory', description: 'View memory file sizes or capture a memory', usage: '/memory [status|add]' },
16
+ { name: 'stats', description: 'Daily usage, sessions, streaks', usage: '/stats' },
17
+ { name: 'context', description: 'Visual grid showing context window usage', usage: '/context [messages|compact [detailed|brief]]' },
18
+ { name: 'model', description: 'Switch AI model, set pricing or context window', usage: '/model <model|pricing in;out|windows tokens>' },
19
+ { name: 'effort', description: 'Set reasoning effort level', usage: '/effort <low|medium|high|max|auto>' },
20
+ { name: 'mode', description: 'Set approval mode', usage: '/mode <manual|plan|auto-edit|full-auto|yolo>' },
21
+ { name: 'agent', description: 'Launch external AI agent', usage: '/agent <prompt>' },
22
+ { name: 'remind', description: 'Create a reminder', usage: '/remind <message at time>' },
23
+ { name: 'cron', description: 'Manage reminders', usage: '/cron <list|delete|clear>' },
24
+ { name: 'channels', description: 'Manage Telegram channel config', usage: '/channels <list|set-bot-token|set-user-id|set-chat-id|disconnect>' },
25
+ { name: 'tools', description: 'View/set tool call limits per turn', usage: '/tools [tools_per_call [websearch_per_call]]' },
26
+ { name: 'websearch', description: 'View/set web search+fetch limit per turn', usage: '/websearch [count]' },
27
+ { name: 'mcp', description: 'Manage MCP server connections', usage: '/mcp <list|add|remove|reload>' },
28
+ { name: 'exit', description: 'Exit curie-agent', usage: '/exit' },
29
+ { name: 'provider', description: 'Switch AI provider', usage: '/provider <anthropic|openai|google|local|openrouter>' },
30
+ { name: 'heartbeat', description: 'Manage heartbeat cycle', usage: '/heartbeat <status|enable|disable|times|daily|weekly|monthly|now>' },
31
+ { name: 'init', description: 'Run the setup wizard', usage: '/init' },
32
+ ];
33
+ export function parseSlashCommand(input) {
34
+ const trimmed = input.trim();
35
+ if (!trimmed.startsWith('/'))
36
+ return null;
37
+ const spaceIdx = trimmed.indexOf(' ');
38
+ if (spaceIdx === -1) {
39
+ return { command: trimmed.slice(1).toLowerCase(), args: '' };
40
+ }
41
+ return {
42
+ command: trimmed.slice(1, spaceIdx).toLowerCase(),
43
+ args: trimmed.slice(spaceIdx + 1).trim(),
44
+ };
45
+ }
46
+ export function handleSlashCommand(cmd, args, ctx) {
47
+ switch (cmd) {
48
+ case 'status':
49
+ return handleStatus(ctx);
50
+ case 'help':
51
+ return handleHelp();
52
+ case 'debug':
53
+ return handleDebug(args, ctx.settings.debug);
54
+ case 'statusline':
55
+ return handleStatusline(args, ctx.settings.statusline);
56
+ case 'theme':
57
+ return handleTheme(args);
58
+ case 'memory':
59
+ return handleMemory(args, ctx);
60
+ case 'stats':
61
+ return { type: 'switch_tab', tab: 'stats', message: 'Switched to Stats tab' };
62
+ case 'context':
63
+ return handleContext(ctx, args);
64
+ case 'model':
65
+ return handleModel(args, ctx.settings);
66
+ case 'effort':
67
+ return handleEffort(args);
68
+ case 'mode':
69
+ return handleMode(args);
70
+ case 'exit':
71
+ case 'quit':
72
+ return { type: 'exit', message: 'Exiting curie-agent.' };
73
+ case 'agent':
74
+ return handleAgent(args);
75
+ case 'remind':
76
+ return handleRemind(args, ctx);
77
+ case 'cron':
78
+ return handleCron(args, ctx);
79
+ case 'channels':
80
+ return handleChannels(args, ctx);
81
+ case 'mcp':
82
+ return handleMcp(args, ctx);
83
+ case 'tools':
84
+ return handleTools(args, ctx.settings);
85
+ case 'websearch':
86
+ return handleWebsearch(args, ctx.settings);
87
+ case 'provider':
88
+ return handleProvider(args);
89
+ case 'heartbeat':
90
+ return handleHeartbeat(args, ctx);
91
+ case 'init': {
92
+ // Wizard is interactive — provider, key, URL, model are handled via onSubmit.
93
+ // /init triggers the initial prompt; subsequent user input flows through onSubmit.
94
+ if (!args) {
95
+ return { type: 'message', message: 'init_wizard' };
96
+ }
97
+ // Treat as direct API key (legacy behavior)
98
+ return { type: 'update_init', message: `API key configured: ${args.trim()}`, apiKey: args.trim() };
99
+ }
100
+ default:
101
+ return {
102
+ type: 'message',
103
+ message: `Unknown command: /${cmd}. Type /help for available commands.`,
104
+ };
105
+ }
106
+ }
107
+ function handleStatus(ctx) {
108
+ const toolsPerCall = ctx.settings.TOOLS_PER_CALL ?? 10;
109
+ const websearchPerCall = ctx.settings.WEBSEARCH_PER_CALL ?? 5;
110
+ const lines = [
111
+ `curie-agent v${ctx.version}`,
112
+ `Model: ${ctx.model}`,
113
+ `Provider: ${ctx.provider}`,
114
+ `Mode: ${ctx.approvalMode}`,
115
+ `CWD: ${ctx.cwd}`,
116
+ ctx.inputTokens !== undefined
117
+ ? `Tokens: ${ctx.inputTokens} in / ${ctx.outputTokens} out`
118
+ : null,
119
+ `Tools per turn: ${toolsPerCall}`,
120
+ `WebSearch per turn: ${websearchPerCall}`,
121
+ ctx.settings.MODEL_COST
122
+ ? `Pricing: $${ctx.settings.MODEL_COST.split(';')[0]} in / $${ctx.settings.MODEL_COST.split(';')[1]} out per 1M`
123
+ : null,
124
+ ].filter(Boolean);
125
+ return { type: 'message', message: lines.join('\n') };
126
+ }
127
+ function handleHelp() {
128
+ const lines = ['Slash commands:'];
129
+ for (const cmd of SLASH_COMMANDS) {
130
+ lines.push(` ${cmd.usage.padEnd(24)} ${cmd.description}`);
131
+ }
132
+ return { type: 'message', message: lines.join('\n') };
133
+ }
134
+ function handleDebug(args, current) {
135
+ const next = args === 'on' ? true : args === 'off' ? false : !current;
136
+ return {
137
+ type: 'update_debug',
138
+ debug: next,
139
+ message: `Debug logging: ${next ? 'enabled' : 'disabled'}`,
140
+ };
141
+ }
142
+ function handleStatusline(args, current) {
143
+ const next = args === 'on' ? true : args === 'off' ? false : !current;
144
+ return {
145
+ type: 'update_statusline',
146
+ statusline: next,
147
+ message: `Status line: ${next ? 'visible' : 'hidden'}`,
148
+ };
149
+ }
150
+ function handleTheme(args) {
151
+ const validThemes = ['tokyo-night', 'nord', 'dracula', 'solarized', 'gruvbox', 'black', 'white', 'grey'];
152
+ const theme = args.toLowerCase();
153
+ if (!theme) {
154
+ return {
155
+ type: 'message',
156
+ message: `Available themes: ${validThemes.join(', ')}\nUsage: /theme <name>`,
157
+ };
158
+ }
159
+ if (!validThemes.includes(theme)) {
160
+ return {
161
+ type: 'message',
162
+ message: `Unknown theme: "${theme}". Available: ${validThemes.join(', ')}`,
163
+ };
164
+ }
165
+ return { type: 'update_theme', theme, message: `Theme changed to: ${theme}` };
166
+ }
167
+ function handleModel(args, settings) {
168
+ const MODEL_COST_DEFAULT = '(not set)';
169
+ const WINDOW_DEFAULT = 200_000;
170
+ if (!args) {
171
+ const cost = settings?.MODEL_COST ?? MODEL_COST_DEFAULT;
172
+ const window = settings?.MODEL_CONTEXT_WINDOW ?? WINDOW_DEFAULT;
173
+ return {
174
+ type: 'message',
175
+ message: `Usage: /model <model>\n /model pricing [in;out] — set custom per-million pricing (e.g. "0.5;2.0")\n /model windows <tokens> — set max context window\n\nCurrent: pricing=${cost} window=${window} tokens`,
176
+ };
177
+ }
178
+ const parts = args.trim().split(/\s+/);
179
+ const sub = parts[0].toLowerCase();
180
+ const rest = parts.slice(1).join(' ').trim();
181
+ switch (sub) {
182
+ case 'pricing': {
183
+ if (!rest) {
184
+ const cost = settings?.MODEL_COST ?? MODEL_COST_DEFAULT;
185
+ return { type: 'message', message: `Usage: /model pricing <in;out>\nExample: /model pricing 0.5;2.0\nCurrent: ${cost}` };
186
+ }
187
+ const [inStr, outStr] = rest.split(';');
188
+ const inCost = parseFloat(inStr ?? '');
189
+ const outCost = parseFloat(outStr ?? '');
190
+ if (isNaN(inCost) || isNaN(outCost) || inCost < 0 || outCost < 0) {
191
+ return { type: 'message', message: `Invalid pricing: "${rest}". Use format: <in;out> (per million tokens, e.g. "0.5;2.0").` };
192
+ }
193
+ return { type: 'update_model_cost', modelCost: rest, message: `Model pricing set to: $${inCost} in / $${outCost} out per 1M tokens` };
194
+ }
195
+ case 'windows': {
196
+ if (!rest) {
197
+ const window = settings?.MODEL_CONTEXT_WINDOW ?? WINDOW_DEFAULT;
198
+ return { type: 'message', message: `Usage: /model windows <tokens>\nExample: /model windows 1000000\nCurrent: ${window}` };
199
+ }
200
+ const windowSize = parseInt(rest, 10);
201
+ if (isNaN(windowSize) || windowSize < 1024) {
202
+ return { type: 'message', message: `Invalid context window: "${rest}". Must be a positive integer (min 1024).` };
203
+ }
204
+ return { type: 'update_context_window', contextWindow: windowSize, message: `Context window set to: ${windowSize.toLocaleString()} tokens` };
205
+ }
206
+ default: {
207
+ const aliasMap = {
208
+ opus: 'claude-opus-4-7',
209
+ sonnet: 'claude-sonnet-4-6',
210
+ haiku: 'claude-haiku-4-5-20251001',
211
+ gpt4o: 'gpt-4o',
212
+ gpt4turbo: 'gpt-4-turbo',
213
+ o1: 'o1',
214
+ 'o3-mini': 'o3-mini',
215
+ };
216
+ const resolved = aliasMap[sub] || args;
217
+ return { type: 'update_model', model: resolved, message: `Model changed to: ${resolved}` };
218
+ }
219
+ }
220
+ }
221
+ function handleEffort(args) {
222
+ const valid = ['low', 'medium', 'high', 'max', 'auto'];
223
+ if (!args) {
224
+ return {
225
+ type: 'message',
226
+ message: `Usage: /effort <level>\nLevels: ${valid.join(', ')}`,
227
+ };
228
+ }
229
+ const level = args.toLowerCase();
230
+ if (!valid.includes(level)) {
231
+ return {
232
+ type: 'message',
233
+ message: `Invalid effort level: "${args}". Valid: ${valid.join(', ')}`,
234
+ };
235
+ }
236
+ return { type: 'update_effort', effort: level, message: `Effort set to: ${level}` };
237
+ }
238
+ function handleMode(args) {
239
+ const valid = ['plan', 'edit', 'auto', 'yolo'];
240
+ if (!args) {
241
+ return {
242
+ type: 'message',
243
+ message: `Usage: /mode <mode>\nModes: ${valid.join(', ')}`,
244
+ };
245
+ }
246
+ const mode = args.toLowerCase();
247
+ if (!valid.includes(mode)) {
248
+ return {
249
+ type: 'message',
250
+ message: `Invalid mode: "${args}". Valid: ${valid.join(', ')}`,
251
+ };
252
+ }
253
+ return { type: 'update_mode', mode: mode, message: `Mode changed to: ${mode}` };
254
+ }
255
+ function handleAgent(args) {
256
+ if (!args) {
257
+ return {
258
+ type: 'message',
259
+ message: 'Usage: /agent [--mode plan|edit|auto|yolo] [--effort low|medium|high|max|auto] <prompt>\nExample: /agent --mode auto --effort medium check codebase',
260
+ };
261
+ }
262
+ let agentMode;
263
+ let agentEffort;
264
+ let remaining = args;
265
+ const validModes = ['plan', 'edit', 'auto', 'yolo'];
266
+ const validEfforts = ['low', 'medium', 'high', 'max', 'auto'];
267
+ // Match --mode <word> or --effort <word> with optional trailing text
268
+ const flagRegex = /^--(mode|effort)\s+(\S+?)(?:\s+(.+))?$/;
269
+ let found = true;
270
+ while (found) {
271
+ found = false;
272
+ const match = remaining.match(flagRegex);
273
+ if (match) {
274
+ const flag = match[1];
275
+ const value = (match[2] ?? '').trim();
276
+ if (flag === 'mode' && validModes.includes(value)) {
277
+ agentMode = value;
278
+ }
279
+ else if (flag === 'effort' && validEfforts.includes(value)) {
280
+ agentEffort = value;
281
+ }
282
+ else {
283
+ break; // unknown flag or invalid value → stop parsing
284
+ }
285
+ // match[3] is the remaining text after the flag value
286
+ remaining = (match[3] ?? '').trim();
287
+ found = true;
288
+ }
289
+ }
290
+ const prompt = remaining.trim();
291
+ if (!prompt) {
292
+ return {
293
+ type: 'message',
294
+ message: 'Usage: /agent [--mode plan|edit|auto|yolo] [--effort low|medium|high|max|auto] <prompt>\nExample: /agent --mode auto --effort medium check codebase',
295
+ };
296
+ }
297
+ const agentId = crypto.randomUUID();
298
+ return {
299
+ type: 'start_agent',
300
+ agentId,
301
+ message: `Agent started: "${prompt}"`,
302
+ agentMode,
303
+ agentEffort,
304
+ };
305
+ }
306
+ function handleRemind(args, ctx) {
307
+ if (!args) {
308
+ return {
309
+ type: 'message',
310
+ message: 'Usage: /remind <message at time>\nExamples: /remind "tomorrow at 7am make breakfast", /remind "in 30 minutes call mom"',
311
+ };
312
+ }
313
+ const cronManager = ctx.cronManager;
314
+ if (!cronManager) {
315
+ return {
316
+ type: 'message',
317
+ message: 'Reminder service not available. Please restart the application.',
318
+ };
319
+ }
320
+ const { parseReminderTime } = require('../../core/src/reminder-parser.js');
321
+ const parsed = parseReminderTime(args);
322
+ if (!parsed) {
323
+ return {
324
+ type: 'message',
325
+ message: `Could not parse time from: "${args}".\nTry: /remind "tomorrow at 7am make breakfast"`,
326
+ };
327
+ }
328
+ const task = cronManager.createReminder(parsed.message, parsed.scheduledAt);
329
+ const timeStr = new Date(task.scheduledAt).toLocaleString();
330
+ return {
331
+ type: 'message',
332
+ message: `Hopper reminder:\nDate: ${timeStr}\n${parsed.message}\nID: ${task.id}`,
333
+ };
334
+ }
335
+ function handleCron(args, ctx) {
336
+ const cronManager = ctx.cronManager;
337
+ if (!cronManager) {
338
+ return {
339
+ type: 'message',
340
+ message: 'Reminder service not available. Please restart the application.',
341
+ };
342
+ }
343
+ // Reload from disk before each command so tool-created reminders
344
+ // are visible even if the tool used a separate CronManager instance.
345
+ cronManager.load();
346
+ const parts = args.trim().split(/\s+/);
347
+ const action = parts[0]?.toLowerCase();
348
+ const rest = parts.slice(1).join(' ').trim();
349
+ switch (action) {
350
+ case 'list': {
351
+ const statusFilter = ['pending', 'fired', 'cancelled'].includes(rest) ? rest : undefined;
352
+ const tasks = cronManager.listReminders(statusFilter);
353
+ if (tasks.length === 0) {
354
+ return {
355
+ type: 'message',
356
+ message: statusFilter
357
+ ? `No ${statusFilter} reminders.`
358
+ : 'No reminders yet.\nUse /remind to create one.',
359
+ };
360
+ }
361
+ const lines = [`Reminders (${tasks.length}${statusFilter ? ` — ${statusFilter}` : ''}):`];
362
+ for (const t of tasks) {
363
+ const timeStr = new Date(t.scheduledAt).toLocaleString();
364
+ const statusEmoji = t.status === 'pending' ? '⏳' : t.status === 'fired' ? '✅' : '❌';
365
+ const label = t.schedule ? `[${scheduleLabel(t.schedule.type)}] ` : '';
366
+ lines.push(` ${statusEmoji} ${label}${t.message}\n Date: ${timeStr}\n ID: ${t.id}`);
367
+ }
368
+ return { type: 'message', message: lines.join('\n') };
369
+ }
370
+ case 'delete': {
371
+ if (!rest) {
372
+ return {
373
+ type: 'message',
374
+ message: 'Usage: /cron delete <id>\nExample: /cron delete abc-123',
375
+ };
376
+ }
377
+ const result = cronManager.cancelReminder(rest);
378
+ if (!result) {
379
+ return { type: 'message', message: `No reminder found with ID: ${rest}` };
380
+ }
381
+ return { type: 'message', message: `Reminder cancelled.` };
382
+ }
383
+ case 'clear': {
384
+ const removed = cronManager.clearCompleted();
385
+ return { type: 'message', message: `Cleared ${removed} completed reminder(s).` };
386
+ }
387
+ default:
388
+ return {
389
+ type: 'message',
390
+ message: `Unknown cron action: "${action}". Use: list, delete, clear`,
391
+ };
392
+ }
393
+ }
394
+ function handleChannels(args, ctx) {
395
+ const parts = args.trim().split(/\s+/);
396
+ const sub = parts[0]?.toLowerCase();
397
+ const rest = parts.slice(1).join(' ').trim();
398
+ switch (sub) {
399
+ case 'list': {
400
+ const token = ctx.settings.TELEGRAM_BOT_TOKEN;
401
+ const userId = ctx.settings.TELEGRAM_USER_ID;
402
+ const chatId = ctx.settings.TELEGRAM_CHAT_ID;
403
+ const tokenMask = token && token.length > 8
404
+ ? token.slice(0, 8) + '...'
405
+ : token || '(not set)';
406
+ return {
407
+ type: 'message',
408
+ message: `Telegram Configuration:\n Bot Token: ${tokenMask}\n Allowed User ID: ${userId || '(not set)'}\n Chat ID: ${chatId || '(not set)'}`,
409
+ };
410
+ }
411
+ case 'set-bot-token': {
412
+ if (!rest) {
413
+ return {
414
+ type: 'message',
415
+ message: 'Usage: /channels set-bot-token <token>\nExample: /channels set-bot-token 123456:ABC-DEF...',
416
+ };
417
+ }
418
+ return { type: 'external', external: 'channels.set-bot-token', message: rest };
419
+ }
420
+ case 'set-user-id': {
421
+ if (!rest) {
422
+ return {
423
+ type: 'message',
424
+ message: 'Usage: /channels set-user-id <id>\nExample: /channels set-user-id 123456789',
425
+ };
426
+ }
427
+ return { type: 'external', external: 'channels.set-user-id', message: rest };
428
+ }
429
+ case 'set-chat-id': {
430
+ if (!rest) {
431
+ return {
432
+ type: 'message',
433
+ message: 'Usage: /channels set-chat-id <chatId>\nExample: /channels set-chat-id -1001234567890',
434
+ };
435
+ }
436
+ return { type: 'external', external: 'channels.set-chat-id', message: rest };
437
+ }
438
+ case 'disconnect': {
439
+ return { type: 'external', external: 'channels.disconnect' };
440
+ }
441
+ case 'switch': {
442
+ if (!rest) {
443
+ return {
444
+ type: 'message',
445
+ message: 'Usage: /channels switch <channelId>\nExamples: /channels switch main, /channels switch telegram:12345',
446
+ };
447
+ }
448
+ return { type: 'external', external: 'channels.switch', message: rest };
449
+ }
450
+ default:
451
+ return {
452
+ type: 'message',
453
+ message: `Unknown channel action: "${sub}". Use: list, switch, set-bot-token, set-user-id, set-chat-id, disconnect`,
454
+ };
455
+ }
456
+ }
457
+ function handleMcp(args, ctx) {
458
+ const parts = args.trim().split(/\s+/);
459
+ const sub = parts[0]?.toLowerCase();
460
+ const rest = parts.slice(1).join(' ').trim();
461
+ if (!sub) {
462
+ return {
463
+ type: 'message',
464
+ message: 'Usage: /mcp <list|add|remove|reload>\n list — Show configured MCP servers\n add <id> <transport> ... — Add an MCP server\n remove <id> — Remove an MCP server by ID\n reload — Reconnect all MCP servers',
465
+ };
466
+ }
467
+ // Parse current MCP servers from settings
468
+ let configs = {};
469
+ try {
470
+ const raw = ctx.settings.MCP_SERVERS;
471
+ if (typeof raw === 'string' && raw.trim().length > 0) {
472
+ configs = JSON.parse(raw);
473
+ }
474
+ else if (raw && typeof raw === 'object') {
475
+ configs = raw;
476
+ }
477
+ }
478
+ catch {
479
+ /* ignore */
480
+ }
481
+ switch (sub) {
482
+ case 'list': {
483
+ const entries = Object.entries(configs);
484
+ if (entries.length === 0) {
485
+ return {
486
+ type: 'message',
487
+ message: 'No MCP servers configured.\nUse /mcp add to add one.\n\nExample:\n /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /workspace',
488
+ };
489
+ }
490
+ const lines = [`MCP Servers (${entries.length}):`];
491
+ for (const [id, cfg] of entries) {
492
+ const c = cfg;
493
+ const transport = c.transport || 'unknown';
494
+ const name = c.name || id;
495
+ let detail = '';
496
+ if (transport === 'stdio') {
497
+ detail = `${c.command || '?'} ${c.args?.join(' ') || ''}`;
498
+ }
499
+ else {
500
+ detail = `url: ${c.url || '?'}`;
501
+ }
502
+ const client = ctx.mcpClients?.find((cl) => cl.serverId === id);
503
+ if (!client) {
504
+ lines.push(` · ${id} (${name}) — ${transport}: ${detail} [not loaded]`);
505
+ }
506
+ else if (client.isConnected) {
507
+ lines.push(` ● ${id} (${name}) — ${transport}: ${detail} [connected]`);
508
+ }
509
+ else {
510
+ lines.push(` ○ ${id} (${name}) — ${transport}: ${detail} [disconnected]`);
511
+ }
512
+ }
513
+ return { type: 'message', message: lines.join('\n') };
514
+ }
515
+ case 'add': {
516
+ // /mcp add <id> <transport> [--env key=value ...] [command] [arg ...]
517
+ if (!rest) {
518
+ return {
519
+ type: 'message',
520
+ message: 'Usage: /mcp add <id> <transport> [--env key=value ...] [command] [arg ...]\n\nExamples:\n /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /workspace\n /mcp add github stdio npx -y @modelcontextprotocol/server-github --env GITHUB_TOKEN=ghp_xxx\n /mcp add my-api sse https://api.example.com/mcp',
521
+ };
522
+ }
523
+ const addParts = rest.split(/\s+/);
524
+ const id = addParts[0];
525
+ const transport = addParts[1]?.toLowerCase();
526
+ if (!id || !transport) {
527
+ return {
528
+ type: 'message',
529
+ message: 'Usage: /mcp add <id> <stdio|sse|streamable-http> [flags] [command] [args...]\nExample: /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /workspace',
530
+ };
531
+ }
532
+ if (!['stdio', 'sse', 'streamable-http'].includes(transport)) {
533
+ return { type: 'message', message: `Invalid transport: "${transport}". Use: stdio, sse, streamable-http` };
534
+ }
535
+ if (configs[id]) {
536
+ return { type: 'message', message: `Server "${id}" already exists. Use /mcp reload to reconnect.` };
537
+ }
538
+ const cfg = { id, name: id, transport };
539
+ let i = 2;
540
+ // Parse --env key=value flags
541
+ const env = {};
542
+ while (i < addParts.length && addParts[i]?.startsWith('--')) {
543
+ if (addParts[i] === '--env' && i + 1 < addParts.length) {
544
+ i++;
545
+ const kv = addParts[i];
546
+ if (!kv)
547
+ break;
548
+ const eqIdx = kv.indexOf('=');
549
+ if (eqIdx > 0) {
550
+ env[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1);
551
+ }
552
+ i++;
553
+ }
554
+ else {
555
+ break;
556
+ }
557
+ }
558
+ if (transport === 'stdio' && i < addParts.length) {
559
+ cfg.command = addParts[i];
560
+ const cmdArgs = addParts.slice(i + 1);
561
+ if (cmdArgs.length > 0)
562
+ cfg.args = cmdArgs;
563
+ }
564
+ else if (transport === 'sse' || transport === 'streamable-http') {
565
+ cfg.url = addParts[i] || undefined;
566
+ }
567
+ if (Object.keys(env).length > 0)
568
+ cfg.env = env;
569
+ configs[id] = cfg;
570
+ ctx.settings.MCP_SERVERS = configs;
571
+ return { type: 'update_mcp', mcpServerId: id, mcpServers: JSON.stringify(configs, null, 2), message: `Added MCP server "${id}":\n\n${JSON.stringify(configs, null, 2)}\n\nRun /mcp reload to connect.` };
572
+ }
573
+ case 'remove': {
574
+ if (!rest) {
575
+ return {
576
+ type: 'message',
577
+ message: 'Usage: /mcp remove <id>\nExample: /mcp remove filesystem',
578
+ };
579
+ }
580
+ if (!configs[rest]) {
581
+ return { type: 'message', message: `No MCP server found with ID: ${rest}` };
582
+ }
583
+ delete configs[rest];
584
+ ctx.settings.MCP_SERVERS = configs;
585
+ return { type: 'update_mcp', mcpServerId: rest, message: `Removed MCP server "${rest}". Run /mcp reload to apply.` };
586
+ }
587
+ case 'reload': {
588
+ return { type: 'update_mcp', message: 'MCP servers reloaded. Reconnecting...' };
589
+ }
590
+ default:
591
+ return {
592
+ type: 'message',
593
+ message: `Unknown MCP action: "${sub}". Use: list, add, remove, reload`,
594
+ };
595
+ }
596
+ }
597
+ function handleTools(args, settings) {
598
+ const toolsPerCall = settings.TOOLS_PER_CALL ?? 10;
599
+ const websearchPerCall = settings.WEBSEARCH_PER_CALL ?? 5;
600
+ if (!args.trim()) {
601
+ return {
602
+ type: 'message',
603
+ message: `Tool call limits (per turn):\n Tools: ${toolsPerCall}\n WebSearch+WebFetch: ${websearchPerCall}\n\nUsage:\n /tools 15 — set tools per call\n /tools 15 8 — set both limits`,
604
+ };
605
+ }
606
+ const parts = args.trim().split(/\s+/);
607
+ const val = parseInt(parts[0], 10);
608
+ if (isNaN(val) || val < 1) {
609
+ return { type: 'message', message: `Invalid value: "${parts[0]}". Must be a positive integer.` };
610
+ }
611
+ const result = {
612
+ type: 'update_tools_per_call',
613
+ toolsPerCall: val,
614
+ message: `Tools per call set to: ${val}`,
615
+ };
616
+ if (parts[1]) {
617
+ const wsVal = parseInt(parts[1], 10);
618
+ if (isNaN(wsVal) || wsVal < 1) {
619
+ return { type: 'message', message: `Invalid websearch value: "${parts[1]}". Must be a positive integer.` };
620
+ }
621
+ result.websearchPerCall = wsVal;
622
+ }
623
+ return result;
624
+ }
625
+ function handleWebsearch(args, settings) {
626
+ const websearchPerCall = settings.WEBSEARCH_PER_CALL ?? 5;
627
+ if (!args.trim()) {
628
+ return {
629
+ type: 'message',
630
+ message: `WebSearch/WebFetch limit per turn: ${websearchPerCall}\n\nUsage:\n /websearch 3 — set websearch+fetch limit`,
631
+ };
632
+ }
633
+ const val = parseInt(args.trim(), 10);
634
+ if (isNaN(val) || val < 1) {
635
+ return { type: 'message', message: `Invalid value: "${args}". Must be a positive integer.` };
636
+ }
637
+ return {
638
+ type: 'update_websearch_per_call',
639
+ websearchPerCall: val,
640
+ message: `WebSearch+WebFetch per turn set to: ${val}`,
641
+ };
642
+ }
643
+ function handleMemory(args, ctx) {
644
+ const parts = args.trim().split(/\s+/);
645
+ const sub = parts[0]?.toLowerCase();
646
+ const rest = parts.slice(1).join(' ').trim();
647
+ switch (sub) {
648
+ case 'status':
649
+ return {
650
+ type: 'update_memory',
651
+ message: 'Retrieving memory file sizes...',
652
+ memory: { content: '', operation: 'status' },
653
+ };
654
+ case 'add': {
655
+ if (!rest) {
656
+ return {
657
+ type: 'message',
658
+ message: 'Usage: /memory add <text>\nExample: /memory add user prefers TypeScript over JavaScript',
659
+ };
660
+ }
661
+ const entry = `- [${new Date().toISOString()}] ${rest}`;
662
+ return {
663
+ type: 'update_memory',
664
+ message: `Memory captured: "${rest}"\nThe agent will organize it into memory files on the next turn.`,
665
+ memory: { content: entry, operation: 'add' },
666
+ };
667
+ }
668
+ default: {
669
+ return {
670
+ type: 'message',
671
+ message: 'Memory commands:\n /memory status — Show memory file sizes\n /memory add <text> — Capture a memory for the agent to organize',
672
+ };
673
+ }
674
+ }
675
+ }
676
+ function formatChannelMessages(messages) {
677
+ const lines = [`Conversation Messages (${messages.length}):`];
678
+ const maxOutput = 8000;
679
+ for (let i = 0; i < messages.length; i++) {
680
+ const msg = messages[i];
681
+ const idx = String(i + 1).padStart(2, ' ');
682
+ const roleLabel = msg.role === 'user' ? 'User'
683
+ : msg.role === 'assistant' ? 'Assistant'
684
+ : msg.role === 'tool' ? 'Tool'
685
+ : msg.role === 'tool-group' ? 'Tools'
686
+ : msg.role === 'system' ? 'System'
687
+ : msg.role === 'decision' ? 'Decision'
688
+ : msg.role === 'heartbeat' ? 'Heartbeat'
689
+ : msg.role;
690
+ const titlePrefix = msg.title ? `[${msg.title}] ` : '';
691
+ const truncated = msg.content.length > 300
692
+ ? msg.content.slice(0, 300) + '...'
693
+ : msg.content;
694
+ lines.push(`[${idx}] ${roleLabel}: ${titlePrefix}${truncated}`);
695
+ }
696
+ const fullOutput = lines.join('\n');
697
+ if (fullOutput.length > maxOutput) {
698
+ const cut = fullOutput.slice(0, maxOutput);
699
+ lines.length = 0;
700
+ lines.push(cut);
701
+ lines.push(`\n[Output truncated at ${maxOutput} characters. Total messages: ${messages.length}]`);
702
+ }
703
+ return { type: 'message', message: lines.join('\n') };
704
+ }
705
+ function formatTurnLoopMessages(messages) {
706
+ const lines = [`Conversation Messages (${messages.length}):`];
707
+ const maxOutput = 8000;
708
+ for (let i = 0; i < messages.length; i++) {
709
+ const msg = messages[i];
710
+ const idx = String(i + 1).padStart(2, ' ');
711
+ if (msg.role === 'user') {
712
+ const truncated = msg.content.length > 200
713
+ ? msg.content.slice(0, 200) + '...'
714
+ : msg.content;
715
+ lines.push(`[${idx}] User: ${truncated}`);
716
+ }
717
+ else if (msg.role === 'assistant') {
718
+ const textParts = [];
719
+ const toolCalls = [];
720
+ for (const block of msg.content) {
721
+ if (block.type === 'text') {
722
+ textParts.push(block.text);
723
+ }
724
+ else if (block.type === 'thinking') {
725
+ const short = block.thinking.length > 150
726
+ ? block.thinking.slice(0, 150) + '...'
727
+ : block.thinking;
728
+ textParts.push(`[thinking ${short.length} chars]`);
729
+ }
730
+ else if (block.type === 'tool-use') {
731
+ toolCalls.push({ name: block.name, input: block.input });
732
+ }
733
+ }
734
+ lines.push(`[${idx}] Assistant:`);
735
+ for (const text of textParts) {
736
+ const textTruncated = text.length > 200 ? text.slice(0, 200) + '...' : text;
737
+ lines.push(` ${textTruncated}`);
738
+ }
739
+ for (const tc of toolCalls) {
740
+ const inputStr = JSON.stringify(tc.input).slice(0, 200);
741
+ const inputTruncated = inputStr.length >= 200 ? inputStr + '...' : inputStr;
742
+ lines.push(` → ${tc.name}(${inputTruncated})`);
743
+ }
744
+ }
745
+ else if (msg.role === 'tool') {
746
+ const truncated = msg.content.length > 300
747
+ ? msg.content.slice(0, 300) + '...\n[truncated, ' + msg.content.length + ' bytes total]'
748
+ : msg.content;
749
+ lines.push(`[${idx}] Tool Result (${msg.toolUseId}):`);
750
+ for (const line of truncated.split('\n').slice(0, 5)) {
751
+ lines.push(` ${line}`);
752
+ }
753
+ }
754
+ }
755
+ const fullOutput = lines.join('\n');
756
+ if (fullOutput.length > maxOutput) {
757
+ const cut = fullOutput.slice(0, maxOutput);
758
+ lines.length = 0;
759
+ lines.push(cut);
760
+ lines.push(`\n[Output truncated at ${maxOutput} characters. Total messages: ${messages.length}]`);
761
+ }
762
+ return { type: 'message', message: lines.join('\n') };
763
+ }
764
+ function handleContext(ctx, args) {
765
+ const sub = args?.trim().toLowerCase();
766
+ if (sub === 'messages') {
767
+ // TurnLoop messages are the source of truth — they contain full tool output,
768
+ // thinking blocks, and structured assistant responses. channelMessages is a
769
+ // display-only layer that collapses tool results into summaries.
770
+ const loopMsgs = ctx.messages;
771
+ if (loopMsgs && loopMsgs.length > 0) {
772
+ return formatTurnLoopMessages(loopMsgs);
773
+ }
774
+ // Fallback: channelMessages from TUI state (always available, but lossy).
775
+ const channelMsgs = ctx.channelMessages;
776
+ if (channelMsgs && channelMsgs.length > 0) {
777
+ const filtered = channelMsgs.filter((m) => !(m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('█ ')));
778
+ if (filtered.length === 0) {
779
+ return { type: 'message', message: 'No messages yet. Start a conversation to see message history.' };
780
+ }
781
+ return formatChannelMessages(filtered);
782
+ }
783
+ return { type: 'message', message: 'No messages yet. Start a conversation to see message history.' };
784
+ }
785
+ // /context compact [detailed|brief] — summarize conversation to free context
786
+ if (sub && sub.startsWith('compact')) {
787
+ const parts = sub.split(/\s+/);
788
+ const depth = (parts[1] ?? 'detailed').toLowerCase();
789
+ if (!['detailed', 'brief'].includes(depth)) {
790
+ return {
791
+ type: 'message',
792
+ message: `Unknown compact depth: "${depth}". Use: /context compact [detailed|brief]`,
793
+ };
794
+ }
795
+ const loopMsgs = ctx.messages;
796
+ if (!loopMsgs || loopMsgs.length < 2) {
797
+ return {
798
+ type: 'message',
799
+ message: 'Not enough messages to compact. Need at least 2 messages (user + assistant turn).',
800
+ };
801
+ }
802
+ return {
803
+ type: 'compact',
804
+ compact: {
805
+ messages: loopMsgs,
806
+ depth,
807
+ },
808
+ message: `Compacting conversation (${depth} summary)...\nThe agent will process the summary and continue with a condensed context.`,
809
+ };
810
+ }
811
+ // Default: context window visual (unchanged behavior)
812
+ const input = ctx.inputTokens ?? 0;
813
+ const output = ctx.outputTokens ?? 0;
814
+ const model = ctx.model || 'unknown';
815
+ const windowSize = ctx.settings.MODEL_CONTEXT_WINDOW ?? ctx.contextWindowSize ?? 200_000;
816
+ const pct = input > 0 ? Math.min(100, Math.round((input / windowSize) * 100)) : 0;
817
+ const filled = Math.round((pct / 100) * 24);
818
+ const bar = '█'.repeat(filled) + '░'.repeat(24 - filled);
819
+ const fmt = (n) => (n >= 1_000 ? `${Math.round(n / 1_000)}k` : String(n));
820
+ if (input === 0 && output === 0) {
821
+ return { type: 'message', message: 'No token data yet. Start a conversation to see context window usage.' };
822
+ }
823
+ const lines = [`Context Window (${model}): ${bar} ${pct}% (${fmt(input)}/${fmt(windowSize)})`];
824
+ if (input > 0 && output > 0) {
825
+ lines.push(` └─ ${fmt(input)} in / ${fmt(output)} out`);
826
+ }
827
+ return { type: 'message', message: lines.join('\n') };
828
+ }
829
+ const PROVIDER_MODEL_ALIASES = {
830
+ opus: 'claude-opus-4-7',
831
+ sonnet: 'claude-sonnet-4-6',
832
+ haiku: 'claude-haiku-4-5-20251001',
833
+ gpt4o: 'gpt-4o',
834
+ gpt4turbo: 'gpt-4-turbo',
835
+ o1: 'o1',
836
+ 'o3-mini': 'o3-mini',
837
+ };
838
+ function handleProvider(args) {
839
+ const validProviders = ['anthropic', 'openai', 'google', 'local', 'ollama', 'openrouter'];
840
+ if (!args) {
841
+ return {
842
+ type: 'message',
843
+ message: `Usage: /provider <name>\nProviders: ${validProviders.join(', ')}\n\nCurrent settings:\n anthropic: MODEL_API_KEY, MODEL_URL\n openai: OPENAI_API_KEY, OPENAI_URL\n google: GOOGLE_API_KEY, GOOGLE_URL\n local: MODEL_URL, MODEL_API_KEY\n ollama: MODEL_URL, MODEL_API_KEY\n openrouter: OPENROUTER_URL, OPENROUTER_API_KEY`,
844
+ };
845
+ }
846
+ const provider = args.toLowerCase();
847
+ if (!validProviders.includes(provider)) {
848
+ return {
849
+ type: 'message',
850
+ message: `Unknown provider: "${args}". Valid: ${validProviders.join(', ')}`,
851
+ };
852
+ }
853
+ return { type: 'update_provider', provider, message: `Provider switched to: ${provider}` };
854
+ }
855
+ function handleHeartbeat(args, ctx) {
856
+ const parts = args.trim().split(/\s+/);
857
+ const sub = parts[0]?.toLowerCase();
858
+ const rest = parts.slice(1).join(' ').trim();
859
+ switch (sub) {
860
+ case 'status':
861
+ case '': {
862
+ const active = ctx.settings.HEARTBEAT === 'on';
863
+ const times = ctx.settings.HEARTBEAT_TIMES ?? '';
864
+ const daily = ctx.settings.HEARTBEAT_DAILY ?? '6:00';
865
+ const weekly = ctx.settings.HEARTBEAT_WEEKLY ?? 'monday@6:00';
866
+ const monthly = ctx.settings.HEARTBEAT_MONTHLY ?? '1@6:00';
867
+ const timesDisplay = times ? times.split(',').map((s) => s.trim()).join(', ') : '(not set)';
868
+ const picked = pickNextSchedule({ HEARTBEAT_TIMES: times, HEARTBEAT_DAILY: daily, HEARTBEAT_WEEKLY: weekly, HEARTBEAT_MONTHLY: monthly });
869
+ const activeSchedule = picked ? `${picked.type} (${picked.value})` : '(none configured)';
870
+ return {
871
+ type: 'message',
872
+ message: `Heartbeat cycle:\n Enabled: ${active ? 'yes' : 'no'}\n Active schedule: ${activeSchedule}\n\n Times: ${timesDisplay}\n Daily: ${daily}\n Weekly: ${weekly}\n Monthly: ${monthly}\n\nUsage:\n /heartbeat — show status\n /heartbeat enable — turn on\n /heartbeat disable — turn off\n /heartbeat times <H:MM,...> — set intra-day times (e.g. 8:10,10:10,14:20)\n /heartbeat daily <H:MM> — set daily time (24h)\n /heartbeat weekly <day@H:MM>\n /heartbeat monthly <D@H:MM>\n /heartbeat now — run immediately`,
873
+ };
874
+ }
875
+ case 'enable': {
876
+ return { type: 'notification', notification: { type: 'heartbeat', enabled: true } };
877
+ }
878
+ case 'disable': {
879
+ return { type: 'notification', notification: { type: 'heartbeat', enabled: false } };
880
+ }
881
+ case 'times': {
882
+ if (!rest) {
883
+ const current = ctx.settings.HEARTBEAT_TIMES ?? '';
884
+ return {
885
+ type: 'message',
886
+ message: `Usage: /heartbeat times <H:MM,...>\nExample: /heartbeat times 8:10,10:10,14:20,16:20\nCurrent: ${current || '(not set)'}`,
887
+ };
888
+ }
889
+ const tokens = rest.split(',').map((s) => s.trim()).filter(Boolean);
890
+ const invalid = tokens.filter((t) => {
891
+ if (!/^\d{1,2}:\d{2}$/.test(t))
892
+ return true;
893
+ const colonIdx = t.indexOf(':');
894
+ const h = parseInt(t.slice(0, colonIdx), 10);
895
+ const m = parseInt(t.slice(colonIdx + 1), 10);
896
+ return h < 0 || h > 23 || m < 0 || m > 59;
897
+ });
898
+ if (invalid.length > 0) {
899
+ return { type: 'message', message: `Invalid time(s): ${invalid.join(', ')}. Use H:MM in 24h format (e.g., 8:10,14:20).` };
900
+ }
901
+ const value = tokens.join(',');
902
+ return {
903
+ type: 'notification',
904
+ notification: { type: 'heartbeat-set', key: 'HEARTBEAT_TIMES', value },
905
+ };
906
+ }
907
+ case 'daily': {
908
+ if (!rest) {
909
+ return {
910
+ type: 'message',
911
+ message: `Usage: /heartbeat daily <H:MM>\nExample: /heartbeat daily 6:00\nCurrent: ${ctx.settings.HEARTBEAT_DAILY ?? '6:00'}`,
912
+ };
913
+ }
914
+ if (!/^\d{1,2}:\d{2}$/.test(rest)) {
915
+ return { type: 'message', message: `Invalid time: "${rest}". Use H:MM (e.g., 6:00, 14:30).` };
916
+ }
917
+ const [hStr, mStr] = rest.split(':');
918
+ const h = parseInt(hStr ?? '0', 10);
919
+ const m = parseInt(mStr ?? '0', 10);
920
+ if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
921
+ return { type: 'message', message: `Invalid time: "${rest}". Hour 0-23, minute 0-59.` };
922
+ }
923
+ return {
924
+ type: 'notification',
925
+ notification: { type: 'heartbeat-set', key: 'HEARTBEAT_DAILY', value: rest },
926
+ };
927
+ }
928
+ case 'weekly': {
929
+ if (!rest) {
930
+ return {
931
+ type: 'message',
932
+ message: `Usage: /heartbeat weekly <day@H:MM>\nExample: /heartbeat weekly monday@6:00\nCurrent: ${ctx.settings.HEARTBEAT_WEEKLY ?? 'monday@6:00'}`,
933
+ };
934
+ }
935
+ const atIdx = rest.indexOf('@');
936
+ if (atIdx < 0) {
937
+ return { type: 'message', message: `Invalid weekly schedule: "${rest}". Use day@H:MM (e.g., monday@6:00).` };
938
+ }
939
+ const day = rest.slice(0, atIdx).toLowerCase();
940
+ const validDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
941
+ if (!validDays.includes(day)) {
942
+ return { type: 'message', message: `Invalid day: "${day}". Use: ${validDays.join(', ')}.` };
943
+ }
944
+ if (!/^\d{1,2}:\d{2}$/.test(rest.slice(atIdx + 1))) {
945
+ return { type: 'message', message: `Invalid time: "${rest}". Use day@H:MM (e.g., monday@6:00).` };
946
+ }
947
+ return {
948
+ type: 'notification',
949
+ notification: { type: 'heartbeat-set', key: 'HEARTBEAT_WEEKLY', value: rest },
950
+ };
951
+ }
952
+ case 'monthly': {
953
+ if (!rest) {
954
+ return {
955
+ type: 'message',
956
+ message: `Usage: /heartbeat monthly <D@H:MM>\nExample: /heartbeat monthly 1@6:00\nCurrent: ${ctx.settings.HEARTBEAT_MONTHLY ?? '1@6:00'}`,
957
+ };
958
+ }
959
+ const atIdx = rest.indexOf('@');
960
+ if (atIdx < 0) {
961
+ return { type: 'message', message: `Invalid monthly schedule: "${rest}". Use D@H:MM (e.g., 1@6:00).` };
962
+ }
963
+ const dayOfMonth = parseInt(rest.slice(0, atIdx), 10);
964
+ if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
965
+ return { type: 'message', message: `Invalid day: "${rest}". Day of month 1-31.` };
966
+ }
967
+ if (!/^\d{1,2}:\d{2}$/.test(rest.slice(atIdx + 1))) {
968
+ return { type: 'message', message: `Invalid time: "${rest}". Use D@H:MM (e.g., 1@6:00).` };
969
+ }
970
+ return {
971
+ type: 'notification',
972
+ notification: { type: 'heartbeat-set', key: 'HEARTBEAT_MONTHLY', value: rest },
973
+ };
974
+ }
975
+ case 'now': {
976
+ return { type: 'notification', notification: { type: 'heartbeat-now' } };
977
+ }
978
+ default:
979
+ return {
980
+ type: 'message',
981
+ message: `Unknown heartbeat action: "${sub}". Use: status, enable, disable, times, daily, weekly, monthly, now`,
982
+ };
983
+ }
984
+ }
985
+ //# sourceMappingURL=slash-commands.js.map