@curie-agent/tui 0.4.0 → 0.4.2

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.
@@ -1,82 +1,24 @@
1
- import { pickNextSchedule } from '@curie-agent/core';
2
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { homedir } from 'node:os';
5
- /** Resolve the unified task file path for a scope. */
6
- function resolveTaskPath(scope, cwd) {
7
- if (scope === 'personal')
8
- return join(homedir(), '.curie-agent', 'tasks.json');
9
- return join(cwd, 'tasks.json');
10
- }
11
- /** Read tasks file; falls back to legacy todo.json. Returns null if neither exists. */
12
- function readTaskJson(path) {
13
- try {
14
- if (!existsSync(path))
15
- return null;
16
- const raw = readFileSync(path, 'utf-8');
17
- const data = JSON.parse(raw);
18
- if (Array.isArray(data.tasks))
19
- return data;
20
- // Legacy format check for plain array at root? No, legacy is {tasks: []} with no version/schema.
21
- return null;
22
- }
23
- catch {
24
- return null;
25
- }
26
- }
27
- /** Normalize a legacy task record (missing mode/scope) to UnifiedTask fields. */
28
- function normalizeTaskRecord(t) {
29
- if (!t.mode)
30
- t.mode = 'human';
31
- if (!t.scope)
32
- t.scope = 'personal';
33
- return t;
34
- }
35
- const THINKING_BUDGET_MAP = {
36
- low: 2_000,
37
- medium: 6_000,
38
- high: 16_000,
39
- max: 32_000,
40
- auto: 0,
41
- };
42
- export const SLASH_COMMANDS = [
43
- // General
44
- { name: 'status', description: 'Show version, model, and account info', usage: '/status', category: 'General' },
45
- { name: 'help', description: 'Show all available commands', usage: '/help', category: 'General' },
46
- { name: 'init', description: 'Run the setup wizard', usage: '/init', category: 'General' },
47
- { name: 'exit', description: 'Exit curie-agent', usage: '/exit', category: 'General' },
48
- // Model & Provider
49
- { name: 'provider', description: 'Switch AI provider', usage: '/provider <anthropic|openai|google|local|ollama|openrouter>', category: 'Model & Provider' },
50
- { name: 'model', description: 'Switch AI model, set pricing or context window', usage: '/model <model|pricing in;out|window tokens>', category: 'Model & Provider' },
51
- { name: 'effort', description: 'Set reasoning effort level', usage: '/effort <low|medium|high|max|auto>', category: 'Model & Provider' },
52
- { name: 'mode', description: 'Set approval mode', usage: '/mode <plan|edit|auto|yolo>', category: 'Model & Provider' },
53
- // Display
54
- { name: 'theme', description: 'Change color theme', usage: '/theme <name>', category: 'Display' },
55
- { name: 'debug', description: 'Toggle debug logging', usage: '/debug [on|off]', category: 'Display' },
56
- { name: 'statusline', description: 'Toggle status line display', usage: '/statusline [on|off]', category: 'Display' },
57
- // Knowledge
58
- { name: 'memory', description: 'View memory file sizes or capture a memory', usage: '/memory [status|add]', category: 'Knowledge' },
59
- { name: 'todo', description: 'Manage tasks in todo.json', usage: '/todo <list|add|complete|remove>', category: 'Knowledge' },
60
- { name: 'stats', description: 'Daily usage, sessions, streaks', usage: '/stats', category: 'Knowledge' },
61
- { name: 'context', description: 'Visual grid showing context window usage, compaction, autocompaction', usage: '/context [auto|messages|compact [detailed|brief]]', category: 'Knowledge' },
62
- { name: 'wiki', description: 'Open the wiki tab or run a wiki operation', usage: '/wiki [list|search <query>|lint|status]', category: 'Knowledge' },
63
- // Automation
64
- { name: 'remind', description: 'Create a reminder', usage: '/remind <message at time>', category: 'Automation' },
65
- { name: 'cron', description: 'Manage reminders', usage: '/cron <list|delete|clear>', category: 'Automation' },
66
- { name: 'task', description: 'Schedule an agent task', usage: '/task <create|list|delete>', category: 'Automation' },
67
- { name: 'heartbeat', description: 'Manage heartbeat cycle', usage: '/heartbeat <status|enable|disable|intraday|daily|weekly|monthly|dreaming|now>', category: 'Automation' },
68
- // Tools
69
- { name: 'agent', description: 'Launch external AI agent', usage: '/agent <prompt>', category: 'Tools' },
70
- { name: 'tools', description: 'View/set tool call limits per turn', usage: '/tools [tools_per_call [websearch_per_call]]', category: 'Tools' },
71
- { name: 'websearch', description: 'View/set web search+fetch limit per turn', usage: '/websearch [count]', category: 'Tools' },
72
- { name: 'mcp', description: 'Manage MCP server connections', usage: '/mcp <list|add|remove|reload>', category: 'Tools' },
73
- { name: 'skill', description: 'List or show available skills', usage: '/skill [name]', category: 'Tools' },
74
- // Communication
75
- { name: 'channels', description: 'Manage Telegram channel config', usage: '/channels <list|set-bot-token|set-user-id|set-chat-id|disconnect>', category: 'Communication' },
76
- // Safety
77
- { name: 'snapshots', description: 'List recent git snapshots for recovery', usage: '/snapshots', category: 'Safety' },
78
- { name: 'revert', description: 'Revert to a git snapshot (index, default: most recent)', usage: '/revert [index]', category: 'Safety' },
79
- ];
1
+ /**
2
+ * Slash-command parsing for the TUI input box.
3
+ *
4
+ * The command *registry* lives in `@curie-agent/protocol` so the daemon can
5
+ * share it; this module only parses raw input and re-exports the registry for
6
+ * convenience. Execution happens in one of two places, per each entry's
7
+ * `handler` field: the daemon's `executeSlashCommand` (most commands) or the
8
+ * CLI's `onSlashCommand` (commands needing terminal/React state).
9
+ *
10
+ * This file previously carried a second, complete command engine that nothing
11
+ * ever called. It was deleted rather than repaired.
12
+ */
13
+ export { SLASH_COMMANDS, SLASH_COMMAND_CATEGORIES, findSlashCommand, allSlashCommandNames, renderSlashCommandHelp, } from '@curie-agent/protocol';
14
+ /**
15
+ * Split `/name rest of args` into its parts.
16
+ *
17
+ * Returns null when the input is not a slash command. The command name is
18
+ * lowercased; args keep their original casing (paths and prompts are
19
+ * case-sensitive). A bare `/` yields an empty command name, which callers
20
+ * should treat as unknown.
21
+ */
80
22
  export function parseSlashCommand(input) {
81
23
  const trimmed = input.trim();
82
24
  if (!trimmed.startsWith('/'))
@@ -90,1541 +32,4 @@ export function parseSlashCommand(input) {
90
32
  args: trimmed.slice(spaceIdx + 1).trim(),
91
33
  };
92
34
  }
93
- export async function handleSlashCommand(cmd, args, ctx) {
94
- switch (cmd) {
95
- case 'status':
96
- return handleStatus(ctx);
97
- case 'help':
98
- return handleHelp();
99
- case 'debug':
100
- return handleDebug(args, ctx.settings.debug);
101
- case 'statusline':
102
- return handleStatusline(args, ctx.settings.statusline);
103
- case 'theme':
104
- return handleTheme(args);
105
- case 'memory':
106
- return handleMemory(args, ctx);
107
- case 'todo':
108
- return handleTodo(args, ctx);
109
- case 'stats':
110
- return { type: 'switch_tab', tab: 'stats', message: 'Switched to Stats tab' };
111
- case 'context':
112
- return handleContext(ctx, args);
113
- case 'model':
114
- return handleModel(args, ctx.settings);
115
- case 'effort':
116
- return handleEffort(args);
117
- case 'mode':
118
- return handleMode(args);
119
- case 'exit':
120
- case 'quit':
121
- return { type: 'exit', message: 'Exiting curie-agent.' };
122
- case 'agent':
123
- return handleAgent(args);
124
- case 'remind':
125
- return handleRemind(args, ctx);
126
- case 'cron':
127
- return handleCron(args, ctx);
128
- case 'task':
129
- return handleTask(args, ctx);
130
- case 'channels':
131
- return handleChannels(args, ctx);
132
- case 'mcp':
133
- return handleMcp(args, ctx);
134
- case 'tools':
135
- return handleTools(args, ctx.settings);
136
- case 'websearch':
137
- return handleWebsearch(args, ctx.settings);
138
- case 'provider':
139
- return handleProvider(args);
140
- case 'heartbeat':
141
- return handleHeartbeat(args, ctx);
142
- case 'init': {
143
- // Wizard is interactive — provider, key, URL, model are handled via onSubmit.
144
- // /init triggers the initial prompt; subsequent user input flows through onSubmit.
145
- if (!args) {
146
- return { type: 'message', message: 'init_wizard' };
147
- }
148
- // Treat as direct API key (legacy behavior)
149
- return { type: 'update_init', message: `API key configured: ${args.trim()}`, apiKey: args.trim() };
150
- }
151
- case 'snapshots':
152
- return handleSnapshots(ctx);
153
- case 'revert':
154
- return handleRevert(args, ctx);
155
- case 'skill':
156
- return handleSkill(args, ctx);
157
- default:
158
- return {
159
- type: 'message',
160
- message: `Unknown command: /${cmd}. Type /help for available commands.`,
161
- };
162
- }
163
- }
164
- function handleSkill(args, ctx) {
165
- const listSkillsFn = ctx.listSkills;
166
- if (!listSkillsFn) {
167
- return { type: 'message', message: 'Skills discovery is not available in this context.' };
168
- }
169
- const skills = listSkillsFn(ctx.cwd);
170
- if (!args.trim()) {
171
- if (skills.length === 0) {
172
- return {
173
- type: 'message',
174
- message: [
175
- 'No skills found.',
176
- '',
177
- 'Skills are SKILL.md files in:',
178
- ' ~/.curie-agent/skills/ (global)',
179
- ' <cwd>/.curie-agent/skills/ (project)',
180
- '',
181
- 'Directory format: skill-name/SKILL.md',
182
- 'Flat format: skill-name-SKILL.md',
183
- ].join('\n'),
184
- };
185
- }
186
- const lines = [`Available Skills (${skills.length}):`];
187
- for (const s of skills) {
188
- const source = s.source === 'project' ? '[project]' : '[global]';
189
- const desc = s.description.length > 80 ? s.description.slice(0, 77) + '...' : s.description;
190
- lines.push(` ${s.name} ${source}`);
191
- lines.push(` ${desc}`);
192
- }
193
- lines.push('');
194
- lines.push('Use /skill <name> to see full details.');
195
- return { type: 'message', message: lines.join('\n') };
196
- }
197
- const name = args.trim().toLowerCase();
198
- const skill = skills.find(s => s.name.toLowerCase() === name);
199
- if (!skill) {
200
- const available = skills.map(s => s.name).join(', ');
201
- return { type: 'message', message: `Skill "${name}" not found. Available: ${available}` };
202
- }
203
- try {
204
- const content = readFileSync(skill.filePath, 'utf-8');
205
- const bodyStart = content.indexOf('---', 3);
206
- const body = bodyStart > 0 ? content.slice(bodyStart + 3).trim() : content;
207
- return {
208
- type: 'message',
209
- message: [
210
- `## ${skill.name} (${skill.source})`,
211
- `Description: ${skill.description}`,
212
- `Source: ${skill.filePath}`,
213
- '',
214
- '---',
215
- body,
216
- ].join('\n'),
217
- };
218
- }
219
- catch {
220
- return { type: 'message', message: `Could not read skill file: ${skill.filePath}` };
221
- }
222
- }
223
- function validatePricingString(cost) {
224
- if (!cost)
225
- return false;
226
- if (!cost.includes('|')) {
227
- const [inStr = '', outStr = ''] = cost.split(';');
228
- const inC = parseFloat(inStr);
229
- const outC = parseFloat(outStr);
230
- return !isNaN(inC) && !isNaN(outC) && inC >= 0 && outC >= 0;
231
- }
232
- const tiers = cost.split('|').map(s => s.trim());
233
- const firstPair = (tiers[0] ?? '').split(';');
234
- const inStr = firstPair[0] ?? '';
235
- const outStr = firstPair[1] ?? '';
236
- if (isNaN(parseFloat(inStr)) || isNaN(parseFloat(outStr)))
237
- return false;
238
- for (let i = 1; i < tiers.length; i++) {
239
- const tier = tiers[i];
240
- const idx = tier.indexOf('<');
241
- if (idx === -1)
242
- return false;
243
- const threshold = parseInt(tier.substring(0, idx).trim(), 10);
244
- const rest = tier.substring(idx + 1).trim();
245
- const [inStr2 = '', outStr2 = ''] = rest.split(';');
246
- const tierIn = parseFloat(inStr2);
247
- const tierOut = parseFloat(outStr2);
248
- if (isNaN(threshold) || isNaN(tierIn) || isNaN(tierOut) || threshold < 0 || tierIn < 0 || tierOut < 0)
249
- return false;
250
- }
251
- return true;
252
- }
253
- function formatPricingDisplay(cost) {
254
- if (!cost)
255
- return '';
256
- if (!cost.includes('|')) {
257
- const [inStr = '', outStr = ''] = cost.split(';');
258
- return `Pricing: $${inStr} in / $${outStr} out per 1M`;
259
- }
260
- const tiers = cost.split('|').map(s => s.trim());
261
- const firstPair = (tiers[0] ?? '').split(';');
262
- const inStr = firstPair[0] ?? '?';
263
- const outStr = firstPair[1] ?? '?';
264
- return `Pricing: $${inStr} in / $${outStr} out per 1M (${tiers.length} tier${tiers.length > 1 ? 's' : ''})`;
265
- }
266
- function handleStatus(ctx) {
267
- const toolsPerCall = ctx.settings.tools_per_call ?? 10;
268
- const websearchPerCall = ctx.settings.websearch_per_call ?? 5;
269
- const modelCost = ctx.settings.providers?.[ctx.settings.current_provider]?.model_cost;
270
- const lines = [
271
- `curie-agent v${ctx.version}`,
272
- `Model: ${ctx.model}`,
273
- `Provider: ${ctx.provider}`,
274
- `Mode: ${ctx.approvalMode}`,
275
- `CWD: ${ctx.cwd}`,
276
- ctx.inputTokens !== undefined
277
- ? `Tokens: ${ctx.inputTokens} in / ${ctx.outputTokens} out`
278
- : null,
279
- `Tools per turn: ${toolsPerCall}`,
280
- `WebSearch per turn: ${websearchPerCall}`,
281
- modelCost
282
- ? formatPricingDisplay(modelCost)
283
- : null,
284
- ].filter(Boolean);
285
- return { type: 'message', message: lines.join('\n') };
286
- }
287
- function handleHelp() {
288
- const lines = [];
289
- // Group by category
290
- const groups = {};
291
- for (const cmd of SLASH_COMMANDS) {
292
- if (!groups[cmd.category])
293
- groups[cmd.category] = [];
294
- groups[cmd.category].push(cmd);
295
- }
296
- // Calculate column width per group
297
- const colWidth = {};
298
- for (const [cat, cmds] of Object.entries(groups)) {
299
- colWidth[cat] = Math.min(Math.max(...cmds.map((c) => c.usage.length)) + 4, 40);
300
- }
301
- for (const [cat, cmds] of Object.entries(groups)) {
302
- if (lines.length > 0)
303
- lines.push('');
304
- lines.push(cat);
305
- const w = colWidth[cat];
306
- for (const cmd of cmds) {
307
- lines.push(` ${cmd.usage.padEnd(w)} ${cmd.description}`);
308
- }
309
- }
310
- return { type: 'message', message: lines.join('\n') };
311
- }
312
- function handleDebug(args, current) {
313
- const next = args === 'on' ? true : args === 'off' ? false : !current;
314
- return {
315
- type: 'update_debug',
316
- debug: next,
317
- message: `Debug logging: ${next ? 'enabled' : 'disabled'}`,
318
- };
319
- }
320
- function handleStatusline(args, current) {
321
- const next = args === 'on' ? true : args === 'off' ? false : !current;
322
- return {
323
- type: 'update_statusline',
324
- statusline: next,
325
- message: `Status line: ${next ? 'visible' : 'hidden'}`,
326
- };
327
- }
328
- function handleTheme(args) {
329
- const validThemes = ['tokyo-night', 'nord', 'dracula', 'solarized', 'gruvbox', 'black', 'white', 'grey'];
330
- const theme = args.toLowerCase();
331
- if (!theme) {
332
- return {
333
- type: 'message',
334
- message: `Available themes: ${validThemes.join(', ')}\nUsage: /theme <name>`,
335
- };
336
- }
337
- if (!validThemes.includes(theme)) {
338
- return {
339
- type: 'message',
340
- message: `Unknown theme: "${theme}". Available: ${validThemes.join(', ')}`,
341
- };
342
- }
343
- return { type: 'update_theme', theme, message: `Theme changed to: ${theme}` };
344
- }
345
- function handleModel(args, settings) {
346
- const WINDOW_DEFAULT = 200_000;
347
- const getModelCost = (s) => s?.providers?.[s.current_provider]?.model_cost;
348
- const getWindow = (s) => s?.providers?.[s.current_provider]?.model_context_window;
349
- if (!args) {
350
- const cost = getModelCost(settings) ?? '(not set)';
351
- const window = getWindow(settings) ?? WINDOW_DEFAULT;
352
- return {
353
- type: 'message',
354
- message: `Usage: /model <model>\n /model pricing [in;out] — set custom per-million pricing (e.g. "0.5;2.0" or "0.5;2.0|200000<1.0;4.0")\n /model window <tokens> — set max context window\n\nCurrent: pricing=${cost} window=${window} tokens`,
355
- };
356
- }
357
- const parts = args.trim().split(/\s+/);
358
- const sub = parts[0].toLowerCase();
359
- const rest = parts.slice(1).join(' ').trim();
360
- switch (sub) {
361
- case 'pricing': {
362
- if (!rest) {
363
- const cost = getModelCost(settings) ?? '(not set)';
364
- return { type: 'message', message: `Usage: /model pricing <in;out>\n /model pricing <in;out|threshold<input;out>...\nExample: /model pricing 0.5;2.0\n /model pricing 0.5;2.0|200000<1.0;4.0\nCurrent: ${cost}` };
365
- }
366
- const isValidPricing = validatePricingString(rest);
367
- if (!isValidPricing) {
368
- return { type: 'message', message: `Invalid pricing: "${rest}". Use format: <in;out> or <in;out|threshold<input;out> (per million tokens, e.g. "0.5;2.0" or "0.5;2.0|200000<1.0;4.0").` };
369
- }
370
- const [inStr = ''] = rest.split(';');
371
- const inCost = parseFloat(inStr);
372
- const firstTier = rest.split('|')[0]?.split(';');
373
- const outCost = firstTier ? parseFloat(firstTier[1] ?? '') : NaN;
374
- const tierCount = rest.includes('|') ? rest.split('|').length : 1;
375
- return {
376
- type: 'update_model_cost',
377
- modelCost: rest,
378
- message: tierCount === 1
379
- ? `Model pricing set to: $${inCost} in / $${outCost} out per 1M tokens`
380
- : `Model pricing set to: ${tierCount} tiers, base $${inCost} in / $${outCost} out per 1M tokens`,
381
- };
382
- }
383
- case 'window': {
384
- if (!rest) {
385
- const window = getWindow(settings) ?? WINDOW_DEFAULT;
386
- return { type: 'message', message: `Usage: /model window <tokens>\nExample: /model window 1000000\nCurrent: ${window}` };
387
- }
388
- const windowSize = parseInt(rest, 10);
389
- if (isNaN(windowSize) || windowSize < 1024) {
390
- return { type: 'message', message: `Invalid context window: "${rest}". Must be a positive integer (min 1024).` };
391
- }
392
- return { type: 'update_context_window', contextWindow: windowSize, message: `Context window set to: ${windowSize.toLocaleString()} tokens` };
393
- }
394
- default: {
395
- const aliasMap = {
396
- opus: 'claude-opus-4-7',
397
- sonnet: 'claude-sonnet-4-6',
398
- haiku: 'claude-haiku-4-5-20251001',
399
- gpt4o: 'gpt-4o',
400
- gpt4turbo: 'gpt-4-turbo',
401
- o1: 'o1',
402
- 'o3-mini': 'o3-mini',
403
- };
404
- const resolved = aliasMap[sub] || args;
405
- return { type: 'update_model', model: resolved, message: `Model changed to: ${resolved}` };
406
- }
407
- }
408
- }
409
- function handleEffort(args) {
410
- const valid = ['low', 'medium', 'high', 'max', 'auto'];
411
- if (!args) {
412
- return {
413
- type: 'message',
414
- message: `Usage: /effort <level>\nLevels: ${valid.join(', ')}`,
415
- };
416
- }
417
- const level = args.toLowerCase();
418
- if (!valid.includes(level)) {
419
- return {
420
- type: 'message',
421
- message: `Invalid effort level: "${args}". Valid: ${valid.join(', ')}`,
422
- };
423
- }
424
- return { type: 'update_effort', effort: level, message: `Effort set to: ${level}` };
425
- }
426
- function handleMode(args) {
427
- const valid = ['plan', 'edit', 'auto', 'yolo'];
428
- if (!args) {
429
- return {
430
- type: 'message',
431
- message: `Usage: /mode <mode>\nModes: ${valid.join(', ')}`,
432
- };
433
- }
434
- const mode = args.toLowerCase();
435
- if (!valid.includes(mode)) {
436
- return {
437
- type: 'message',
438
- message: `Invalid mode: "${args}". Valid: ${valid.join(', ')}`,
439
- };
440
- }
441
- return { type: 'update_mode', mode: mode, message: `Mode changed to: ${mode}` };
442
- }
443
- function handleAgent(args) {
444
- if (!args) {
445
- return {
446
- type: 'message',
447
- message: 'Usage: /agent [--mode plan|edit|auto|yolo] [--effort low|medium|high|max|auto] <prompt>\nExample: /agent --mode auto --effort medium check codebase',
448
- };
449
- }
450
- let agentMode;
451
- let agentEffort;
452
- let remaining = args;
453
- const validModes = ['plan', 'edit', 'auto', 'yolo'];
454
- const validEfforts = ['low', 'medium', 'high', 'max', 'auto'];
455
- // Match --mode <word> or --effort <word> with optional trailing text
456
- const flagRegex = /^--(mode|effort)\s+(\S+?)(?:\s+(.+))?$/;
457
- let found = true;
458
- while (found) {
459
- found = false;
460
- const match = remaining.match(flagRegex);
461
- if (match) {
462
- const flag = match[1];
463
- const value = (match[2] ?? '').trim();
464
- if (flag === 'mode' && validModes.includes(value)) {
465
- agentMode = value;
466
- }
467
- else if (flag === 'effort' && validEfforts.includes(value)) {
468
- agentEffort = value;
469
- }
470
- else {
471
- break; // unknown flag or invalid value → stop parsing
472
- }
473
- // match[3] is the remaining text after the flag value
474
- remaining = (match[3] ?? '').trim();
475
- found = true;
476
- }
477
- }
478
- const prompt = remaining.trim();
479
- if (!prompt) {
480
- return {
481
- type: 'message',
482
- message: 'Usage: /agent [--mode plan|edit|auto|yolo] [--effort low|medium|high|max|auto] <prompt>\nExample: /agent --mode auto --effort medium check codebase',
483
- };
484
- }
485
- const agentId = crypto.randomUUID();
486
- return {
487
- type: 'start_agent',
488
- agentId,
489
- message: `Agent started: "${prompt}"`,
490
- agentMode,
491
- agentEffort,
492
- };
493
- }
494
- function handleRemind(args, ctx) {
495
- if (!args) {
496
- return {
497
- type: 'message',
498
- message: 'Usage: /remind <message at time>\nExample: /remind "tomorrow at 7am make breakfast"\nOr use /todo notify add "..." for the unified format.',
499
- };
500
- }
501
- const { parseReminderTime } = {};
502
- try {
503
- Object.assign(require('../../core/src/reminder-parser.js'), { parseReminderTime });
504
- }
505
- catch { /* module not available */ }
506
- if (!parseReminderTime) {
507
- return { type: 'message', message: `Could not parse time from: "${args}".\nUse /todo notify add "tomorrow at 7am make breakfast"` };
508
- }
509
- const parsed = parseReminderTime(args);
510
- if (!parsed) {
511
- return { type: 'message', message: `Could not parse time from: "${args}".\nUse /todo notify add "tomorrow at 7am make breakfast"` };
512
- }
513
- if (ctx.taskManager) {
514
- ctx.taskManager.load();
515
- const task = ctx.taskManager.create({ title: parsed.message, mode: 'notify', scope: 'personal', scheduled_at: parsed.scheduledAt });
516
- return { type: 'message', message: `Reminder set:\nTime: ${new Date(task.scheduled_at).toLocaleString()}\nMessage: ${task.title}\nID: ${task.id}` };
517
- }
518
- return { type: 'message', message: 'Reminder service not available. Please restart the application.' };
519
- }
520
- function handleCron(args, ctx) {
521
- // /cron is an alias for viewing notify-mode tasks in the unified store.
522
- const parts = args.trim().split(/\s+/);
523
- const action = parts[0]?.toLowerCase();
524
- const rest = parts.slice(1).join(' ').trim();
525
- if (!ctx.taskManager) {
526
- return { type: 'message', message: 'Task service not available. Please restart the application.' };
527
- }
528
- ctx.taskManager.load();
529
- switch (action) {
530
- case 'list': {
531
- const allTasks = ctx.taskManager.list({ mode: 'notify' });
532
- if (allTasks.length === 0)
533
- return { type: 'message', message: 'No scheduled reminders.\nUse /todo notify add "..." to create one.' };
534
- const lines = [`Reminders (${allTasks.length}):`];
535
- for (const t of allTasks.sort((a, b) => Number(a.scheduled_at ?? 0) - Number(b.scheduled_at ?? 0))) {
536
- const timeStr = t.scheduled_at ? new Date(t.scheduled_at).toLocaleString() : '—';
537
- lines.push(` ${t.status} ${t.title}\n Time: ${timeStr}\n ID: ${t.id.slice(0, 8)}`);
538
- }
539
- return { type: 'message', message: lines.join('\n') };
540
- }
541
- case 'delete': {
542
- if (!rest)
543
- return { type: 'message', message: 'Usage: /cron delete <id>\nExample: /cron delete abc-123' };
544
- const result = ctx.taskManager.cancelTask(rest);
545
- if (result)
546
- return { type: 'message', message: 'Reminder cancelled.' };
547
- return { type: 'message', message: `No reminder found with ID: ${rest}` };
548
- }
549
- case 'clear': {
550
- const removed = ctx.taskManager.clearCompleted();
551
- return { type: 'message', message: `Cleared ${removed} completed task(s).` };
552
- }
553
- default:
554
- return handleTodo('list personal', ctx);
555
- }
556
- }
557
- function handleChannels(args, ctx) {
558
- const parts = args.trim().split(/\s+/);
559
- const sub = parts[0]?.toLowerCase();
560
- const rest = parts.slice(1).join(' ').trim();
561
- switch (sub) {
562
- case 'list': {
563
- const token = ctx.settings.channels?.bot_token;
564
- const userId = ctx.settings.channels?.user_id;
565
- const chatId = ctx.settings.channels?.chat_id;
566
- const tokenMask = token && token.length > 8
567
- ? token.slice(0, 8) + '...'
568
- : token || '(not set)';
569
- return {
570
- type: 'message',
571
- message: `Telegram Configuration:\n Bot Token: ${tokenMask}\n Allowed User ID: ${userId || '(not set)'}\n Chat ID: ${chatId || '(not set)'}`,
572
- };
573
- }
574
- case 'set-bot-token': {
575
- if (!rest) {
576
- return {
577
- type: 'message',
578
- message: 'Usage: /channels set-bot-token <token>\nExample: /channels set-bot-token 123456:ABC-DEF...',
579
- };
580
- }
581
- return { type: 'external', external: 'channels.set-bot-token', message: rest };
582
- }
583
- case 'set-user-id': {
584
- if (!rest) {
585
- return {
586
- type: 'message',
587
- message: 'Usage: /channels set-user-id <id>\nExample: /channels set-user-id 123456789',
588
- };
589
- }
590
- return { type: 'external', external: 'channels.set-user-id', message: rest };
591
- }
592
- case 'set-chat-id': {
593
- if (!rest) {
594
- return {
595
- type: 'message',
596
- message: 'Usage: /channels set-chat-id <chatId>\nExample: /channels set-chat-id -1001234567890',
597
- };
598
- }
599
- return { type: 'external', external: 'channels.set-chat-id', message: rest };
600
- }
601
- case 'disconnect': {
602
- return { type: 'external', external: 'channels.disconnect' };
603
- }
604
- case 'switch': {
605
- if (!rest) {
606
- return {
607
- type: 'message',
608
- message: 'Usage: /channels switch <channelId>\nExamples: /channels switch main, /channels switch telegram:12345',
609
- };
610
- }
611
- return { type: 'external', external: 'channels.switch', message: rest };
612
- }
613
- default:
614
- return {
615
- type: 'message',
616
- message: `Unknown channel action: "${sub}". Use: list, switch, set-bot-token, set-user-id, set-chat-id, disconnect`,
617
- };
618
- }
619
- }
620
- function handleMcp(args, ctx) {
621
- const parts = args.trim().split(/\s+/);
622
- const sub = parts[0]?.toLowerCase();
623
- const rest = parts.slice(1).join(' ').trim();
624
- if (!sub) {
625
- return {
626
- type: 'message',
627
- 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',
628
- };
629
- }
630
- // Parse current MCP servers from settings
631
- let configs = {};
632
- try {
633
- const raw = ctx.settings.mcp_servers;
634
- if (typeof raw === 'string' && raw.trim().length > 0) {
635
- configs = JSON.parse(raw);
636
- }
637
- else if (raw && typeof raw === 'object') {
638
- configs = raw;
639
- }
640
- }
641
- catch {
642
- /* ignore */
643
- }
644
- switch (sub) {
645
- case 'list': {
646
- const entries = Object.entries(configs);
647
- if (entries.length === 0) {
648
- return {
649
- type: 'message',
650
- message: 'No MCP servers configured.\nUse /mcp add to add one.\n\nExample:\n /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /workspace',
651
- };
652
- }
653
- const lines = [`MCP Servers (${entries.length}):`];
654
- for (const [id, cfg] of entries) {
655
- const c = cfg;
656
- const transport = c.transport || 'unknown';
657
- const name = c.name || id;
658
- let detail = '';
659
- if (transport === 'stdio') {
660
- detail = `${c.command || '?'} ${c.args?.join(' ') || ''}`;
661
- }
662
- else {
663
- detail = `url: ${c.url || '?'}`;
664
- }
665
- const client = ctx.mcpClients?.find((cl) => cl.serverId === id);
666
- const wasFailed = ctx.mcpFailed?.includes(id);
667
- if (!client) {
668
- const status = wasFailed ? 'connection failed' : 'not running';
669
- lines.push(` ⚠️ ${id} (${name}) — ${transport}: ${detail} [${status}]`);
670
- }
671
- else if (client.isConnected) {
672
- lines.push(` ✅ ${id} (${name}) — ${transport}: ${detail}`);
673
- }
674
- else {
675
- lines.push(` ⚠️ ${id} (${name}) — ${transport}: ${detail} [disconnected]`);
676
- }
677
- }
678
- return { type: 'message', message: lines.join('\n') };
679
- }
680
- case 'add': {
681
- // /mcp add <id> <transport> [--env key=value ...] [command] [arg ...]
682
- if (!rest) {
683
- return {
684
- type: 'message',
685
- 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',
686
- };
687
- }
688
- const addParts = rest.split(/\s+/);
689
- const id = addParts[0];
690
- const transport = addParts[1]?.toLowerCase();
691
- if (!id || !transport) {
692
- return {
693
- type: 'message',
694
- message: 'Usage: /mcp add <id> <stdio|sse|streamable-http> [flags] [command] [args...]\nExample: /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /workspace',
695
- };
696
- }
697
- if (!['stdio', 'sse', 'streamable-http'].includes(transport)) {
698
- return { type: 'message', message: `Invalid transport: "${transport}". Use: stdio, sse, streamable-http` };
699
- }
700
- if (configs[id]) {
701
- return { type: 'message', message: `Server "${id}" already exists. Use /mcp reload to reconnect.` };
702
- }
703
- const cfg = { id, name: id, transport };
704
- let i = 2;
705
- // Parse --env key=value flags
706
- const env = {};
707
- while (i < addParts.length && addParts[i]?.startsWith('--')) {
708
- if (addParts[i] === '--env' && i + 1 < addParts.length) {
709
- i++;
710
- const kv = addParts[i];
711
- if (!kv)
712
- break;
713
- const eqIdx = kv.indexOf('=');
714
- if (eqIdx > 0) {
715
- env[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1);
716
- }
717
- i++;
718
- }
719
- else {
720
- break;
721
- }
722
- }
723
- if (transport === 'stdio' && i < addParts.length) {
724
- cfg.command = addParts[i];
725
- const cmdArgs = addParts.slice(i + 1);
726
- if (cmdArgs.length > 0)
727
- cfg.args = cmdArgs;
728
- }
729
- else if (transport === 'sse' || transport === 'streamable-http') {
730
- cfg.url = addParts[i] || undefined;
731
- }
732
- if (Object.keys(env).length > 0)
733
- cfg.env = env;
734
- configs[id] = cfg;
735
- ctx.settings.mcp_servers = configs;
736
- 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.` };
737
- }
738
- case 'remove': {
739
- if (!rest) {
740
- return {
741
- type: 'message',
742
- message: 'Usage: /mcp remove <id>\nExample: /mcp remove filesystem',
743
- };
744
- }
745
- if (!configs[rest]) {
746
- return { type: 'message', message: `No MCP server found with ID: ${rest}` };
747
- }
748
- delete configs[rest];
749
- ctx.settings.mcp_servers = configs;
750
- return { type: 'update_mcp', mcpServerId: rest, message: `Removed MCP server "${rest}". Run /mcp reload to apply.` };
751
- }
752
- case 'reload': {
753
- return { type: 'update_mcp', message: 'MCP servers reloaded. Reconnecting...' };
754
- }
755
- default:
756
- return {
757
- type: 'message',
758
- message: `Unknown MCP action: "${sub}". Use: list, add, remove, reload`,
759
- };
760
- }
761
- }
762
- function handleTools(args, settings) {
763
- const toolsPerCall = settings.tools_per_call ?? 10;
764
- const websearchPerCall = settings.websearch_per_call ?? 5;
765
- if (!args.trim()) {
766
- return {
767
- type: 'message',
768
- 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`,
769
- };
770
- }
771
- const parts = args.trim().split(/\s+/);
772
- const val = parseInt(parts[0], 10);
773
- if (isNaN(val) || val < 1) {
774
- return { type: 'message', message: `Invalid value: "${parts[0]}". Must be a positive integer.` };
775
- }
776
- const result = {
777
- type: 'update_tools_per_call',
778
- toolsPerCall: val,
779
- message: `Tools per call set to: ${val}`,
780
- };
781
- if (parts[1]) {
782
- const wsVal = parseInt(parts[1], 10);
783
- if (isNaN(wsVal) || wsVal < 1) {
784
- return { type: 'message', message: `Invalid websearch value: "${parts[1]}". Must be a positive integer.` };
785
- }
786
- result.websearchPerCall = wsVal;
787
- }
788
- return result;
789
- }
790
- function handleWebsearch(args, settings) {
791
- const websearchPerCall = settings.websearch_per_call ?? 5;
792
- if (!args.trim()) {
793
- return {
794
- type: 'message',
795
- message: `WebSearch/WebFetch limit per turn: ${websearchPerCall}\n\nUsage:\n /websearch 3 — set websearch+fetch limit`,
796
- };
797
- }
798
- const val = parseInt(args.trim(), 10);
799
- if (isNaN(val) || val < 1) {
800
- return { type: 'message', message: `Invalid value: "${args}". Must be a positive integer.` };
801
- }
802
- return {
803
- type: 'update_websearch_per_call',
804
- websearchPerCall: val,
805
- message: `WebSearch+WebFetch per turn set to: ${val}`,
806
- };
807
- }
808
- function handleMemory(args, ctx) {
809
- const parts = args.trim().split(/\s+/);
810
- const sub = parts[0]?.toLowerCase();
811
- const rest = parts.slice(1).join(' ').trim();
812
- switch (sub) {
813
- case 'status':
814
- return {
815
- type: 'update_memory',
816
- message: 'Retrieving memory file sizes...',
817
- memory: { content: '', operation: 'status' },
818
- };
819
- case 'add': {
820
- if (!rest) {
821
- return {
822
- type: 'message',
823
- message: 'Usage: /memory add <text>\nExample: /memory add user prefers TypeScript over JavaScript',
824
- };
825
- }
826
- const entry = `- [${new Date().toISOString()}] ${rest}`;
827
- return {
828
- type: 'update_memory',
829
- message: `Memory captured: "${rest}"\nThe agent will organize it into memory files on the next turn.`,
830
- memory: { content: entry, operation: 'add' },
831
- };
832
- }
833
- default: {
834
- return {
835
- type: 'message',
836
- message: 'Memory commands:\n /memory status — Show memory file sizes\n /memory add <text> — Capture a memory for the agent to organize',
837
- };
838
- }
839
- }
840
- }
841
- /**
842
- * Parse /todo args: "/todo [scope:]add|list|complete|cancel|start|remove [title|id]"
843
- * Optionally with mode keywords: "auto/add", "notify/add"
844
- */
845
- function parseTodoArgs(args) {
846
- const parts = args.trim().split(/\s+/);
847
- if (!parts.length)
848
- return { scope: 'project', action: '', titleOrId: '' };
849
- let scope = 'project';
850
- let action = '';
851
- let idx = 0;
852
- // First token: scope, mode keyword, or action
853
- const first = parts[0].toLowerCase();
854
- if (first === 'personal' || first === 'project') {
855
- scope = first;
856
- idx = 1;
857
- }
858
- else if (first === 'auto' || first === 'notify') {
859
- // mode keyword: /todo auto add ..., /todo notify list ...
860
- idx = 1;
861
- }
862
- // Second token: action or scope fallback
863
- const second = parts[idx]?.toLowerCase() ?? '';
864
- const actions = ['list', 'add', 'complete', 'cancel', 'start', 'remove'];
865
- if (actions.includes(second)) {
866
- action = second;
867
- idx++;
868
- }
869
- else if (first === 'personal' || first === 'project') {
870
- // no scope given — second token is the action, third is the content
871
- if (actions.includes(second))
872
- action = second;
873
- idx += actions.includes(second) ? 1 : 0;
874
- }
875
- const titleOrId = parts.slice(idx).join(' ').trim();
876
- return { scope, action, titleOrId };
877
- }
878
- /** Helper to read a tasks file (unified format), falls back to legacy todo.json. */
879
- function readTasksAtPath(path) {
880
- if (!existsSync(path))
881
- return null;
882
- try {
883
- const raw = readFileSync(path, 'utf-8');
884
- const parsed = JSON.parse(raw);
885
- if (Array.isArray(parsed.tasks))
886
- return { tasks: parsed.tasks };
887
- return null;
888
- }
889
- catch {
890
- return null;
891
- }
892
- }
893
- /** Write a task record to file (unified format). */
894
- function writeTaskToFile(filePath, taskRecord) {
895
- let data = readTasksAtPath(filePath);
896
- if (!data) {
897
- data = { tasks: [] };
898
- }
899
- // Update/insert the task
900
- const idx = data.tasks.findIndex((t) => t.id === taskRecord.id);
901
- if (idx >= 0) {
902
- data.tasks[idx] = taskRecord;
903
- }
904
- else {
905
- data.tasks.push(taskRecord);
906
- }
907
- writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
908
- }
909
- /** Remove a task from file by ID. */
910
- function removeTaskFromFile(filePath, id) {
911
- if (!existsSync(filePath))
912
- return false;
913
- try {
914
- const raw = readFileSync(filePath, 'utf-8');
915
- const parsed = JSON.parse(raw);
916
- const tasks = parsed.tasks;
917
- if (!Array.isArray(tasks))
918
- return false;
919
- for (const t of tasks)
920
- normalizeTaskRecord(t);
921
- const idx = tasks.findIndex((t) => t.id === id);
922
- if (idx === -1)
923
- return false;
924
- tasks.splice(idx, 1);
925
- tasks.forEach((t, i) => { t.order = i; });
926
- parsed.tasks = tasks;
927
- writeFileSync(filePath, JSON.stringify(parsed, null, 2), 'utf-8');
928
- return true;
929
- }
930
- catch {
931
- return false;
932
- }
933
- }
934
- /** Normalize and return the active+done task counts from a TasksData. */
935
- function getTaskCounts(tasks) {
936
- const active = tasks.filter((t) => !['done', 'canceled'].includes(String(t.status ?? '')));
937
- const doneCount = tasks.filter((t) => t.status === 'done').length;
938
- return { active: active.length, done: doneCount };
939
- }
940
- function handleTodo(args, ctx) {
941
- const parsed = parseTodoArgs(args);
942
- const { scope, action, titleOrId } = parsed;
943
- const path = resolveTaskPath(scope, ctx.cwd);
944
- // Support both unified tasks.json and legacy todo.json
945
- const fullPath = existsSync(path) ? path : (scope === 'personal'
946
- ? join(homedir(), '.curie-agent', 'todo.json')
947
- : join(ctx.cwd, 'todo.json'));
948
- switch (action) {
949
- case 'list': {
950
- const data = readTasksAtPath(fullPath);
951
- if (!data || !data.tasks.length) {
952
- return { type: 'message', message: `No tasks in ${scope}.` };
953
- }
954
- // Normalize and filter
955
- const normalizedTasks = data.tasks.map((t) => normalizeTaskRecord(t));
956
- let tasks = normalizedTasks;
957
- const active = tasks.filter((t) => !['done', 'canceled'].includes(String(t.status ?? '')));
958
- const done = tasks.filter((t) => t.status === 'done');
959
- // Auto-detect mode from title (for user convenience)
960
- const lower = args.toLowerCase();
961
- const hasModeKeyword = /auto/.test(lower) || /notify/.test(lower);
962
- const lines = [`Tasks (${scope}) — ${active.length} active, ${done.length} done:`];
963
- for (const t of active.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))) {
964
- const icon = String(t.status) === 'in_progress' ? '[*]' : '-';
965
- const prio = (t.priority !== 'medium' && t.priority) ? ` [${t.priority}]` : '';
966
- const modeCol = t.mode ? `[${String(t.mode).toUpperCase()}]` : '[MANUAL]';
967
- const scheduledAt = typeof t.scheduled_at === 'number' ? t.scheduled_at : undefined;
968
- const timeStr = scheduledAt ? ` (at ${new Date(scheduledAt).toLocaleString()})` : '';
969
- lines.push(` ${icon} ${String(t.id).slice(0, 8)} ${modeCol}${prio} ${t.title}${timeStr}`);
970
- }
971
- return { type: 'message', message: lines.join('\n') };
972
- }
973
- case 'add': {
974
- if (!titleOrId) {
975
- return {
976
- type: 'message',
977
- message: `Usage: /todo add <title>\nExample: /todo add "Finish the report"\n /todo agent add "build at 3pm" — agent executes it\n /todo notify add "remind about X" — notification only`,
978
- };
979
- }
980
- // Detect mode from keyword or natural language time parsing
981
- let mode = 'human';
982
- const lower = titleOrId.toLowerCase();
983
- if (lower.startsWith('at ') || /\bat\b/.test(lower)) {
984
- // Has time reference — try to parse with TaskManager
985
- const taskMgr = ctx.taskManager;
986
- let instruction;
987
- // Check if "agent" was explicitly requested
988
- if (/^agent\s+add\s*/i.test(args) || /^agent\b/.test(args)) {
989
- mode = 'agent';
990
- }
991
- else if (/^notify\s+add\s*/i.test(args) || /notify\s+/i.test(args)) {
992
- mode = 'notify';
993
- }
994
- const timeKeywordPattern = /^(agent|notify)\s*add\s*/i;
995
- instruction = titleOrId.replace(timeKeywordPattern, '');
996
- if (!instruction) {
997
- return { type: 'message', message: 'Usage: /todo add <title>\n /todo agent add "build at 3pm"\n /todo notify add "remind about X"' };
998
- }
999
- // Try natural language time parsing
1000
- let { parseReminderTime } = {};
1001
- try {
1002
- parseReminderTime = require('../../core/src/reminder-parser.js').parseReminderTime;
1003
- }
1004
- catch { /* module not available */ }
1005
- if (mode === 'human' && parseReminderTime) {
1006
- const parsed = parseReminderTime(instruction);
1007
- if (parsed) {
1008
- instruction = parsed.message;
1009
- if (taskMgr) {
1010
- taskMgr.load();
1011
- taskMgr.create({ title: instruction, mode: 'agent', scope: 'personal', scheduled_at: parsed.scheduledAt });
1012
- const timeStr = new Date(parsed.scheduledAt).toLocaleString();
1013
- return { type: 'message', message: `Task scheduled:\nTime: ${timeStr}\nInstruction: ${instruction}` };
1014
- }
1015
- }
1016
- }
1017
- if (mode === 'notify' && parseReminderTime) {
1018
- const parsed = parseReminderTime(instruction);
1019
- if (parsed && taskMgr) {
1020
- taskMgr.load();
1021
- const task = taskMgr.create({ title: parsed.message, mode: 'notify', scope: 'personal', scheduled_at: parsed.scheduledAt });
1022
- const timeStr = new Date(task.scheduled_at).toLocaleString();
1023
- return { type: 'message', message: `Reminder scheduled:\nTime: ${timeStr}\nMessage: ${parsed.message}` };
1024
- }
1025
- }
1026
- if (mode === 'agent' && parseReminderTime) {
1027
- const parsed = parseReminderTime(instruction);
1028
- if (parsed && taskMgr) {
1029
- taskMgr.load();
1030
- const task = taskMgr.create({ title: parsed.message, mode: 'agent', scope: 'personal', scheduled_at: parsed.scheduledAt });
1031
- const timeStr = new Date(task.scheduled_at).toLocaleString();
1032
- return { type: 'message', message: `Scheduled task:\nTime: ${timeStr}\nInstruction: ${parsed.message}` };
1033
- }
1034
- }
1035
- // No time could be parsed — fall through to manual mode
1036
- }
1037
- else if (/^agent\s+add\s*/i.test(args) || /^agent\b/.test(args)) {
1038
- mode = 'agent';
1039
- }
1040
- else if (/^notify\s+add\s*/i.test(args) || /notify\s+/i.test(args)) {
1041
- mode = 'notify';
1042
- }
1043
- const title = (mode === 'human' ? titleOrId : titleOrId.replace(/^(agent|notify)\s+add\s*/i, '').trim()) || titleOrId;
1044
- if (!title)
1045
- return { type: 'message', message: `Usage: /todo add <title>\nExample: /todo add "Finish the report"` };
1046
- let data = readTasksAtPath(fullPath);
1047
- if (!data) {
1048
- data = { $schema: 'tasks.schema.json', version: 1, tasks: [] };
1049
- }
1050
- data.tasks = data.tasks.map((t) => normalizeTaskRecord(t));
1051
- const id = crypto.randomUUID();
1052
- const task = {
1053
- id,
1054
- title,
1055
- description: '',
1056
- status: 'todo',
1057
- priority: 'medium',
1058
- tags: [],
1059
- order: data.tasks.length,
1060
- created_at: new Date().toISOString(),
1061
- completed_at: null,
1062
- };
1063
- // Add mode and scope fields
1064
- task.mode = mode;
1065
- task.scope = scope;
1066
- data.tasks.push(task);
1067
- writeFileSync(fullPath, JSON.stringify(data, null, 2), 'utf-8');
1068
- const modePrefix = mode === 'human' ? '' : `[${mode}] `;
1069
- return { type: 'message', message: `${modePrefix}Added task: "${title}" (ID: ${id.slice(0, 8)})` };
1070
- }
1071
- case 'complete': {
1072
- if (!titleOrId)
1073
- return { type: 'message', message: 'Usage: /todo complete <id>' };
1074
- let data = readTasksAtPath(fullPath);
1075
- if (!data)
1076
- return { type: 'message', message: 'No tasks found.' };
1077
- data.tasks = data.tasks.map((t) => normalizeTaskRecord(t));
1078
- const idx = data.tasks.findIndex((t) => String(t.id) === titleOrId || String(t.id).startsWith(titleOrId));
1079
- if (idx === -1)
1080
- return { type: 'message', message: `Task not found: ${titleOrId}` };
1081
- data.tasks[idx].status = 'done';
1082
- data.tasks[idx].completed_at = new Date().toISOString();
1083
- writeFileSync(fullPath, JSON.stringify(data, null, 2), 'utf-8');
1084
- return { type: 'message', message: `Completed: "${data.tasks[idx].title}"` };
1085
- }
1086
- case 'cancel': {
1087
- if (!titleOrId)
1088
- return { type: 'message', message: 'Usage: /todo cancel <id>' };
1089
- let data = readTasksAtPath(fullPath);
1090
- if (!data)
1091
- return { type: 'message', message: 'No tasks found.' };
1092
- data.tasks = data.tasks.map((t) => normalizeTaskRecord(t));
1093
- const idx = data.tasks.findIndex((t) => String(t.id) === titleOrId || String(t.id).startsWith(titleOrId));
1094
- if (idx === -1)
1095
- return { type: 'message', message: `Task not found: ${titleOrId}` };
1096
- data.tasks[idx].status = 'canceled';
1097
- writeFileSync(fullPath, JSON.stringify(data, null, 2), 'utf-8');
1098
- return { type: 'message', message: `Canceled: "${data.tasks[idx].title}"` };
1099
- }
1100
- case 'start': {
1101
- if (!titleOrId)
1102
- return { type: 'message', message: 'Usage: /todo start <id>' };
1103
- let data = readTasksAtPath(fullPath);
1104
- if (!data)
1105
- return { type: 'message', message: 'No tasks found.' };
1106
- data.tasks = data.tasks.map((t) => normalizeTaskRecord(t));
1107
- const idx = data.tasks.findIndex((t) => String(t.id) === titleOrId || String(t.id).startsWith(titleOrId));
1108
- if (idx === -1)
1109
- return { type: 'message', message: `Task not found: ${titleOrId}` };
1110
- data.tasks[idx].status = 'in_progress';
1111
- writeFileSync(fullPath, JSON.stringify(data, null, 2), 'utf-8');
1112
- return { type: 'message', message: `Started: "${data.tasks[idx].title}"` };
1113
- }
1114
- case 'remove': {
1115
- if (!titleOrId)
1116
- return { type: 'message', message: 'Usage: /todo remove <id>' };
1117
- if (removeTaskFromFile(fullPath, titleOrId)) {
1118
- return { type: 'message', message: `Removed task: ${titleOrId.slice(0, 8)}` };
1119
- }
1120
- return { type: 'message', message: `Task not found: ${titleOrId}` };
1121
- }
1122
- default: {
1123
- if (!action) {
1124
- return {
1125
- type: 'message',
1126
- message: 'Task commands:\n /todo list [personal|project] — List tasks\n /todo add <title> — Add a manual task\n /todo auto add "X at Y" — Agent executes X at Y\n /todo notify add "remind about X" — Notification only\n /todo complete <id> — Mark done\n /todo cancel <id> — Cancel a task\n /todo start <id> — Start working on it\n /todo remove <id> — Delete permanently',
1127
- };
1128
- }
1129
- return { type: 'message', message: `Unknown todo action: "${action}". Use: list, add, complete, cancel, start, remove` };
1130
- }
1131
- }
1132
- }
1133
- function formatChannelMessages(messages) {
1134
- const lines = [`Conversation Messages (${messages.length}):`];
1135
- const maxOutput = 8000;
1136
- for (let i = 0; i < messages.length; i++) {
1137
- const msg = messages[i];
1138
- const idx = String(i + 1).padStart(2, ' ');
1139
- const roleLabel = msg.role === 'user' ? 'User'
1140
- : msg.role === 'assistant' ? 'Assistant'
1141
- : msg.role === 'tool' ? 'Tool'
1142
- : msg.role === 'tool-group' ? 'Tools'
1143
- : msg.role === 'system' ? 'System'
1144
- : msg.role === 'decision' ? 'Decision'
1145
- : msg.role === 'heartbeat' ? 'Heartbeat'
1146
- : msg.role === 'task' ? 'Task'
1147
- : msg.role === 'debug' ? 'Debug'
1148
- : msg.role;
1149
- const titlePrefix = msg.title ? `[${msg.title}] ` : '';
1150
- const truncated = msg.content.length > 300
1151
- ? msg.content.slice(0, 300) + '...'
1152
- : msg.content;
1153
- lines.push(`[${idx}] ${roleLabel}: ${titlePrefix}${truncated}`);
1154
- }
1155
- const fullOutput = lines.join('\n');
1156
- if (fullOutput.length > maxOutput) {
1157
- const cut = fullOutput.slice(0, maxOutput);
1158
- lines.length = 0;
1159
- lines.push(cut);
1160
- lines.push(`\n[Output truncated at ${maxOutput} characters. Total messages: ${messages.length}]`);
1161
- }
1162
- return { type: 'message', message: lines.join('\n') };
1163
- }
1164
- function formatTurnLoopMessages(messages) {
1165
- const lines = [`Conversation Messages (${messages.length}):`];
1166
- const maxOutput = 8000;
1167
- for (let i = 0; i < messages.length; i++) {
1168
- const msg = messages[i];
1169
- const idx = String(i + 1).padStart(2, ' ');
1170
- if (msg.role === 'user') {
1171
- const truncated = msg.content.length > 200
1172
- ? msg.content.slice(0, 200) + '...'
1173
- : msg.content;
1174
- lines.push(`[${idx}] User: ${truncated}`);
1175
- }
1176
- else if (msg.role === 'assistant') {
1177
- const textParts = [];
1178
- const toolCalls = [];
1179
- for (const block of msg.content) {
1180
- if (block.type === 'text') {
1181
- textParts.push(block.text);
1182
- }
1183
- else if (block.type === 'thinking') {
1184
- const short = block.thinking.length > 150
1185
- ? block.thinking.slice(0, 150) + '...'
1186
- : block.thinking;
1187
- textParts.push(`[thinking ${short.length} chars]`);
1188
- }
1189
- else if (block.type === 'tool-use') {
1190
- toolCalls.push({ name: block.name, input: block.input });
1191
- }
1192
- }
1193
- lines.push(`[${idx}] Assistant:`);
1194
- for (const text of textParts) {
1195
- const textTruncated = text.length > 200 ? text.slice(0, 200) + '...' : text;
1196
- lines.push(` ${textTruncated}`);
1197
- }
1198
- for (const tc of toolCalls) {
1199
- const inputStr = JSON.stringify(tc.input).slice(0, 200);
1200
- const inputTruncated = inputStr.length >= 200 ? inputStr + '...' : inputStr;
1201
- lines.push(` → ${tc.name}(${inputTruncated})`);
1202
- }
1203
- }
1204
- else if (msg.role === 'tool') {
1205
- const truncated = msg.content.length > 300
1206
- ? msg.content.slice(0, 300) + '...\n[truncated, ' + msg.content.length + ' bytes total]'
1207
- : msg.content;
1208
- lines.push(`[${idx}] Tool Result (${msg.toolUseId}):`);
1209
- for (const line of truncated.split('\n').slice(0, 5)) {
1210
- lines.push(` ${line}`);
1211
- }
1212
- }
1213
- }
1214
- const fullOutput = lines.join('\n');
1215
- if (fullOutput.length > maxOutput) {
1216
- const cut = fullOutput.slice(0, maxOutput);
1217
- lines.length = 0;
1218
- lines.push(cut);
1219
- lines.push(`\n[Output truncated at ${maxOutput} characters. Total messages: ${messages.length}]`);
1220
- }
1221
- return { type: 'message', message: lines.join('\n') };
1222
- }
1223
- function handleContext(ctx, args) {
1224
- const sub = args?.trim().toLowerCase();
1225
- if (sub === 'messages') {
1226
- // TurnLoop messages are the source of truth — they contain full tool output,
1227
- // thinking blocks, and structured assistant responses. channelMessages is a
1228
- // display-only layer that collapses tool results into summaries.
1229
- const loopMsgs = ctx.messages;
1230
- if (loopMsgs && loopMsgs.length > 0) {
1231
- return formatTurnLoopMessages(loopMsgs);
1232
- }
1233
- // Fallback: channelMessages from TUI state (always available, but lossy).
1234
- const channelMsgs = ctx.channelMessages;
1235
- if (channelMsgs && channelMsgs.length > 0) {
1236
- const filtered = channelMsgs.filter((m) => !(m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('█ ')));
1237
- if (filtered.length === 0) {
1238
- return { type: 'message', message: 'No messages yet. Start a conversation to see message history.' };
1239
- }
1240
- return formatChannelMessages(filtered);
1241
- }
1242
- return { type: 'message', message: 'No messages yet. Start a conversation to see message history.' };
1243
- }
1244
- // /context compact [detailed|brief] — summarize conversation to free context
1245
- if (sub && sub.startsWith('compact')) {
1246
- const parts = sub.split(/\s+/);
1247
- const depth = (parts[1] ?? 'detailed').toLowerCase();
1248
- if (!['detailed', 'brief'].includes(depth)) {
1249
- return {
1250
- type: 'message',
1251
- message: `Unknown compact depth: "${depth}". Use: /context compact [detailed|brief]`,
1252
- };
1253
- }
1254
- const loopMsgs = ctx.messages;
1255
- if (!loopMsgs || loopMsgs.length < 2) {
1256
- return {
1257
- type: 'message',
1258
- message: 'Not enough messages to compact. Need at least 2 messages (user + assistant turn).',
1259
- };
1260
- }
1261
- return {
1262
- type: 'compact',
1263
- compact: {
1264
- messages: loopMsgs,
1265
- depth,
1266
- },
1267
- message: `Compacting conversation (${loopMsgs.length} messages)... This will use one AI turn to summarize your session. The agent will continue automatically.`,
1268
- };
1269
- }
1270
- // /context auto — autocompaction settings and controls
1271
- if (sub && (sub === 'auto' || sub.startsWith('auto '))) {
1272
- const parts = sub.split(/\s+/);
1273
- const action = parts[0]?.toLowerCase() ?? 'auto';
1274
- const arg1 = parts[1]?.toLowerCase();
1275
- const arg2 = parts[2];
1276
- const s = ctx.settings;
1277
- if (action === 'auto' && !arg1) {
1278
- // Show current settings
1279
- const lines = [
1280
- 'Autocompaction Settings:',
1281
- ` Enabled: ${s.auto_compact?.enabled ?? 'on'}`,
1282
- ` Context fill threshold: ${s.auto_compact?.threshold ?? 75}%`,
1283
- ` Warning threshold: ${s.auto_compact?.warn_threshold ?? 60}%`,
1284
- ` Forced compaction threshold: ${s.auto_compact?.forced_threshold ?? 85}%`,
1285
- ` Pricing tier warning: ${s.pricing_tier_warn ?? 'on'}`,
1286
- '',
1287
- 'Usage:',
1288
- ' /context auto on/off — enable or disable autocompaction',
1289
- ' /context auto threshold <N> — set compaction threshold (%)',
1290
- ' /context auto warn <N> — set warning threshold (%)',
1291
- ' /context auto pricing on/off — enable/disable pricing tier warnings',
1292
- ];
1293
- return { type: 'message', message: lines.join('\n') };
1294
- }
1295
- if (arg1 === 'on') {
1296
- ctx.settingsMgr?.update({ auto_compact: { ...s.auto_compact, enabled: 'on' } });
1297
- return { type: 'message', message: 'Autocompaction enabled.' };
1298
- }
1299
- if (arg1 === 'off') {
1300
- ctx.settingsMgr?.update({ auto_compact: { ...s.auto_compact, enabled: 'off' } });
1301
- return { type: 'message', message: 'Autocompaction disabled.' };
1302
- }
1303
- if (arg1 === 'threshold' && arg2) {
1304
- const pct = parseInt(arg2, 10);
1305
- if (isNaN(pct) || pct < 10 || pct > 99) {
1306
- return { type: 'message', message: 'Invalid threshold. Use a value between 10 and 99.' };
1307
- }
1308
- ctx.settingsMgr?.update({ auto_compact: { ...s.auto_compact, threshold: pct } });
1309
- return { type: 'message', message: `Compaction threshold set to ${pct}%.` };
1310
- }
1311
- if (arg1 === 'warn' && arg2) {
1312
- const pct = parseInt(arg2, 10);
1313
- if (isNaN(pct) || pct < 5 || pct > 95) {
1314
- return { type: 'message', message: 'Invalid warning threshold. Use a value between 5 and 95.' };
1315
- }
1316
- ctx.settingsMgr?.update({ auto_compact: { ...s.auto_compact, warn_threshold: pct } });
1317
- return { type: 'message', message: `Warning threshold set to ${pct}%.` };
1318
- }
1319
- if (arg1 === 'pricing') {
1320
- if (arg2 === 'on') {
1321
- ctx.settingsMgr?.update({ pricing_tier_warn: 'on' });
1322
- return { type: 'message', message: 'Pricing tier warnings enabled.' };
1323
- }
1324
- if (arg2 === 'off') {
1325
- ctx.settingsMgr?.update({ pricing_tier_warn: 'off' });
1326
- return { type: 'message', message: 'Pricing tier warnings disabled.' };
1327
- }
1328
- return { type: 'message', message: 'Usage: /context auto pricing on/off' };
1329
- }
1330
- return { type: 'message', message: 'Usage: /context auto [on|off|threshold N|warn N|pricing on/off]\nRun "/context auto" without arguments to see current settings.' };
1331
- }
1332
- // Default: context window visual (unchanged behavior)
1333
- const input = ctx.contextWindowInputTokens ?? ctx.inputTokens ?? 0;
1334
- const output = ctx.contextWindowOutputTokens ?? ctx.outputTokens ?? 0;
1335
- const model = ctx.model || 'unknown';
1336
- const windowSize = ctx.settings.providers?.[ctx.settings.current_provider]?.model_context_window ?? ctx.contextWindowSize ?? 200_000;
1337
- const pct = input > 0 ? Math.min(100, Math.round((input / windowSize) * 100)) : 0;
1338
- const filled = Math.round((pct / 100) * 24);
1339
- const bar = '█'.repeat(filled) + '░'.repeat(24 - filled);
1340
- const fmt = (n) => (n >= 1_000 ? `${Math.round(n / 1_000)}k` : String(n));
1341
- if (input === 0 && output === 0) {
1342
- return { type: 'message', message: 'No token data yet. Start a conversation to see context window usage.' };
1343
- }
1344
- const lines = [`Context Window (${model}): ${bar} ${pct}% (${fmt(input)}/${fmt(windowSize)})`];
1345
- if (input > 0 && output > 0) {
1346
- lines.push(` └─ ${fmt(input)} in / ${fmt(output)} out`);
1347
- }
1348
- return { type: 'message', message: lines.join('\n') };
1349
- }
1350
- const PROVIDER_MODEL_ALIASES = {
1351
- opus: 'claude-opus-4-7',
1352
- sonnet: 'claude-sonnet-4-6',
1353
- haiku: 'claude-haiku-4-5-20251001',
1354
- gpt4o: 'gpt-4o',
1355
- gpt4turbo: 'gpt-4-turbo',
1356
- o1: 'o1',
1357
- 'o3-mini': 'o3-mini',
1358
- };
1359
- function handleProvider(args) {
1360
- const validProviders = ['anthropic', 'openai', 'google', 'local', 'ollama', 'openrouter'];
1361
- if (!args) {
1362
- return {
1363
- type: 'message',
1364
- message: `Usage: /provider <name>\nProviders: ${validProviders.join(', ')}\n\nCurrent settings:\n anthropic: providers.anthropic.api_key, providers.anthropic.url\n openai: providers.openai.api_key, providers.openai.url\n google: providers.google.api_key, providers.google.url\n local: providers.local.url, providers.local.api_key\n ollama: providers.ollama.url, providers.ollama.api_key\n openrouter: providers.openrouter.api_key, providers.openrouter.url`,
1365
- };
1366
- }
1367
- const provider = args.toLowerCase();
1368
- if (!validProviders.includes(provider)) {
1369
- return {
1370
- type: 'message',
1371
- message: `Unknown provider: "${args}". Valid: ${validProviders.join(', ')}`,
1372
- };
1373
- }
1374
- return { type: 'update_provider', provider, message: `Provider switched to: ${provider}` };
1375
- }
1376
- function handleHeartbeat(args, ctx) {
1377
- const parts = args.trim().split(/\s+/);
1378
- const sub = parts[0]?.toLowerCase();
1379
- const rest = parts.slice(1).join(' ').trim();
1380
- switch (sub) {
1381
- case 'status':
1382
- case '': {
1383
- const active = ctx.settings.heartbeat?.schedule === 'on';
1384
- const intraday = ctx.settings.heartbeat?.intraday ?? '';
1385
- const daily = ctx.settings.heartbeat?.daily ?? '6:00';
1386
- const weekly = ctx.settings.heartbeat?.weekly ?? 'monday@6:00';
1387
- const monthly = ctx.settings.heartbeat?.monthly ?? '1@6:00';
1388
- const dreaming = ctx.settings.heartbeat?.dreaming ?? '2:00';
1389
- const intradayDisplay = intraday ? intraday.split(',').map((s) => s.trim()).join(', ') : '(not set)';
1390
- const picked = pickNextSchedule({ HEARTBEAT_INTRADAY: intraday, HEARTBEAT_DAILY: daily, HEARTBEAT_WEEKLY: weekly, HEARTBEAT_MONTHLY: monthly, HEARTBEAT_DREAMING: dreaming });
1391
- const activeSchedule = picked ? `${picked.type} (${picked.value})` : '(none configured)';
1392
- return {
1393
- type: 'message',
1394
- message: `Heartbeat cycle:\n Enabled : ${active ? 'yes' : 'no'}\n Active schedule: ${activeSchedule}\n\n Intraday: ${intradayDisplay}\n Daily : ${daily}\n Weekly : ${weekly}\n Monthly : ${monthly}\n Dreaming: ${dreaming}\n\nUsage:\n /heartbeat — show status\n /heartbeat enable — turn on\n /heartbeat disable — turn off\n /heartbeat intraday <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 dreaming <H:MM> — set dreaming time (24h)\n /heartbeat now — run immediately`,
1395
- };
1396
- }
1397
- case 'enable': {
1398
- return { type: 'notification', notification: { type: 'heartbeat', enabled: true } };
1399
- }
1400
- case 'disable': {
1401
- return { type: 'notification', notification: { type: 'heartbeat', enabled: false } };
1402
- }
1403
- case 'intraday': {
1404
- if (!rest) {
1405
- const current = ctx.settings.heartbeat?.intraday ?? '';
1406
- return {
1407
- type: 'message',
1408
- message: `Usage: /heartbeat intraday <H:MM,...>\nExample: /heartbeat intraday 8:10,10:10,14:20,16:20\nCurrent: ${current || '(not set)'}`,
1409
- };
1410
- }
1411
- const tokens = rest.split(',').map((s) => s.trim()).filter(Boolean);
1412
- const invalid = tokens.filter((t) => {
1413
- if (!/^\d{1,2}:\d{2}$/.test(t))
1414
- return true;
1415
- const colonIdx = t.indexOf(':');
1416
- const h = parseInt(t.slice(0, colonIdx), 10);
1417
- const m = parseInt(t.slice(colonIdx + 1), 10);
1418
- return h < 0 || h > 23 || m < 0 || m > 59;
1419
- });
1420
- if (invalid.length > 0) {
1421
- return { type: 'message', message: `Invalid time(s): ${invalid.join(', ')}. Use H:MM in 24h format (e.g., 8:10,14:20).` };
1422
- }
1423
- const value = tokens.join(',');
1424
- return {
1425
- type: 'notification',
1426
- notification: { type: 'heartbeat-set', key: 'heartbeat.intraday', value },
1427
- };
1428
- }
1429
- case 'daily': {
1430
- if (!rest) {
1431
- return {
1432
- type: 'message',
1433
- message: `Usage: /heartbeat daily <H:MM>\nExample: /heartbeat daily 6:00\nCurrent: ${ctx.settings.heartbeat?.daily ?? '6:00'}`,
1434
- };
1435
- }
1436
- if (!/^\d{1,2}:\d{2}$/.test(rest)) {
1437
- return { type: 'message', message: `Invalid time: "${rest}". Use H:MM (e.g., 6:00, 14:30).` };
1438
- }
1439
- const [hStr, mStr] = rest.split(':');
1440
- const h = parseInt(hStr ?? '0', 10);
1441
- const m = parseInt(mStr ?? '0', 10);
1442
- if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
1443
- return { type: 'message', message: `Invalid time: "${rest}". Hour 0-23, minute 0-59.` };
1444
- }
1445
- return {
1446
- type: 'notification',
1447
- notification: { type: 'heartbeat-set', key: 'heartbeat.daily', value: rest },
1448
- };
1449
- }
1450
- case 'weekly': {
1451
- if (!rest) {
1452
- return {
1453
- type: 'message',
1454
- message: `Usage: /heartbeat weekly <day@H:MM>\nExample: /heartbeat weekly monday@6:00\nCurrent: ${ctx.settings.heartbeat?.weekly ?? 'monday@6:00'}`,
1455
- };
1456
- }
1457
- const atIdx = rest.indexOf('@');
1458
- if (atIdx < 0) {
1459
- return { type: 'message', message: `Invalid weekly schedule: "${rest}". Use day@H:MM (e.g., monday@6:00).` };
1460
- }
1461
- const day = rest.slice(0, atIdx).toLowerCase();
1462
- const validDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1463
- if (!validDays.includes(day)) {
1464
- return { type: 'message', message: `Invalid day: "${day}". Use: ${validDays.join(', ')}.` };
1465
- }
1466
- if (!/^\d{1,2}:\d{2}$/.test(rest.slice(atIdx + 1))) {
1467
- return { type: 'message', message: `Invalid time: "${rest}". Use day@H:MM (e.g., monday@6:00).` };
1468
- }
1469
- return {
1470
- type: 'notification',
1471
- notification: { type: 'heartbeat-set', key: 'heartbeat.weekly', value: rest },
1472
- };
1473
- }
1474
- case 'monthly': {
1475
- if (!rest) {
1476
- return {
1477
- type: 'message',
1478
- message: `Usage: /heartbeat monthly <D@H:MM>\nExample: /heartbeat monthly 1@6:00\nCurrent: ${ctx.settings.heartbeat?.monthly ?? '1@6:00'}`,
1479
- };
1480
- }
1481
- const atIdx = rest.indexOf('@');
1482
- if (atIdx < 0) {
1483
- return { type: 'message', message: `Invalid monthly schedule: "${rest}". Use D@H:MM (e.g., 1@6:00).` };
1484
- }
1485
- const dayOfMonth = parseInt(rest.slice(0, atIdx), 10);
1486
- if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
1487
- return { type: 'message', message: `Invalid day: "${rest}". Day of month 1-31.` };
1488
- }
1489
- if (!/^\d{1,2}:\d{2}$/.test(rest.slice(atIdx + 1))) {
1490
- return { type: 'message', message: `Invalid time: "${rest}". Use D@H:MM (e.g., 1@6:00).` };
1491
- }
1492
- return {
1493
- type: 'notification',
1494
- notification: { type: 'heartbeat-set', key: 'heartbeat.monthly', value: rest },
1495
- };
1496
- }
1497
- case 'dreaming': {
1498
- if (!rest) {
1499
- return {
1500
- type: 'message',
1501
- message: `Usage: /heartbeat dreaming <H:MM>\nExample: /heartbeat dreaming 2:00\nCurrent: ${ctx.settings.heartbeat?.dreaming ?? '2:00'}`,
1502
- };
1503
- }
1504
- if (!/^\d{1,2}:\d{2}$/.test(rest)) {
1505
- return { type: 'message', message: `Invalid time: "${rest}". Use H:MM (e.g., 2:00, 14:30).` };
1506
- }
1507
- const [hStr, mStr] = rest.split(':');
1508
- const h = parseInt(hStr ?? '0', 10);
1509
- const m = parseInt(mStr ?? '0', 10);
1510
- if (isNaN(h) || isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
1511
- return { type: 'message', message: `Invalid time: "${rest}". Hour 0-23, minute 0-59.` };
1512
- }
1513
- return {
1514
- type: 'notification',
1515
- notification: { type: 'heartbeat-set', key: 'heartbeat.dreaming', value: rest },
1516
- };
1517
- }
1518
- case 'now': {
1519
- return { type: 'notification', notification: { type: 'heartbeat-now' } };
1520
- }
1521
- default:
1522
- return {
1523
- type: 'message',
1524
- message: `Unknown heartbeat action: "${sub}". Use: status, enable, disable, intraday, daily, weekly, monthly, dreaming, now`,
1525
- };
1526
- }
1527
- }
1528
- function handleSnapshots(ctx) {
1529
- const list = ctx.listSnapshots?.(ctx.cwd);
1530
- if (!list || list.length === 0) {
1531
- return { type: 'message', message: 'No snapshots found for this directory.' };
1532
- }
1533
- const lines = [`Git Snapshots (${list.length}):`];
1534
- list.forEach((s, i) => {
1535
- const dt = new Date(s.timestamp);
1536
- const timeStr = dt.toLocaleString();
1537
- lines.push(` ${i})-${timeStr} — ${s.sha.slice(0, 7)} (${s.label}, ${s.changedFiles} file${s.changedFiles === 1 ? '' : 's'})`);
1538
- });
1539
- lines.push('\nUse /revert <index> to restore a snapshot.');
1540
- return { type: 'message', message: lines.join('\n') };
1541
- }
1542
- async function handleRevert(args, ctx) {
1543
- if (!ctx.revertTo) {
1544
- return { type: 'message', message: 'Snapshot revert is not available in this context.' };
1545
- }
1546
- const snapshots = ctx.listSnapshots?.(ctx.cwd) ?? [];
1547
- if (snapshots.length === 0) {
1548
- return { type: 'message', message: 'No snapshots found. Snapshots are created automatically in yolo mode.' };
1549
- }
1550
- // Parse index from args (default: 0 = most recent)
1551
- const idx = args ? parseInt(args.trim(), 10) : 0;
1552
- if (isNaN(idx) || idx < 0 || idx >= snapshots.length) {
1553
- return {
1554
- type: 'message',
1555
- message: `Invalid index: ${args || '0'}. Choose 0-${snapshots.length - 1} (0 = most recent).\n${snapshots.map((s, i) => ` ${i}) ${s.sha.slice(0, 7)} — ${new Date(s.timestamp).toLocaleString()}`).join('\n')}`,
1556
- };
1557
- }
1558
- const target = snapshots[idx];
1559
- if (!target) {
1560
- return { type: 'message', message: 'Snapshot not found.' };
1561
- }
1562
- const result = await ctx.revertTo(ctx.cwd, target.sha);
1563
- if (result.success) {
1564
- const files = target.changedFiles != null ? `${target.changedFiles} file${target.changedFiles === 1 ? '' : 's'} restored` : '';
1565
- return { type: 'message', message: files ? `Reverted to snapshot ${target.sha.slice(0, 7)} (${target.label}) — ${files}` : `Reverted to snapshot ${target.sha.slice(0, 7)} (${target.label}).` };
1566
- }
1567
- return { type: 'message', message: result.error ?? 'Revert failed.' };
1568
- }
1569
- function handleTask(args, ctx) {
1570
- // /task creates auto-mode scheduled tasks (LLM executes at given time).
1571
- // Uses unified TaskManager.
1572
- const parts = args.trim().split(/\s+/);
1573
- const action = parts[0]?.toLowerCase();
1574
- const rest = parts.slice(1).join(' ').trim();
1575
- if (!ctx.taskManager) {
1576
- return { type: 'message', message: 'Task service not available. Please restart the application.' };
1577
- }
1578
- switch (action) {
1579
- case 'create': {
1580
- if (!rest) {
1581
- return {
1582
- type: 'message',
1583
- message: 'Usage: /task create <instruction at time>\nExample:\n /task create "at 7:55 make a report about AI models"\nOr use /todo agent add "..." for the new unified format.',
1584
- };
1585
- }
1586
- let { parseReminderTime } = {};
1587
- try {
1588
- Object.assign(require('../../core/src/reminder-parser.js'), { parseReminderTime });
1589
- }
1590
- catch { /* not available */ }
1591
- if (!parseReminderTime) {
1592
- return { type: 'message', message: `Could not parse time from: "${rest}".\nTry: /todo agent add "at 7:55 do something"` };
1593
- }
1594
- const parsed = parseReminderTime(rest);
1595
- if (!parsed) {
1596
- return { type: 'message', message: `Could not parse time from: "${rest}".\nUse /todo agent add "instruction at time"` };
1597
- }
1598
- ctx.taskManager.load();
1599
- const task = ctx.taskManager.create({ title: parsed.message, mode: 'agent', scope: 'personal', scheduled_at: parsed.scheduledAt });
1600
- return { type: 'message', message: `Task scheduled:\nTime: ${new Date(task.scheduled_at).toLocaleString()}\nInstruction: ${task.title}\nID: ${task.id}` };
1601
- }
1602
- case 'list': {
1603
- ctx.taskManager.load();
1604
- const tasks = ctx.taskManager.list({ mode: 'agent' });
1605
- if (!tasks.length)
1606
- return { type: 'message', message: 'No scheduled tasks.\nUse /todo agent add "..." to create one.' };
1607
- const lines = [`Tasks (${tasks.length}):`];
1608
- for (const t of tasks) {
1609
- const timeStr = t.scheduled_at ? new Date(t.scheduled_at).toLocaleString() : '—';
1610
- const statusLabel = t.status === 'pending' ? 'PENDING' : t.status === 'executing' ? 'RUNNING' : t.status.toUpperCase();
1611
- lines.push(` [${statusLabel}] ${t.title}\n Time: ${timeStr}\n ID: ${t.id.slice(0, 8)}`);
1612
- }
1613
- return { type: 'message', message: lines.join('\n') };
1614
- }
1615
- case 'delete': {
1616
- if (!rest)
1617
- return { type: 'message', message: 'Usage: /task delete <id>' };
1618
- const result = ctx.taskManager.cancelTask(rest);
1619
- if (result)
1620
- return { type: 'message', message: 'Task cancelled.' };
1621
- return { type: 'message', message: `No task found with ID: ${rest}` };
1622
- }
1623
- default:
1624
- return {
1625
- type: 'message',
1626
- message: 'Usage: /task <create|list|delete>\n create <instruction at time> — Schedule a task\n list — List scheduled tasks\n delete <id> — Cancel a task\nOr use /todo auto add "..." for the new unified format.',
1627
- };
1628
- }
1629
- }
1630
35
  //# sourceMappingURL=slash-commands.js.map