@dotsetlabs/dotclaw 1.9.0 → 2.0.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 (167) hide show
  1. package/.env.example +6 -0
  2. package/README.md +14 -7
  3. package/config-examples/groups/global/CLAUDE.md +6 -14
  4. package/config-examples/groups/main/CLAUDE.md +8 -39
  5. package/config-examples/runtime.json +4 -4
  6. package/container/agent-runner/package-lock.json +258 -0
  7. package/container/agent-runner/package.json +2 -1
  8. package/container/agent-runner/src/agent-config.ts +57 -8
  9. package/container/agent-runner/src/browser.ts +180 -0
  10. package/container/agent-runner/src/container-protocol.ts +2 -0
  11. package/container/agent-runner/src/id.ts +3 -2
  12. package/container/agent-runner/src/index.ts +167 -436
  13. package/container/agent-runner/src/ipc.ts +33 -1
  14. package/container/agent-runner/src/mcp-client.ts +222 -0
  15. package/container/agent-runner/src/mcp-registry.ts +163 -0
  16. package/container/agent-runner/src/skill-loader.ts +375 -0
  17. package/container/agent-runner/src/tools.ts +249 -21
  18. package/container/agent-runner/src/tts.ts +61 -0
  19. package/dist/admin-commands.d.ts.map +1 -1
  20. package/dist/admin-commands.js +12 -0
  21. package/dist/admin-commands.js.map +1 -1
  22. package/dist/agent-execution.d.ts +3 -1
  23. package/dist/agent-execution.d.ts.map +1 -1
  24. package/dist/agent-execution.js +20 -0
  25. package/dist/agent-execution.js.map +1 -1
  26. package/dist/background-job-classifier.d.ts +1 -1
  27. package/dist/background-job-classifier.d.ts.map +1 -1
  28. package/dist/background-jobs.d.ts.map +1 -1
  29. package/dist/background-jobs.js +9 -0
  30. package/dist/background-jobs.js.map +1 -1
  31. package/dist/cli.js +61 -16
  32. package/dist/cli.js.map +1 -1
  33. package/dist/config.d.ts +0 -2
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/config.js +1 -3
  36. package/dist/config.js.map +1 -1
  37. package/dist/container-protocol.d.ts +2 -0
  38. package/dist/container-protocol.d.ts.map +1 -1
  39. package/dist/container-runner.d.ts.map +1 -1
  40. package/dist/container-runner.js +3 -8
  41. package/dist/container-runner.js.map +1 -1
  42. package/dist/dashboard.d.ts +5 -0
  43. package/dist/dashboard.d.ts.map +1 -1
  44. package/dist/dashboard.js +11 -5
  45. package/dist/dashboard.js.map +1 -1
  46. package/dist/db.d.ts +0 -13
  47. package/dist/db.d.ts.map +1 -1
  48. package/dist/db.js +41 -70
  49. package/dist/db.js.map +1 -1
  50. package/dist/hooks.d.ts +7 -0
  51. package/dist/hooks.d.ts.map +1 -0
  52. package/dist/hooks.js +93 -0
  53. package/dist/hooks.js.map +1 -0
  54. package/dist/id.d.ts.map +1 -1
  55. package/dist/id.js +2 -1
  56. package/dist/id.js.map +1 -1
  57. package/dist/index.js +741 -2812
  58. package/dist/index.js.map +1 -1
  59. package/dist/ipc-dispatcher.d.ts +26 -0
  60. package/dist/ipc-dispatcher.d.ts.map +1 -0
  61. package/dist/ipc-dispatcher.js +1044 -0
  62. package/dist/ipc-dispatcher.js.map +1 -0
  63. package/dist/local-embeddings.d.ts +7 -0
  64. package/dist/local-embeddings.d.ts.map +1 -0
  65. package/dist/local-embeddings.js +60 -0
  66. package/dist/local-embeddings.js.map +1 -0
  67. package/dist/maintenance.d.ts.map +1 -1
  68. package/dist/maintenance.js +7 -1
  69. package/dist/maintenance.js.map +1 -1
  70. package/dist/memory-embeddings.d.ts +1 -1
  71. package/dist/memory-embeddings.d.ts.map +1 -1
  72. package/dist/memory-embeddings.js +59 -31
  73. package/dist/memory-embeddings.js.map +1 -1
  74. package/dist/memory-store.d.ts +0 -10
  75. package/dist/memory-store.d.ts.map +1 -1
  76. package/dist/memory-store.js +11 -27
  77. package/dist/memory-store.js.map +1 -1
  78. package/dist/message-pipeline.d.ts +47 -0
  79. package/dist/message-pipeline.d.ts.map +1 -0
  80. package/dist/message-pipeline.js +876 -0
  81. package/dist/message-pipeline.js.map +1 -0
  82. package/dist/metrics.d.ts +8 -8
  83. package/dist/metrics.d.ts.map +1 -1
  84. package/dist/metrics.js.map +1 -1
  85. package/dist/model-registry.d.ts +0 -14
  86. package/dist/model-registry.d.ts.map +1 -1
  87. package/dist/model-registry.js +0 -36
  88. package/dist/model-registry.js.map +1 -1
  89. package/dist/orchestration.d.ts +39 -0
  90. package/dist/orchestration.d.ts.map +1 -0
  91. package/dist/orchestration.js +136 -0
  92. package/dist/orchestration.js.map +1 -0
  93. package/dist/paths.d.ts.map +1 -1
  94. package/dist/paths.js +2 -0
  95. package/dist/paths.js.map +1 -1
  96. package/dist/providers/discord/discord-format.d.ts +16 -0
  97. package/dist/providers/discord/discord-format.d.ts.map +1 -0
  98. package/dist/providers/discord/discord-format.js +153 -0
  99. package/dist/providers/discord/discord-format.js.map +1 -0
  100. package/dist/providers/discord/discord-provider.d.ts +50 -0
  101. package/dist/providers/discord/discord-provider.d.ts.map +1 -0
  102. package/dist/providers/discord/discord-provider.js +604 -0
  103. package/dist/providers/discord/discord-provider.js.map +1 -0
  104. package/dist/providers/discord/index.d.ts +4 -0
  105. package/dist/providers/discord/index.d.ts.map +1 -0
  106. package/dist/providers/discord/index.js +3 -0
  107. package/dist/providers/discord/index.js.map +1 -0
  108. package/dist/providers/registry.d.ts +14 -0
  109. package/dist/providers/registry.d.ts.map +1 -0
  110. package/dist/providers/registry.js +49 -0
  111. package/dist/providers/registry.js.map +1 -0
  112. package/dist/providers/telegram/index.d.ts +4 -0
  113. package/dist/providers/telegram/index.d.ts.map +1 -0
  114. package/dist/providers/telegram/index.js +3 -0
  115. package/dist/providers/telegram/index.js.map +1 -0
  116. package/dist/providers/telegram/telegram-format.d.ts +3 -0
  117. package/dist/providers/telegram/telegram-format.d.ts.map +1 -0
  118. package/dist/providers/telegram/telegram-format.js +215 -0
  119. package/dist/providers/telegram/telegram-format.js.map +1 -0
  120. package/dist/providers/telegram/telegram-provider.d.ts +51 -0
  121. package/dist/providers/telegram/telegram-provider.d.ts.map +1 -0
  122. package/dist/providers/telegram/telegram-provider.js +824 -0
  123. package/dist/providers/telegram/telegram-provider.js.map +1 -0
  124. package/dist/providers/types.d.ts +107 -0
  125. package/dist/providers/types.d.ts.map +1 -0
  126. package/dist/providers/types.js +2 -0
  127. package/dist/providers/types.js.map +1 -0
  128. package/dist/request-router.d.ts.map +1 -1
  129. package/dist/request-router.js +12 -26
  130. package/dist/request-router.js.map +1 -1
  131. package/dist/runtime-config.d.ts +70 -6
  132. package/dist/runtime-config.d.ts.map +1 -1
  133. package/dist/runtime-config.js +119 -65
  134. package/dist/runtime-config.js.map +1 -1
  135. package/dist/skill-manager.d.ts +39 -0
  136. package/dist/skill-manager.d.ts.map +1 -0
  137. package/dist/skill-manager.js +286 -0
  138. package/dist/skill-manager.js.map +1 -0
  139. package/dist/task-scheduler.d.ts.map +1 -1
  140. package/dist/task-scheduler.js +4 -0
  141. package/dist/task-scheduler.js.map +1 -1
  142. package/dist/tool-intent-probe.d.ts +11 -0
  143. package/dist/tool-intent-probe.d.ts.map +1 -0
  144. package/dist/tool-intent-probe.js +63 -0
  145. package/dist/tool-intent-probe.js.map +1 -0
  146. package/dist/tool-policy.d.ts.map +1 -1
  147. package/dist/tool-policy.js +18 -0
  148. package/dist/tool-policy.js.map +1 -1
  149. package/dist/transcription.d.ts +8 -0
  150. package/dist/transcription.d.ts.map +1 -0
  151. package/dist/transcription.js +174 -0
  152. package/dist/transcription.js.map +1 -0
  153. package/dist/types.d.ts +2 -9
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/workflow-engine.d.ts +51 -0
  156. package/dist/workflow-engine.d.ts.map +1 -0
  157. package/dist/workflow-engine.js +281 -0
  158. package/dist/workflow-engine.js.map +1 -0
  159. package/dist/workflow-store.d.ts +39 -0
  160. package/dist/workflow-store.d.ts.map +1 -0
  161. package/dist/workflow-store.js +173 -0
  162. package/dist/workflow-store.js.map +1 -0
  163. package/package.json +15 -3
  164. package/scripts/bootstrap.js +40 -4
  165. package/scripts/configure.js +48 -7
  166. package/scripts/doctor.js +30 -4
  167. package/scripts/init.js +13 -6
package/dist/index.js CHANGED
@@ -1,39 +1,43 @@
1
1
  import dotenv from 'dotenv';
2
- import { Telegraf } from 'telegraf';
3
2
  import { execSync } from 'child_process';
4
3
  import fs from 'fs';
5
4
  import path from 'path';
6
- import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, IPC_POLL_INTERVAL, TIMEZONE, CONTAINER_MODE, CONTAINER_PRIVILEGED, WARM_START_ENABLED, ENV_PATH, BATCH_WINDOW_MS, MAX_BATCH_SIZE } from './config.js';
5
+ import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, CONTAINER_MODE, CONTAINER_PRIVILEGED, WARM_START_ENABLED, ENV_PATH, } from './config.js';
7
6
  // Load .env from the canonical location (~/.dotclaw/.env)
8
7
  dotenv.config({ path: ENV_PATH });
9
- import { initDatabase, closeDatabase, storeMessage, upsertChat, getMessagesSinceCursor, getChatState, updateChatState, createTask, updateTask, deleteTask, getTaskById, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, getBackgroundJobQueuePosition, getBackgroundJobQueueDepth, linkMessageToTrace, getTraceIdForMessage, recordUserFeedback, enqueueMessageItem, claimBatchForChat, completeQueuedMessages, failQueuedMessages, requeueQueuedMessages, getChatsWithPendingMessages, resetStalledMessages, resetStalledBackgroundJobs, getPendingMessageCount } from './db.js';
10
- import { startSchedulerLoop, stopSchedulerLoop, runTaskNow } from './task-scheduler.js';
11
- import { startBackgroundJobLoop, stopBackgroundJobLoop, spawnBackgroundJob, getBackgroundJobStatus, listBackgroundJobsForGroup, cancelBackgroundJob, recordBackgroundJobUpdate } from './background-jobs.js';
8
+ import { initDatabase, closeDatabase, storeMessage, upsertChat, getChatState, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, getTraceIdForMessage, recordUserFeedback, getChatsWithPendingMessages, resetStalledMessages, resetStalledBackgroundJobs, } from './db.js';
9
+ import { startSchedulerLoop, stopSchedulerLoop } from './task-scheduler.js';
10
+ import { startBackgroundJobLoop, stopBackgroundJobLoop, } from './background-jobs.js';
12
11
  import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
13
- import { hostPathToContainerGroupPath, resolveContainerGroupPathToHost } from './path-mapping.js';
14
12
  import { writeTrace } from './trace-writer.js';
15
- import { formatTelegramMessage, TELEGRAM_PARSE_MODE } from './telegram-format.js';
16
- import { initMemoryStore, closeMemoryStore, getMemoryStats, upsertMemoryItems, searchMemories, listMemories, forgetMemories, cleanupExpiredMemories } from './memory-store.js';
13
+ import { initMemoryStore, closeMemoryStore, cleanupExpiredMemories, upsertMemoryItems } from './memory-store.js';
17
14
  import { startEmbeddingWorker, stopEmbeddingWorker } from './memory-embeddings.js';
18
- import { createProgressManager, DEFAULT_PROGRESS_MESSAGES, DEFAULT_PROGRESS_STAGES, formatProgressWithPlan, formatPlanStepList } from './progress.js';
19
15
  import { parseAdminCommand } from './admin-commands.js';
20
16
  import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
21
- import { startMetricsServer, stopMetricsServer, recordMessage, recordError, recordRoutingDecision, recordStageLatency } from './metrics.js';
17
+ import { startMetricsServer, stopMetricsServer, recordMessage, recordRoutingDecision, recordStageLatency } from './metrics.js';
22
18
  import { startMaintenanceLoop, stopMaintenanceLoop } from './maintenance.js';
23
19
  import { warmGroupContainer, startDaemonHealthCheckLoop, stopDaemonHealthCheckLoop, cleanupInstanceContainers, suppressHealthChecks, resetUnhealthyDaemons } from './container-runner.js';
24
20
  import { startWakeDetector, stopWakeDetector } from './wake-detector.js';
25
21
  import { loadRuntimeConfig } from './runtime-config.js';
22
+ import { transcribeVoice } from './transcription.js';
23
+ import { emitHook } from './hooks.js';
24
+ import { closeWorkflowStore } from './workflow-store.js';
26
25
  import { invalidatePersonalizationCache } from './personalization.js';
26
+ import { installSkill, removeSkill, listSkills, updateSkill } from './skill-manager.js';
27
27
  import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionError } from './agent-execution.js';
28
28
  import { logger } from './logger.js';
29
- import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime, setMessageQueueDepth } from './dashboard.js';
30
- import { humanizeError } from './error-messages.js';
31
- import { classifyBackgroundJob } from './background-job-classifier.js';
32
- import { routeRequest, routePrompt } from './request-router.js';
33
- import { probePlanner } from './planner-probe.js';
34
- import { generateId } from './id.js';
35
- import { isValidTimezone, normalizeTaskTimezone, parseScheduledTimestamp } from './timezone.js';
29
+ import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime } from './dashboard.js';
30
+ import { routePrompt } from './request-router.js';
31
+ // Provider system
32
+ import { ProviderRegistry } from './providers/registry.js';
33
+ import { createTelegramProvider } from './providers/telegram/index.js';
34
+ import { createMessagePipeline, getActiveDrains, getActiveRuns, providerAttachmentToMessageAttachment } from './message-pipeline.js';
35
+ import { startIpcWatcher, stopIpcWatcher } from './ipc-dispatcher.js';
36
36
  const runtime = loadRuntimeConfig();
37
+ // ───────────────────────── State ─────────────────────────
38
+ let sessions = {};
39
+ let registeredGroups = {};
40
+ // ───────────────────────── Helpers ─────────────────────────
37
41
  function buildTriggerRegex(pattern) {
38
42
  if (!pattern)
39
43
  return null;
@@ -52,87 +56,23 @@ function buildAvailableGroupsSnapshot() {
52
56
  isRegistered: true
53
57
  }));
54
58
  }
55
- function isRecord(value) {
56
- return typeof value === 'object' && value !== null;
57
- }
58
- function isMemoryScope(value) {
59
- return value === 'user' || value === 'group' || value === 'global';
60
- }
61
- function isMemoryType(value) {
62
- return value === 'identity'
63
- || value === 'preference'
64
- || value === 'fact'
65
- || value === 'relationship'
66
- || value === 'project'
67
- || value === 'task'
68
- || value === 'note'
69
- || value === 'archive';
70
- }
71
- function isMemoryKind(value) {
72
- return value === 'semantic'
73
- || value === 'episodic'
74
- || value === 'procedural'
75
- || value === 'preference';
76
- }
77
- function clampInputMessage(content, maxChars) {
78
- if (!content)
79
- return '';
80
- if (!Number.isFinite(maxChars) || maxChars <= 0)
81
- return content;
82
- if (content.length <= maxChars)
83
- return content;
84
- return `${content.slice(0, maxChars)}\n\n[Message truncated for length]`;
85
- }
86
- function coerceMemoryItems(input) {
87
- if (!Array.isArray(input))
88
- return [];
89
- const items = [];
90
- for (const raw of input) {
91
- if (!isRecord(raw))
92
- continue;
93
- const scope = raw.scope;
94
- const type = raw.type;
95
- const kind = raw.kind;
96
- const content = raw.content;
97
- if (!isMemoryScope(scope) || !isMemoryType(type) || typeof content !== 'string' || !content.trim()) {
98
- continue;
99
- }
100
- items.push({
101
- scope,
102
- type,
103
- kind: isMemoryKind(kind) ? kind : undefined,
104
- conflict_key: typeof raw.conflict_key === 'string' ? raw.conflict_key : undefined,
105
- content: content.trim(),
106
- subject_id: typeof raw.subject_id === 'string' ? raw.subject_id : null,
107
- importance: typeof raw.importance === 'number' ? raw.importance : undefined,
108
- confidence: typeof raw.confidence === 'number' ? raw.confidence : undefined,
109
- tags: Array.isArray(raw.tags) ? raw.tags.filter((tag) => typeof tag === 'string') : undefined,
110
- ttl_days: typeof raw.ttl_days === 'number' ? raw.ttl_days : undefined,
111
- source: typeof raw.source === 'string' ? raw.source : undefined,
112
- metadata: isRecord(raw.metadata) ? raw.metadata : undefined
113
- });
114
- }
115
- return items;
59
+ function sleep(ms) {
60
+ return new Promise(resolve => setTimeout(resolve, ms));
116
61
  }
117
- // Rate limiting configuration
118
- const RATE_LIMIT_MAX_MESSAGES = 20; // Max messages per user per window
119
- const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window
62
+ // ───────────────────────── Rate Limiter ─────────────────────────
63
+ const RATE_LIMIT_MAX_MESSAGES = 20;
64
+ const RATE_LIMIT_WINDOW_MS = 60_000;
120
65
  const rateLimiter = new Map();
121
- const JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS = 120_000;
122
- const lastJobUpdateNotifications = new Map();
123
66
  function checkRateLimit(userId) {
124
67
  const now = Date.now();
125
68
  const entry = rateLimiter.get(userId);
126
69
  if (!entry || now > entry.resetAt) {
127
- // New window
128
70
  rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
129
71
  return { allowed: true };
130
72
  }
131
73
  if (entry.count >= RATE_LIMIT_MAX_MESSAGES) {
132
- // Rate limited
133
74
  return { allowed: false, retryAfterMs: entry.resetAt - now };
134
75
  }
135
- // Increment counter
136
76
  entry.count += 1;
137
77
  return { allowed: true };
138
78
  }
@@ -144,171 +84,34 @@ function cleanupRateLimiter() {
144
84
  }
145
85
  }
146
86
  }
147
- // Clean up expired rate limit entries periodically
148
87
  const rateLimiterInterval = setInterval(cleanupRateLimiter, 60_000);
149
- const TELEGRAM_HANDLER_TIMEOUT_MS = runtime.host.telegram.handlerTimeoutMs;
150
- const TELEGRAM_SEND_RETRIES = runtime.host.telegram.sendRetries;
151
- const TELEGRAM_SEND_RETRY_DELAY_MS = runtime.host.telegram.sendRetryDelayMs;
88
+ // ───────────────────────── Config Constants ─────────────────────────
152
89
  const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
153
90
  const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
154
- const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
155
91
  const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
156
92
  const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
157
93
  const HEARTBEAT_GROUP_FOLDER = (runtime.host.heartbeat.groupFolder || MAIN_GROUP_FOLDER).trim() || MAIN_GROUP_FOLDER;
158
- const BACKGROUND_JOBS_ENABLED = runtime.host.backgroundJobs.enabled;
159
- const AUTO_SPAWN_CONFIG = runtime.host.backgroundJobs.autoSpawn;
160
- const AUTO_SPAWN_ENABLED = BACKGROUND_JOBS_ENABLED && AUTO_SPAWN_CONFIG.enabled;
161
- const AUTO_SPAWN_FOREGROUND_TIMEOUT_MS = AUTO_SPAWN_CONFIG.foregroundTimeoutMs;
162
- const AUTO_SPAWN_ON_TIMEOUT = AUTO_SPAWN_CONFIG.onTimeout;
163
- const AUTO_SPAWN_ON_TOOL_LIMIT = AUTO_SPAWN_CONFIG.onToolLimit;
164
- const AUTO_SPAWN_CLASSIFIER_ENABLED = AUTO_SPAWN_CONFIG.classifier.enabled;
165
- const TOOL_CALL_FALLBACK_PATTERN = /tool calls? but did not get a final response/i;
166
- // Initialize Telegram bot with extended timeout for long-running agent tasks
167
- const telegrafBot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN, {
168
- handlerTimeout: TELEGRAM_HANDLER_TIMEOUT_MS
169
- });
170
- telegrafBot.catch((err, ctx) => {
171
- logger.error({ err, chatId: ctx?.chat?.id }, 'Unhandled Telegraf error');
172
- });
173
- let sessions = {};
174
- let registeredGroups = {};
175
- const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
176
- const TELEGRAM_SEND_DELAY_MS = 250;
177
- const TELEGRAM_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
178
- const TELEGRAM_FILE_DOWNLOAD_TIMEOUT_MS = 45_000;
179
- const MESSAGE_QUEUE_MAX_RETRIES = Math.max(1, runtime.host.messageQueue.maxRetries ?? 4);
180
- const MESSAGE_QUEUE_RETRY_BASE_MS = Math.max(250, runtime.host.messageQueue.retryBaseMs ?? 3_000);
181
- const MESSAGE_QUEUE_RETRY_MAX_MS = Math.max(MESSAGE_QUEUE_RETRY_BASE_MS, runtime.host.messageQueue.retryMaxMs ?? 60_000);
182
- const activeDrains = new Set();
183
- const activeRuns = new Map();
184
- const MAX_DRAIN_ITERATIONS = 50;
185
- const CANCEL_PHRASES = new Set([
186
- 'cancel', 'stop', 'abort', 'cancel request', 'stop request'
187
- ]);
188
- function isCancelMessage(content) {
189
- if (!content)
190
- return false;
191
- const trimmed = content.trim();
192
- if (trimmed.length > 20)
193
- return false;
194
- return CANCEL_PHRASES.has(trimmed.toLowerCase());
195
- }
196
- function inferProgressStage(params) {
197
- const content = params.content.toLowerCase();
198
- const tools = params.plannerTools.map(tool => tool.toLowerCase());
199
- const hasWebTool = tools.some(tool => tool.includes('web') || tool.includes('search') || tool.includes('fetch'));
200
- const hasCodeTool = tools.some(tool => tool.includes('bash') || tool.includes('edit') || tool.includes('write') || tool.includes('git'));
201
- if (params.enablePlanner)
202
- return 'planning';
203
- if (hasWebTool || /research|search|browse|web|site|docs/.test(content))
204
- return 'searching';
205
- if (hasCodeTool || /build|code|implement|refactor|fix|debug/.test(content))
206
- return 'coding';
207
- return 'drafting';
208
- }
209
- function estimateForegroundMs(params) {
210
- if (typeof params.routing.estimatedMinutes === 'number' && Number.isFinite(params.routing.estimatedMinutes)) {
211
- return Math.max(1000, params.routing.estimatedMinutes * 60_000);
212
- }
213
- const baseChars = params.content.length;
214
- if (baseChars === 0)
215
- return null;
216
- const stepFactor = params.plannerSteps.length > 0 ? params.plannerSteps.length * 6000 : 0;
217
- const toolFactor = params.plannerTools.length > 0 ? params.plannerTools.length * 8000 : 0;
218
- const lengthFactor = Math.min(60_000, Math.max(3000, Math.round(baseChars / 3)));
219
- const profileFactor = params.routing.profile === 'deep' ? 1.4 : 1;
220
- return Math.round((lengthFactor + stepFactor + toolFactor) * profileFactor);
221
- }
222
- function inferPlanStepIndex(stage, totalSteps) {
223
- if (!Number.isFinite(totalSteps) || totalSteps <= 0)
224
- return null;
225
- const normalized = stage.trim().toLowerCase();
226
- if (!normalized)
227
- return 1;
228
- switch (normalized) {
229
- case 'planning':
230
- return 1;
231
- case 'searching':
232
- return Math.min(2, totalSteps);
233
- case 'coding':
234
- return Math.min(Math.max(2, Math.ceil(totalSteps * 0.6)), totalSteps);
235
- case 'drafting':
236
- return Math.min(Math.max(2, Math.ceil(totalSteps * 0.8)), totalSteps);
237
- case 'finalizing':
238
- return totalSteps;
239
- default:
240
- return 1;
241
- }
242
- }
243
- async function setTyping(chatId) {
244
- try {
245
- await telegrafBot.telegram.sendChatAction(chatId, 'typing');
246
- }
247
- catch (err) {
248
- logger.debug({ chatId, err }, 'Failed to set typing indicator');
249
- }
250
- }
251
- function sleep(ms) {
252
- return new Promise(resolve => setTimeout(resolve, ms));
253
- }
254
- function isBotMentioned(text, entities, botUsername, botId) {
255
- if (!entities || entities.length === 0)
256
- return false;
257
- const normalized = botUsername ? botUsername.toLowerCase() : '';
258
- for (const entity of entities) {
259
- const segment = text.slice(entity.offset, entity.offset + entity.length);
260
- if (entity.type === 'mention') {
261
- if (segment.toLowerCase() === `@${normalized}`)
262
- return true;
94
+ // ───────────────────────── State Management ─────────────────────────
95
+ function loadState() {
96
+ sessions = {};
97
+ const rawGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
98
+ // Migrate: prefix unprefixed chat IDs with 'telegram:'
99
+ let migrated = false;
100
+ const loadedGroups = {};
101
+ for (const [chatId, group] of Object.entries(rawGroups)) {
102
+ if (!chatId.includes(':')) {
103
+ // Unprefixed add telegram: prefix
104
+ loadedGroups[ProviderRegistry.addPrefix('telegram', chatId)] = group;
105
+ migrated = true;
263
106
  }
264
- if (entity.type === 'text_mention' && botId && entity.user?.id === botId)
265
- return true;
266
- if (entity.type === 'bot_command') {
267
- if (segment.toLowerCase().includes(`@${normalized}`))
268
- return true;
107
+ else {
108
+ loadedGroups[chatId] = group;
269
109
  }
270
110
  }
271
- return false;
272
- }
273
- function isBotReplied(message, botId) {
274
- if (!message?.reply_to_message?.from?.id || !botId)
275
- return false;
276
- return message.reply_to_message.from.id === botId;
277
- }
278
- function getTelegramErrorCode(err) {
279
- const anyErr = err;
280
- if (typeof anyErr?.code === 'number')
281
- return anyErr.code;
282
- if (typeof anyErr?.response?.error_code === 'number')
283
- return anyErr.response.error_code;
284
- return null;
285
- }
286
- function getTelegramRetryAfterMs(err) {
287
- const anyErr = err;
288
- const retryAfter = anyErr?.parameters?.retry_after ?? anyErr?.response?.parameters?.retry_after;
289
- if (typeof retryAfter === 'number' && Number.isFinite(retryAfter))
290
- return retryAfter * 1000;
291
- if (typeof retryAfter === 'string') {
292
- const parsed = Number.parseInt(retryAfter, 10);
293
- if (Number.isFinite(parsed))
294
- return parsed * 1000;
111
+ if (migrated) {
112
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), loadedGroups);
113
+ logger.info('Migrated registered_groups.json chat IDs with telegram: prefix');
295
114
  }
296
- return null;
297
- }
298
- function isRetryableTelegramError(err) {
299
- const code = getTelegramErrorCode(err);
300
- if (code === 429)
301
- return true;
302
- if (code && code >= 500 && code < 600)
303
- return true;
304
- const anyErr = err;
305
- if (!anyErr?.code)
306
- return false;
307
- return ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND'].includes(anyErr.code);
308
- }
309
- function loadState() {
310
- sessions = {};
311
- const loadedGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
312
115
  const sanitizedGroups = {};
313
116
  const usedFolders = new Set();
314
117
  let invalidCount = 0;
@@ -360,7 +163,6 @@ function registerGroup(chatId, group) {
360
163
  }
361
164
  registeredGroups[chatId] = group;
362
165
  saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
363
- // Create group folder
364
166
  const groupDir = path.join(GROUPS_DIR, group.folder);
365
167
  fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
366
168
  logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
@@ -418,2405 +220,636 @@ function unregisterGroup(identifier) {
418
220
  logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
419
221
  return { ok: true, group: { ...group, chat_id: chatId } };
420
222
  }
421
- function splitPlainText(text, maxLength) {
422
- if (text.length <= maxLength)
423
- return [text];
424
- const chunks = [];
425
- for (let i = 0; i < text.length; i += maxLength) {
426
- chunks.push(text.slice(i, i + maxLength));
427
- }
428
- return chunks;
223
+ // ───────────────────────── Admin Commands ─────────────────────────
224
+ function formatGroups(groups) {
225
+ if (groups.length === 0)
226
+ return 'No registered groups.';
227
+ const lines = groups.map(group => {
228
+ const trigger = group.trigger ? ` (trigger: ${group.trigger})` : '';
229
+ return `- ${group.name} [${group.folder}] chat=${group.chat_id}${trigger}`;
230
+ });
231
+ return ['Registered groups:', ...lines].join('\n');
429
232
  }
430
- async function sendMessage(chatId, text, options) {
431
- const parseMode = options?.parseMode === undefined ? TELEGRAM_PARSE_MODE : options.parseMode;
432
- const chunks = parseMode
433
- ? formatTelegramMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH)
434
- : splitPlainText(text, TELEGRAM_MAX_MESSAGE_LENGTH);
435
- let firstMessageId;
436
- const sendChunk = async (chunk, isFirst) => {
437
- for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
438
- try {
439
- const payload = {};
440
- if (parseMode)
441
- payload.parse_mode = parseMode;
442
- if (options?.messageThreadId)
443
- payload.message_thread_id = options.messageThreadId;
444
- if (isFirst && options?.replyToMessageId) {
445
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
446
- }
447
- const sent = await telegrafBot.telegram.sendMessage(chatId, chunk, payload);
448
- if (!firstMessageId) {
449
- firstMessageId = String(sent.message_id);
450
- }
451
- return true;
452
- }
453
- catch (err) {
454
- const retryAfterMs = getTelegramRetryAfterMs(err);
455
- const retryable = isRetryableTelegramError(err);
456
- if (!retryable || attempt === TELEGRAM_SEND_RETRIES) {
457
- logger.error({ chatId, attempt, err }, 'Failed to send Telegram message chunk');
458
- return false;
459
- }
460
- const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
461
- logger.warn({ chatId, attempt, delayMs }, 'Telegram send failed; retrying');
462
- await sleep(delayMs);
463
- }
464
- }
465
- return false;
466
- };
467
- try {
468
- // Telegram bots send messages as themselves, no prefix needed
469
- for (let i = 0; i < chunks.length; i += 1) {
470
- const ok = await sendChunk(chunks[i], i === 0);
471
- if (!ok)
472
- return { success: false };
473
- if (i < chunks.length - 1) {
474
- await sleep(TELEGRAM_SEND_DELAY_MS);
475
- }
476
- }
477
- logger.info({ chatId, length: text.length }, 'Message sent');
478
- return { success: true, messageId: firstMessageId };
479
- }
480
- catch (err) {
481
- logger.error({ chatId, err }, 'Failed to send message');
482
- return { success: false };
233
+ function applyModelOverride(params) {
234
+ const defaultModel = runtime.host.defaultModel;
235
+ const config = loadModelRegistry(defaultModel);
236
+ const nextModel = params.model.trim();
237
+ if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
238
+ return { ok: false, error: 'Model not in allowlist' };
483
239
  }
484
- }
485
- function hostPathToContainerPath(hostPath, groupFolder) {
486
- return hostPathToContainerGroupPath(hostPath, groupFolder, GROUPS_DIR);
487
- }
488
- function resolveContainerPathToHost(containerPath, groupFolder) {
489
- return resolveContainerGroupPathToHost(containerPath, groupFolder, GROUPS_DIR);
490
- }
491
- async function sendDocument(chatId, filePath, options) {
492
- for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
493
- try {
494
- const payload = {};
495
- if (options?.caption)
496
- payload.caption = options.caption;
497
- if (options?.replyToMessageId) {
498
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
499
- }
500
- await telegrafBot.telegram.sendDocument(chatId, { source: filePath }, payload);
501
- logger.info({ chatId, filePath }, 'Document sent');
502
- return { success: true };
503
- }
504
- catch (err) {
505
- if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
506
- logger.error({ chatId, filePath, attempt, err }, 'Failed to send document');
507
- return { success: false };
508
- }
509
- const retryAfterMs = getTelegramRetryAfterMs(err);
510
- const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
511
- logger.warn({ chatId, attempt, delayMs }, 'Document send failed; retrying');
512
- await sleep(delayMs);
513
- }
240
+ const scope = params.scope || 'global';
241
+ const targetId = params.targetId;
242
+ if (scope === 'user' && !targetId) {
243
+ return { ok: false, error: 'Missing target_id for user scope' };
514
244
  }
515
- return { success: false };
516
- }
517
- async function sendPhoto(chatId, filePath, options) {
518
- for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
519
- try {
520
- const payload = {};
521
- if (options?.caption)
522
- payload.caption = options.caption;
523
- if (options?.replyToMessageId) {
524
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
525
- }
526
- await telegrafBot.telegram.sendPhoto(chatId, { source: filePath }, payload);
527
- logger.info({ chatId, filePath }, 'Photo sent');
528
- return { success: true };
529
- }
530
- catch (err) {
531
- if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
532
- logger.error({ chatId, filePath, attempt, err }, 'Failed to send photo');
533
- return { success: false };
534
- }
535
- const retryAfterMs = getTelegramRetryAfterMs(err);
536
- const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
537
- logger.warn({ chatId, attempt, delayMs }, 'Photo send failed; retrying');
538
- await sleep(delayMs);
539
- }
245
+ if (scope === 'group' && !targetId) {
246
+ return { ok: false, error: 'Missing target_id for group scope' };
540
247
  }
541
- return { success: false };
542
- }
543
- async function downloadTelegramFile(fileId, groupFolder, filename) {
544
- let localPath = null;
545
- let tmpPath = null;
546
- try {
547
- const fileLink = await telegrafBot.telegram.getFileLink(fileId);
548
- const url = fileLink.href || String(fileLink);
549
- const abortController = new AbortController();
550
- const timeout = setTimeout(() => abortController.abort(), TELEGRAM_FILE_DOWNLOAD_TIMEOUT_MS);
551
- let response;
552
- try {
553
- response = await fetch(url, { signal: abortController.signal });
554
- }
555
- finally {
556
- clearTimeout(timeout);
557
- }
558
- if (!response.ok) {
559
- logger.warn({ fileId, status: response.status }, 'Failed to download Telegram file');
560
- return { path: null, error: 'download_failed' };
561
- }
562
- const contentLength = response.headers.get('content-length');
563
- const declaredSize = contentLength ? parseInt(contentLength, 10) : NaN;
564
- if (Number.isFinite(declaredSize) && declaredSize > TELEGRAM_MAX_ATTACHMENT_BYTES) {
565
- logger.warn({ fileId, size: contentLength }, 'Telegram file too large (>20MB)');
566
- return { path: null, error: 'too_large' };
567
- }
568
- const inboxDir = path.join(GROUPS_DIR, groupFolder, 'inbox');
569
- fs.mkdirSync(inboxDir, { recursive: true });
570
- const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
571
- const localName = `${Date.now()}_${safeName}`;
572
- localPath = path.join(inboxDir, localName);
573
- tmpPath = `${localPath}.tmp`;
574
- const fileStream = fs.createWriteStream(tmpPath, { flags: 'wx' });
575
- let bytesWritten = 0;
576
- const body = response.body;
577
- if (!body) {
578
- throw new Error('Telegram response had no body');
579
- }
580
- const reader = body.getReader();
581
- try {
582
- while (true) {
583
- const { done, value } = await reader.read();
584
- if (done)
585
- break;
586
- if (!value || value.byteLength === 0)
587
- continue;
588
- bytesWritten += value.byteLength;
589
- if (bytesWritten > TELEGRAM_MAX_ATTACHMENT_BYTES) {
590
- await reader.cancel();
591
- throw new Error('STREAMING_TOO_LARGE');
592
- }
593
- if (!fileStream.write(Buffer.from(value))) {
594
- await new Promise(resolve => fileStream.once('drain', resolve));
595
- }
596
- }
597
- await new Promise((resolve, reject) => {
598
- fileStream.end((err) => {
599
- if (err)
600
- reject(err);
601
- else
602
- resolve();
603
- });
604
- });
605
- }
606
- catch (streamErr) {
607
- fileStream.destroy();
608
- throw streamErr;
609
- }
610
- fs.renameSync(tmpPath, localPath);
611
- tmpPath = null;
612
- logger.info({ fileId, localPath, size: bytesWritten }, 'Downloaded Telegram file');
613
- return { path: localPath };
248
+ const nextConfig = { ...config };
249
+ if (scope === 'global') {
250
+ nextConfig.model = nextModel;
614
251
  }
615
- catch (err) {
616
- const isTooLarge = err instanceof Error && err.message === 'STREAMING_TOO_LARGE';
617
- if (isTooLarge) {
618
- logger.warn({ fileId }, 'Telegram file too large (>20MB) during streaming');
619
- }
620
- else {
621
- logger.error({ fileId, err }, 'Error downloading Telegram file');
622
- }
623
- return { path: null, error: isTooLarge ? 'too_large' : 'download_failed' };
252
+ else if (scope === 'group') {
253
+ nextConfig.per_group = nextConfig.per_group || {};
254
+ nextConfig.per_group[targetId] = { model: nextModel };
624
255
  }
625
- finally {
626
- if (tmpPath && fs.existsSync(tmpPath)) {
627
- try {
628
- fs.unlinkSync(tmpPath);
629
- }
630
- catch {
631
- // ignore cleanup failure
632
- }
633
- }
634
- if (localPath && fs.existsSync(localPath) && fs.statSync(localPath).size === 0) {
635
- try {
636
- fs.unlinkSync(localPath);
637
- }
638
- catch {
639
- // ignore cleanup failure
640
- }
641
- }
256
+ else if (scope === 'user') {
257
+ nextConfig.per_user = nextConfig.per_user || {};
258
+ nextConfig.per_user[targetId] = { model: nextModel };
642
259
  }
260
+ nextConfig.updated_at = new Date().toISOString();
261
+ saveModelRegistry(nextConfig);
262
+ return { ok: true };
643
263
  }
644
- function buildAttachmentsXml(attachments, groupFolder) {
645
- if (!attachments || attachments.length === 0)
646
- return '';
647
- const escapeXml = (s) => s
648
- .replace(/&/g, '&amp;')
649
- .replace(/</g, '&lt;')
650
- .replace(/>/g, '&gt;')
651
- .replace(/"/g, '&quot;');
652
- return attachments.map(a => {
653
- const attrs = [`type="${escapeXml(a.type)}"`];
654
- const containerPath = a.local_path ? hostPathToContainerPath(a.local_path, groupFolder) : null;
655
- if (containerPath)
656
- attrs.push(`path="${escapeXml(containerPath)}"`);
657
- if (a.file_name)
658
- attrs.push(`filename="${escapeXml(a.file_name)}"`);
659
- if (a.mime_type)
660
- attrs.push(`mime="${escapeXml(a.mime_type)}"`);
661
- if (a.file_size)
662
- attrs.push(`size="${a.file_size}"`);
663
- if (a.duration)
664
- attrs.push(`duration="${a.duration}"`);
665
- if (a.width)
666
- attrs.push(`width="${a.width}"`);
667
- if (a.height)
668
- attrs.push(`height="${a.height}"`);
669
- return `<attachment ${attrs.join(' ')} />`;
670
- }).join('\n');
671
- }
672
- async function sendVoice(chatId, filePath, options) {
673
- for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
674
- try {
675
- const payload = {};
676
- if (options?.caption)
677
- payload.caption = options.caption;
678
- if (options?.duration)
679
- payload.duration = options.duration;
680
- if (options?.replyToMessageId) {
681
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
682
- }
683
- await telegrafBot.telegram.sendVoice(chatId, { source: filePath }, payload);
684
- logger.info({ chatId, filePath }, 'Voice sent');
685
- return { success: true };
686
- }
687
- catch (err) {
688
- if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
689
- logger.error({ chatId, filePath, attempt, err }, 'Failed to send voice');
690
- return { success: false };
691
- }
692
- const retryAfterMs = getTelegramRetryAfterMs(err);
693
- const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
694
- await sleep(delayMs);
695
- }
264
+ async function handleAdminCommand(params, sendReply) {
265
+ const parsed = parseAdminCommand(params.content, params.botUsername);
266
+ if (!parsed)
267
+ return false;
268
+ const reply = (text) => sendReply(params.chatId, text, { threadId: params.threadId });
269
+ const group = registeredGroups[params.chatId];
270
+ if (!group) {
271
+ await reply('This chat is not registered with DotClaw.');
272
+ return true;
696
273
  }
697
- return { success: false };
698
- }
699
- async function sendAudio(chatId, filePath, options) {
700
- for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
701
- try {
702
- const payload = {};
703
- if (options?.caption)
704
- payload.caption = options.caption;
705
- if (options?.duration)
706
- payload.duration = options.duration;
707
- if (options?.performer)
708
- payload.performer = options.performer;
709
- if (options?.title)
710
- payload.title = options.title;
711
- if (options?.replyToMessageId) {
712
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
713
- }
714
- await telegrafBot.telegram.sendAudio(chatId, { source: filePath }, payload);
715
- logger.info({ chatId, filePath }, 'Audio sent');
716
- return { success: true };
274
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
275
+ const command = parsed.command;
276
+ const args = parsed.args;
277
+ const requireMain = (name) => {
278
+ if (isMain)
279
+ return false;
280
+ reply(`${name} is only available in the main group.`).catch(() => undefined);
281
+ return true;
282
+ };
283
+ if (command === 'help') {
284
+ await reply([
285
+ 'DotClaw admin commands:',
286
+ '- `/dotclaw help`',
287
+ '- `/dotclaw groups` (main only)',
288
+ '- `/dotclaw add-group <chat_id> <name> [folder]` (main only)',
289
+ '- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
290
+ '- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
291
+ '- `/dotclaw remember <fact>` (main only)',
292
+ '- `/dotclaw skill install <url> [--global]` (main only)',
293
+ '- `/dotclaw skill remove <name> [--global]` (main only)',
294
+ '- `/dotclaw skill list [--global]` (main only)',
295
+ '- `/dotclaw skill update <name> [--global]` (main only)',
296
+ '- `/dotclaw style <concise|balanced|detailed>`',
297
+ '- `/dotclaw tools <conservative|balanced|proactive>`',
298
+ '- `/dotclaw caution <low|balanced|high>`',
299
+ '- `/dotclaw memory <strict|balanced|loose>`'
300
+ ].join('\n'));
301
+ return true;
302
+ }
303
+ if (command === 'groups') {
304
+ if (requireMain('Listing groups'))
305
+ return true;
306
+ await reply(formatGroups(listRegisteredGroups()));
307
+ return true;
308
+ }
309
+ if (command === 'add-group') {
310
+ if (requireMain('Adding groups'))
311
+ return true;
312
+ if (args.length < 2) {
313
+ await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
314
+ return true;
717
315
  }
718
- catch (err) {
719
- if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
720
- logger.error({ chatId, filePath, attempt, err }, 'Failed to send audio');
721
- return { success: false };
722
- }
723
- const retryAfterMs = getTelegramRetryAfterMs(err);
724
- const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
725
- await sleep(delayMs);
316
+ const newChatId = args[0];
317
+ const name = args[1];
318
+ const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 50);
319
+ if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
320
+ await reply(`Invalid folder name: "${folder}"`);
321
+ return true;
726
322
  }
727
- }
728
- return { success: false };
729
- }
730
- async function sendLocation(chatId, latitude, longitude, options) {
731
- try {
732
- const payload = {};
733
- if (options?.replyToMessageId) {
734
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
323
+ if (registeredGroups[newChatId]) {
324
+ await reply(`Chat ${newChatId} is already registered.`);
325
+ return true;
735
326
  }
736
- await telegrafBot.telegram.sendLocation(chatId, latitude, longitude, payload);
737
- logger.info({ chatId, latitude, longitude }, 'Location sent');
738
- return { success: true };
739
- }
740
- catch (err) {
741
- logger.error({ chatId, err }, 'Failed to send location');
742
- return { success: false };
327
+ const newGroup = {
328
+ name,
329
+ folder,
330
+ added_at: new Date().toISOString()
331
+ };
332
+ registerGroup(newChatId, newGroup);
333
+ await reply(`Group "${name}" registered (folder: ${folder}).`);
334
+ return true;
743
335
  }
744
- }
745
- async function sendContact(chatId, phoneNumber, firstName, options) {
746
- try {
747
- const payload = {};
748
- if (options?.lastName)
749
- payload.last_name = options.lastName;
750
- if (options?.replyToMessageId) {
751
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
336
+ if (command === 'remove-group') {
337
+ if (requireMain('Removing groups'))
338
+ return true;
339
+ if (args.length < 1) {
340
+ await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
341
+ return true;
752
342
  }
753
- await telegrafBot.telegram.sendContact(chatId, phoneNumber, firstName, payload);
754
- logger.info({ chatId, phoneNumber }, 'Contact sent');
755
- return { success: true };
756
- }
757
- catch (err) {
758
- logger.error({ chatId, err }, 'Failed to send contact');
759
- return { success: false };
760
- }
761
- }
762
- async function sendPoll(chatId, question, pollOptions, pollConfig) {
763
- try {
764
- const payload = {};
765
- if (pollConfig?.is_anonymous !== undefined)
766
- payload.is_anonymous = pollConfig.is_anonymous;
767
- if (pollConfig?.type)
768
- payload.type = pollConfig.type;
769
- if (pollConfig?.allows_multiple_answers !== undefined)
770
- payload.allows_multiple_answers = pollConfig.allows_multiple_answers;
771
- if (pollConfig?.correct_option_id !== undefined)
772
- payload.correct_option_id = pollConfig.correct_option_id;
773
- if (pollConfig?.replyToMessageId) {
774
- payload.reply_parameters = { message_id: pollConfig.replyToMessageId, allow_sending_without_reply: true };
343
+ const result = unregisterGroup(args[0]);
344
+ if (!result.ok) {
345
+ await reply(`Failed to remove group: ${result.error}`);
346
+ return true;
775
347
  }
776
- const sent = await telegrafBot.telegram.sendPoll(chatId, question, pollOptions, payload);
777
- logger.info({ chatId, question }, 'Poll sent');
778
- return { success: true, messageId: sent.message_id };
779
- }
780
- catch (err) {
781
- logger.error({ chatId, err }, 'Failed to send poll');
782
- return { success: false };
348
+ await reply(`Group "${result.group.name}" removed.`);
349
+ return true;
783
350
  }
784
- }
785
- async function sendInlineKeyboard(chatId, text, buttons, options) {
786
- try {
787
- const payload = {
788
- reply_markup: { inline_keyboard: buttons }
789
- };
790
- if (options?.replyToMessageId) {
791
- payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
351
+ if (command === 'set-model') {
352
+ if (requireMain('Setting models'))
353
+ return true;
354
+ if (args.length < 1) {
355
+ await reply('Usage: /dotclaw set-model <model> [global|group|user] [target_id]');
356
+ return true;
792
357
  }
793
- const sent = await telegrafBot.telegram.sendMessage(chatId, text, payload);
794
- logger.info({ chatId }, 'Inline keyboard sent');
795
- return { success: true, messageId: sent.message_id };
796
- }
797
- catch (err) {
798
- logger.error({ chatId, err }, 'Failed to send inline keyboard');
799
- return { success: false };
800
- }
801
- }
802
- // ── Callback query store (5-minute TTL for inline button callbacks) ───
803
- const callbackDataStore = new Map();
804
- function registerCallbackData(chatJid, data, label) {
805
- const id = generateId('cb');
806
- callbackDataStore.set(id, { chatJid, data, label, createdAt: Date.now() });
807
- return id;
808
- }
809
- // Clean up expired callback data every 60s
810
- setInterval(() => {
811
- const cutoff = Date.now() - 5 * 60 * 1000;
812
- for (const [id, entry] of callbackDataStore) {
813
- if (entry.createdAt < cutoff) {
814
- callbackDataStore.delete(id);
358
+ const model = args[0];
359
+ const scopeCandidate = (args[1] || '').toLowerCase();
360
+ const scope = (scopeCandidate === 'global' || scopeCandidate === 'group' || scopeCandidate === 'user')
361
+ ? scopeCandidate
362
+ : 'global';
363
+ const targetId = args[2] || (scope === 'group' ? group.folder : scope === 'user' ? params.senderId : undefined);
364
+ const result = applyModelOverride({ model, scope, targetId });
365
+ if (!result.ok) {
366
+ await reply(`Failed to set model: ${result.error || 'unknown error'}`);
367
+ return true;
815
368
  }
369
+ await reply(`Model set to ${model} (${scope}${targetId ? `:${targetId}` : ''}).`);
370
+ return true;
816
371
  }
817
- }, 60_000);
818
- function normalizePollOptions(rawOptions) {
819
- if (!Array.isArray(rawOptions))
820
- return null;
821
- const options = rawOptions
822
- .filter((value) => typeof value === 'string')
823
- .map(option => option.trim())
824
- .filter(Boolean);
825
- if (options.length < 2 || options.length > 10)
826
- return null;
827
- if (options.some(option => option.length > 100))
828
- return null;
829
- if (new Set(options.map(option => option.toLowerCase())).size !== options.length)
830
- return null;
831
- return options;
832
- }
833
- function isAllowedInlineButtonUrl(value) {
834
- try {
835
- const parsed = new URL(value);
836
- return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'tg:';
837
- }
838
- catch {
839
- return false;
840
- }
841
- }
842
- function normalizeInlineKeyboard(rawButtons) {
843
- if (!Array.isArray(rawButtons) || rawButtons.length === 0)
844
- return null;
845
- const rows = [];
846
- for (const rawRow of rawButtons) {
847
- if (!Array.isArray(rawRow) || rawRow.length === 0)
848
- return null;
849
- const row = [];
850
- for (const rawButton of rawRow) {
851
- if (!rawButton || typeof rawButton !== 'object')
852
- return null;
853
- const button = rawButton;
854
- const text = typeof button.text === 'string' ? button.text.trim() : '';
855
- const url = typeof button.url === 'string' ? button.url.trim() : '';
856
- const callbackData = typeof button.callback_data === 'string' ? button.callback_data : '';
857
- const hasUrl = url.length > 0;
858
- const hasCallback = callbackData.length > 0;
859
- if (!text || hasUrl === hasCallback)
860
- return null;
861
- if (hasUrl && !isAllowedInlineButtonUrl(url))
862
- return null;
863
- if (hasCallback && callbackData.length > 64)
864
- return null;
865
- if (hasUrl)
866
- row.push({ text, url });
867
- else
868
- row.push({ text, callback_data: callbackData });
372
+ if (command === 'remember') {
373
+ if (requireMain('Remembering facts'))
374
+ return true;
375
+ const fact = args.join(' ').trim();
376
+ if (!fact) {
377
+ await reply('Usage: /dotclaw remember <fact>');
378
+ return true;
869
379
  }
870
- rows.push(row);
871
- }
872
- return rows;
873
- }
874
- class RetryableMessageProcessingError extends Error {
875
- constructor(message) {
876
- super(message);
877
- this.name = 'RetryableMessageProcessingError';
878
- }
879
- }
880
- function computeMessageQueueRetryDelayMs(attempt) {
881
- const exp = Math.max(0, attempt - 1);
882
- const base = Math.min(MESSAGE_QUEUE_RETRY_MAX_MS, MESSAGE_QUEUE_RETRY_BASE_MS * Math.pow(2, exp));
883
- const jitter = base * (0.8 + Math.random() * 0.4);
884
- return Math.max(250, Math.round(jitter));
885
- }
886
- async function sendMessageForQueue(chatId, text, options) {
887
- const result = await sendMessage(chatId, text, options);
888
- if (!result.success) {
889
- throw new RetryableMessageProcessingError('Failed to deliver Telegram message');
380
+ const items = [{
381
+ scope: 'global',
382
+ type: 'fact',
383
+ content: fact,
384
+ importance: 0.7,
385
+ confidence: 0.8,
386
+ tags: ['manual']
387
+ }];
388
+ upsertMemoryItems('global', items, 'admin-command');
389
+ await reply(`Remembered: "${fact}"`);
390
+ return true;
890
391
  }
891
- return { success: true, messageId: result.messageId };
892
- }
893
- function enqueueMessage(msg) {
894
- if (isCancelMessage(msg.content)) {
895
- const controller = activeRuns.get(msg.chatId);
896
- if (controller) {
897
- controller.abort();
898
- activeRuns.delete(msg.chatId);
899
- void sendMessage(msg.chatId, 'Canceled.', { messageThreadId: msg.messageThreadId });
900
- return;
392
+ if (command === 'style') {
393
+ const level = (args[0] || '').toLowerCase();
394
+ const mapping = {
395
+ concise: 'Prefers concise, short responses.',
396
+ balanced: 'Prefers balanced-length responses.',
397
+ detailed: 'Prefers detailed, thorough responses.'
398
+ };
399
+ if (!mapping[level]) {
400
+ await reply('Usage: /dotclaw style <concise|balanced|detailed>');
401
+ return true;
901
402
  }
902
- void sendMessage(msg.chatId, "Nothing's running right now.", { messageThreadId: msg.messageThreadId });
903
- return;
904
- }
905
- enqueueMessageItem({
906
- chat_jid: msg.chatId,
907
- message_id: msg.messageId,
908
- sender_id: msg.senderId,
909
- sender_name: msg.senderName,
910
- content: msg.content,
911
- timestamp: msg.timestamp,
912
- is_group: msg.isGroup,
913
- chat_type: msg.chatType,
914
- message_thread_id: msg.messageThreadId
915
- });
916
- setMessageQueueDepth(getPendingMessageCount());
917
- if (!activeDrains.has(msg.chatId)) {
918
- void drainQueue(msg.chatId);
403
+ const items = [{
404
+ scope: 'user',
405
+ subject_id: params.senderId,
406
+ type: 'preference',
407
+ conflict_key: 'response_style',
408
+ content: mapping[level],
409
+ importance: 0.6,
410
+ confidence: 0.8,
411
+ tags: [`response_style:${level}`]
412
+ }];
413
+ upsertMemoryItems(group.folder, items, 'admin-command');
414
+ invalidatePersonalizationCache(group.folder, params.senderId);
415
+ await reply(`Response style set to ${level}.`);
416
+ return true;
919
417
  }
920
- }
921
- async function drainQueue(chatId) {
922
- if (activeDrains.has(chatId))
923
- return;
924
- activeDrains.add(chatId);
925
- setMessageQueueDepth(getPendingMessageCount());
926
- let reschedule = false;
927
- try {
928
- let iterations = 0;
929
- while (iterations < MAX_DRAIN_ITERATIONS) {
930
- const batch = claimBatchForChat(chatId, BATCH_WINDOW_MS, MAX_BATCH_SIZE);
931
- if (batch.length === 0)
932
- break;
933
- iterations++;
934
- const last = batch[batch.length - 1];
935
- const triggerMsg = {
936
- chatId: last.chat_jid,
937
- messageId: last.message_id,
938
- senderId: last.sender_id,
939
- senderName: last.sender_name,
940
- content: last.content,
941
- timestamp: last.timestamp,
942
- isGroup: last.is_group === 1,
943
- chatType: last.chat_type,
944
- messageThreadId: last.message_thread_id ?? undefined
945
- };
946
- const batchIds = batch.map(b => b.id);
947
- try {
948
- await processMessage(triggerMsg);
949
- completeQueuedMessages(batchIds);
950
- }
951
- catch (err) {
952
- const errMsg = err instanceof Error ? err.message : String(err);
953
- const attempt = Math.max(1, ...batch.map(row => {
954
- const previousAttempts = Number.isFinite(row.attempt_count) ? Number(row.attempt_count) : 0;
955
- return previousAttempts + 1;
956
- }));
957
- const isRetryable = err instanceof RetryableMessageProcessingError;
958
- if (isRetryable && attempt < MESSAGE_QUEUE_MAX_RETRIES) {
959
- requeueQueuedMessages(batchIds, errMsg);
960
- const delayMs = computeMessageQueueRetryDelayMs(attempt);
961
- logger.warn({
962
- chatId,
963
- attempt,
964
- maxRetries: MESSAGE_QUEUE_MAX_RETRIES,
965
- delayMs,
966
- error: errMsg
967
- }, 'Retryable batch failure; re-queued for retry');
968
- await sleep(delayMs);
969
- continue;
970
- }
971
- failQueuedMessages(batchIds, errMsg);
972
- logger.error({ chatId, attempt, err }, 'Error processing message batch');
973
- }
974
- }
975
- if (iterations >= MAX_DRAIN_ITERATIONS) {
976
- reschedule = true;
977
- logger.warn({ chatId, iterations }, 'Drain loop hit iteration limit; re-scheduling');
978
- setTimeout(() => {
979
- activeDrains.delete(chatId);
980
- void drainQueue(chatId);
981
- }, 1000);
418
+ if (command === 'tools') {
419
+ const level = (args[0] || '').toLowerCase();
420
+ const mapping = {
421
+ conservative: 'Prefers conservative tool usage.',
422
+ balanced: 'Prefers balanced tool usage.',
423
+ proactive: 'Prefers proactive tool usage.'
424
+ };
425
+ if (!mapping[level]) {
426
+ await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
427
+ return true;
982
428
  }
429
+ const items = [{
430
+ scope: 'user',
431
+ subject_id: params.senderId,
432
+ type: 'preference',
433
+ conflict_key: 'tool_usage',
434
+ content: mapping[level],
435
+ importance: 0.6,
436
+ confidence: 0.8,
437
+ tags: [`tool_usage:${level}`]
438
+ }];
439
+ upsertMemoryItems(group.folder, items, 'admin-command');
440
+ invalidatePersonalizationCache(group.folder, params.senderId);
441
+ await reply(`Tool usage set to ${level}.`);
442
+ return true;
983
443
  }
984
- finally {
985
- if (!reschedule) {
986
- activeDrains.delete(chatId);
444
+ if (command === 'caution') {
445
+ const level = (args[0] || '').toLowerCase();
446
+ const mapping = {
447
+ low: 'Prefers low caution.',
448
+ balanced: 'Prefers balanced caution.',
449
+ high: 'Prefers high caution.'
450
+ };
451
+ if (!mapping[level]) {
452
+ await reply('Usage: /dotclaw caution <low|balanced|high>');
453
+ return true;
987
454
  }
988
- setMessageQueueDepth(getPendingMessageCount());
989
- }
990
- }
991
- async function processMessage(msg) {
992
- const group = registeredGroups[msg.chatId];
993
- if (!group) {
994
- logger.debug({ chatId: msg.chatId }, 'Message from unregistered Telegram chat');
995
- return false;
455
+ const items = [{
456
+ scope: 'user',
457
+ subject_id: params.senderId,
458
+ type: 'preference',
459
+ conflict_key: 'caution_level',
460
+ content: mapping[level],
461
+ importance: 0.6,
462
+ confidence: 0.8,
463
+ tags: [`caution_level:${level}`]
464
+ }];
465
+ upsertMemoryItems(group.folder, items, 'admin-command');
466
+ invalidatePersonalizationCache(group.folder, params.senderId);
467
+ await reply(`Caution level set to ${level}.`);
468
+ return true;
996
469
  }
997
- recordMessage('telegram');
998
- setLastMessageTime(msg.timestamp);
999
- // Get messages since last agent interaction, filtered to only include
1000
- // messages up to and including the triggering message (not future queued ones)
1001
- const chatState = getChatState(msg.chatId);
1002
- let missedMessages = getMessagesSinceCursor(msg.chatId, chatState?.last_agent_timestamp || null, chatState?.last_agent_message_id || null);
1003
- const triggerMessageId = Number.parseInt(msg.messageId, 10);
1004
- missedMessages = missedMessages.filter((message) => {
1005
- if (message.timestamp < msg.timestamp)
470
+ if (command === 'memory') {
471
+ const level = (args[0] || '').toLowerCase();
472
+ const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
473
+ if (threshold === null) {
474
+ await reply('Usage: /dotclaw memory <strict|balanced|loose>');
1006
475
  return true;
1007
- if (message.timestamp !== msg.timestamp)
1008
- return false;
1009
- const numericId = Number.parseInt(message.id, 10);
1010
- if (Number.isFinite(triggerMessageId) && Number.isFinite(numericId)) {
1011
- return numericId <= triggerMessageId;
1012
476
  }
1013
- return message.id <= msg.messageId;
1014
- });
1015
- if (missedMessages.length === 0) {
1016
- logger.warn({ chatId: msg.chatId }, 'No missed messages found; falling back to current message');
1017
- const fallbackAttachments = msg.attachments && msg.attachments.length > 0
1018
- ? JSON.stringify(msg.attachments)
1019
- : null;
1020
- missedMessages = [{
1021
- id: msg.messageId,
1022
- chat_jid: msg.chatId,
1023
- sender: msg.senderId,
1024
- sender_name: msg.senderName,
1025
- content: msg.content,
1026
- timestamp: msg.timestamp,
1027
- attachments_json: fallbackAttachments
477
+ const items = [{
478
+ scope: 'user',
479
+ subject_id: params.senderId,
480
+ type: 'preference',
481
+ conflict_key: 'memory_importance_threshold',
482
+ content: `Prefers memory strictness ${level}.`,
483
+ importance: 0.6,
484
+ confidence: 0.8,
485
+ tags: [`memory_importance_threshold:${threshold}`],
486
+ metadata: { memory_importance_threshold: threshold, threshold }
1028
487
  }];
488
+ upsertMemoryItems(group.folder, items, 'admin-command');
489
+ await reply(`Memory strictness set to ${level}.`);
490
+ return true;
1029
491
  }
1030
- const lines = missedMessages.map(m => {
1031
- // Escape XML special characters in content
1032
- const escapeXml = (s) => s
1033
- .replace(/&/g, '&amp;')
1034
- .replace(/</g, '&lt;')
1035
- .replace(/>/g, '&gt;')
1036
- .replace(/"/g, '&quot;');
1037
- const safeContent = clampInputMessage(m.content, INPUT_MESSAGE_MAX_CHARS);
1038
- // Parse attachments from DB JSON if present
1039
- let attachments = [];
1040
- if (m.attachments_json) {
1041
- try {
1042
- attachments = JSON.parse(m.attachments_json);
1043
- }
1044
- catch { /* ignore */ }
1045
- }
1046
- const attachmentXml = buildAttachmentsXml(attachments, group.folder);
1047
- const inner = attachmentXml
1048
- ? `${escapeXml(safeContent)}\n${attachmentXml}`
1049
- : escapeXml(safeContent);
1050
- return `<message sender="${escapeXml(m.sender_name)}" sender_id="${escapeXml(m.sender)}" time="${m.timestamp}">${inner}</message>`;
1051
- });
1052
- const prompt = `<messages>
1053
- ${lines.join('\n')}
1054
- </messages>`;
1055
- const lastMessage = missedMessages[missedMessages.length - 1];
1056
- const parsedReplyToMessageId = Number.parseInt(msg.messageId, 10);
1057
- const replyToMessageId = Number.isFinite(parsedReplyToMessageId) ? parsedReplyToMessageId : undefined;
1058
- const containerAttachments = (() => {
1059
- for (let idx = missedMessages.length - 1; idx >= 0; idx -= 1) {
1060
- const raw = missedMessages[idx].attachments_json;
1061
- if (!raw)
1062
- continue;
1063
- try {
1064
- const parsed = JSON.parse(raw);
1065
- if (!Array.isArray(parsed) || parsed.length === 0)
1066
- continue;
1067
- const mapped = parsed.flatMap(attachment => {
1068
- if (!attachment?.local_path)
1069
- return [];
1070
- const containerPath = hostPathToContainerPath(attachment.local_path, group.folder);
1071
- if (!containerPath)
1072
- return [];
1073
- return [{
1074
- type: attachment.type,
1075
- path: containerPath,
1076
- file_name: attachment.file_name,
1077
- mime_type: attachment.mime_type,
1078
- file_size: attachment.file_size,
1079
- duration: attachment.duration,
1080
- width: attachment.width,
1081
- height: attachment.height
1082
- }];
1083
- });
1084
- if (mapped.length > 0)
1085
- return mapped;
1086
- }
1087
- catch {
1088
- // ignore malformed attachment payloads
1089
- }
492
+ if (command === 'skill-help') {
493
+ await reply([
494
+ 'Skill commands:',
495
+ '- `/dotclaw skill install <url> [--global]` — install from git repo or URL',
496
+ '- `/dotclaw skill remove <name> [--global]` — remove a skill',
497
+ '- `/dotclaw skill list [--global]` — list installed skills',
498
+ '- `/dotclaw skill update <name> [--global]` — re-pull from source'
499
+ ].join('\n'));
500
+ return true;
501
+ }
502
+ if (command === 'skill-install') {
503
+ if (requireMain('Installing skills'))
504
+ return true;
505
+ if (!runtime.agent.skills.installEnabled) {
506
+ await reply('Skill installation is disabled in runtime config (`agent.skills.installEnabled`).');
507
+ return true;
1090
508
  }
1091
- return undefined;
1092
- })();
1093
- const routingStartedAt = Date.now();
1094
- const routingDecision = routeRequest({
1095
- prompt,
1096
- lastMessage
1097
- });
1098
- recordRoutingDecision(routingDecision.profile);
1099
- const routerMs = Date.now() - routingStartedAt;
1100
- recordStageLatency('router', routerMs, 'telegram');
1101
- logger.info({
1102
- chatId: msg.chatId,
1103
- profile: routingDecision.profile,
1104
- reason: routingDecision.reason,
1105
- shouldBackground: routingDecision.shouldBackground
1106
- }, 'Routing decision');
1107
- const traceBase = createTraceBase({
1108
- chatId: msg.chatId,
1109
- groupFolder: group.folder,
1110
- userId: msg.senderId,
1111
- inputText: prompt,
1112
- source: 'dotclaw'
1113
- });
1114
- logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing message');
1115
- await setTyping(msg.chatId);
1116
- const recallQuery = missedMessages.map(entry => entry.content).join('\n');
1117
- let output = null;
1118
- let context = null;
1119
- let errorMessage = null;
1120
- const isTimeoutError = (value) => {
1121
- if (!value)
1122
- return false;
1123
- return /timed out|timeout/i.test(value);
1124
- };
1125
- const shouldPlannerProbe = () => {
1126
- const config = runtime.host.routing.plannerProbe;
1127
- if (!config.enabled)
1128
- return false;
1129
- if (routingDecision.profile === 'fast' || routingDecision.shouldBackground)
1130
- return false;
1131
- const contentLength = lastMessage?.content?.length || 0;
1132
- return contentLength >= config.minChars;
1133
- };
1134
- const maybeAutoSpawn = async (reason, _detail, overrides) => {
1135
- if (!BACKGROUND_JOBS_ENABLED)
1136
- return false;
1137
- if (reason !== 'router' && !AUTO_SPAWN_ENABLED)
1138
- return false;
1139
- if (reason === 'timeout' && !AUTO_SPAWN_ON_TIMEOUT)
1140
- return false;
1141
- if (reason === 'tool_limit' && !AUTO_SPAWN_ON_TOOL_LIMIT)
1142
- return false;
1143
- const tags = ['auto-spawn', reason, `profile:${routingDecision.profile}`];
1144
- if (overrides?.tags && overrides.tags.length > 0) {
1145
- tags.push(...overrides.tags);
509
+ const isGlobal = args.includes('--global');
510
+ const source = args.filter(a => a !== '--global')[0];
511
+ if (!source) {
512
+ await reply('Usage: /dotclaw skill install <url> [--global]');
513
+ return true;
1146
514
  }
1147
- if (routingDecision.estimatedMinutes) {
1148
- tags.push(`eta:${routingDecision.estimatedMinutes}`);
515
+ const scope = isGlobal ? 'global' : 'group';
516
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
517
+ await reply(`Installing skill from ${source}...`);
518
+ const result = await installSkill({ source, targetDir, scope });
519
+ if (!result.ok) {
520
+ await reply(`Failed to install skill: ${result.error}`);
1149
521
  }
1150
- const estimatedMs = typeof routingDecision.estimatedMinutes === 'number'
1151
- ? routingDecision.estimatedMinutes * 60_000
1152
- : null;
1153
- const computedTimeoutMs = estimatedMs
1154
- ? Math.min(runtime.host.backgroundJobs.maxRuntimeMs, Math.max(5 * 60_000, Math.round(estimatedMs * 2)))
1155
- : undefined;
1156
- const result = spawnBackgroundJob({
1157
- prompt,
1158
- groupFolder: group.folder,
1159
- chatJid: msg.chatId,
1160
- contextMode: 'group',
1161
- tags,
1162
- parentTraceId: traceBase.trace_id,
1163
- parentMessageId: msg.messageId,
1164
- modelOverride: overrides?.modelOverride ?? routingDecision.modelOverride,
1165
- maxToolSteps: overrides?.maxToolSteps ?? routingDecision.maxToolSteps,
1166
- toolAllow: routingDecision.toolAllow,
1167
- toolDeny: routingDecision.toolDeny,
1168
- timeoutMs: overrides?.timeoutMs ?? computedTimeoutMs
1169
- });
1170
- if (!result.ok || !result.jobId) {
1171
- logger.warn({ chatId: msg.chatId, reason, error: result.error }, 'Auto-spawn background job failed');
1172
- return false;
522
+ else {
523
+ await reply(`Skill "${result.name}" installed (${scope}). Available on next agent run.`);
1173
524
  }
1174
- const queuePosition = getBackgroundJobQueuePosition({ jobId: result.jobId, groupFolder: group.folder });
1175
- const eta = routingDecision.estimatedMinutes ? `~${routingDecision.estimatedMinutes} min` : null;
1176
- const queueLine = queuePosition && queuePosition.position > 1
1177
- ? `\n\n${queuePosition.position - 1} job${queuePosition.position > 2 ? 's' : ''} ahead of this one.`
1178
- : '';
1179
- const etaLine = eta ? `\n\nEstimated time: ${eta}.` : '';
1180
- const planPreview = plannerProbeSteps.length > 0
1181
- ? formatPlanStepList({ steps: plannerProbeSteps, currentStep: 1, maxSteps: 4 })
1182
- : '';
1183
- const planLine = planPreview ? `\n\nPlanned steps:\n${planPreview}` : '';
1184
- await sendMessageForQueue(msg.chatId, `Working on it in the background. I'll send the result when it's done.${queueLine}${etaLine}${planLine}`, { messageThreadId: msg.messageThreadId, replyToMessageId });
1185
- updateChatState(msg.chatId, msg.timestamp, msg.messageId);
1186
525
  return true;
1187
- };
1188
- let plannerProbeTools = [];
1189
- let plannerProbeSteps = [];
1190
- let plannerProbeMs = null;
1191
- if (shouldPlannerProbe() && lastMessage) {
1192
- const probeStarted = Date.now();
1193
- const probeResult = await probePlanner({
1194
- lastMessage,
1195
- recentMessages: missedMessages
1196
- });
1197
- plannerProbeMs = Date.now() - probeStarted;
1198
- recordStageLatency('planner_probe', plannerProbeMs, 'telegram');
1199
- if (probeResult.steps.length > 0)
1200
- plannerProbeSteps = probeResult.steps;
1201
- if (probeResult.tools.length > 0)
1202
- plannerProbeTools = probeResult.tools;
1203
- logger.info({
1204
- chatId: msg.chatId,
1205
- shouldBackground: probeResult.shouldBackground,
1206
- steps: probeResult.steps.length,
1207
- tools: probeResult.tools.length,
1208
- latencyMs: probeResult.latencyMs,
1209
- model: probeResult.model,
1210
- error: probeResult.error
1211
- }, 'Planner probe decision');
1212
- if (probeResult.shouldBackground) {
1213
- const autoSpawned = await maybeAutoSpawn('planner', 'planner probe predicted multi-step work');
1214
- if (autoSpawned)
1215
- return true;
1216
- }
1217
526
  }
1218
- if (routingDecision.shouldBackground) {
1219
- const autoSpawned = await maybeAutoSpawn('router', routingDecision.reason);
1220
- if (autoSpawned) {
527
+ if (command === 'skill-remove') {
528
+ if (requireMain('Removing skills'))
529
+ return true;
530
+ const isGlobal = args.includes('--global');
531
+ const name = args.filter(a => a !== '--global')[0];
532
+ if (!name) {
533
+ await reply('Usage: /dotclaw skill remove <name> [--global]');
1221
534
  return true;
1222
535
  }
1223
- }
1224
- let classifierMs = null;
1225
- if (AUTO_SPAWN_ENABLED && AUTO_SPAWN_CLASSIFIER_ENABLED && lastMessage && routingDecision.shouldRunClassifier) {
1226
- try {
1227
- const queueDepth = getBackgroundJobQueueDepth({ groupFolder: group.folder });
1228
- const classifierResult = await classifyBackgroundJob({
1229
- lastMessage,
1230
- recentMessages: missedMessages,
1231
- isGroup: msg.isGroup,
1232
- chatType: msg.chatType,
1233
- queueDepth,
1234
- metricsSource: 'telegram'
1235
- });
1236
- if (classifierResult.latencyMs) {
1237
- classifierMs = classifierResult.latencyMs;
1238
- recordStageLatency('classifier', classifierResult.latencyMs, 'telegram');
1239
- }
1240
- logger.info({
1241
- chatId: msg.chatId,
1242
- decision: classifierResult.shouldBackground,
1243
- confidence: classifierResult.confidence,
1244
- latencyMs: classifierResult.latencyMs,
1245
- model: classifierResult.model,
1246
- reason: classifierResult.reason,
1247
- error: classifierResult.error
1248
- }, 'Background job classifier decision');
1249
- if (classifierResult.shouldBackground) {
1250
- const estimated = classifierResult.estimatedMinutes;
1251
- if (typeof estimated === 'number' && Number.isFinite(estimated) && estimated > 0) {
1252
- routingDecision.estimatedMinutes = Math.round(estimated);
1253
- }
1254
- const autoSpawned = await maybeAutoSpawn('classifier', classifierResult.reason);
1255
- if (autoSpawned) {
1256
- return true;
1257
- }
1258
- }
536
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
537
+ const result = removeSkill({ name, targetDir });
538
+ if (!result.ok) {
539
+ await reply(`Failed to remove skill: ${result.error}`);
1259
540
  }
1260
- catch (err) {
1261
- logger.warn({ chatId: msg.chatId, err }, 'Background job classifier failed');
541
+ else {
542
+ await reply(`Skill "${name}" removed.`);
1262
543
  }
544
+ return true;
1263
545
  }
1264
- // Refresh typing indicator every 4s (Telegram expires it after ~5s)
1265
- const typingInterval = setInterval(() => { void setTyping(msg.chatId); }, 4_000);
1266
- const predictedStage = inferProgressStage({
1267
- content: lastMessage?.content || prompt,
1268
- plannerTools: plannerProbeTools,
1269
- plannerSteps: plannerProbeSteps,
1270
- enablePlanner: routingDecision.enablePlanner
1271
- });
1272
- const predictedMs = estimateForegroundMs({
1273
- content: lastMessage?.content || prompt,
1274
- routing: routingDecision,
1275
- plannerSteps: plannerProbeSteps,
1276
- plannerTools: plannerProbeTools
1277
- });
1278
- const planStepIndex = inferPlanStepIndex(predictedStage, plannerProbeSteps.length);
1279
- const progressManager = createProgressManager({
1280
- enabled: routingDecision.progress.enabled,
1281
- initialDelayMs: routingDecision.progress.initialMs,
1282
- intervalMs: routingDecision.progress.intervalMs,
1283
- maxUpdates: routingDecision.progress.maxUpdates,
1284
- messages: routingDecision.progress.messages.length > 0
1285
- ? routingDecision.progress.messages
1286
- : DEFAULT_PROGRESS_MESSAGES,
1287
- stageMessages: DEFAULT_PROGRESS_STAGES,
1288
- stageThrottleMs: 20_000,
1289
- send: async (text) => { await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId }); },
1290
- onError: (err) => logger.debug({ chatId: msg.chatId, err }, 'Failed to send progress update')
1291
- });
1292
- progressManager.start();
1293
- let sentPlan = false;
1294
- if (predictedMs && predictedMs >= 10_000 && routingDecision.progress.enabled) {
1295
- if (plannerProbeSteps.length > 0) {
1296
- const planMessage = formatProgressWithPlan({
1297
- steps: plannerProbeSteps,
1298
- currentStep: planStepIndex ?? 1,
1299
- stage: predictedStage
1300
- });
1301
- progressManager.notify(planMessage);
1302
- sentPlan = true;
546
+ if (command === 'skill-list') {
547
+ if (requireMain('Listing skills'))
548
+ return true;
549
+ const isGlobal = args.includes('--global');
550
+ const scope = isGlobal ? 'global' : 'group';
551
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
552
+ const skills = listSkills(targetDir, scope);
553
+ if (skills.length === 0) {
554
+ await reply(`No skills installed (${scope}).`);
1303
555
  }
1304
556
  else {
1305
- progressManager.notify(DEFAULT_PROGRESS_STAGES.ack);
557
+ const lines = skills.map(s => `- ${s.name} (v${s.version}, source: ${s.source === 'local' ? 'local' : 'remote'})`);
558
+ await reply(`Installed skills (${scope}):\n${lines.join('\n')}`);
1306
559
  }
560
+ return true;
1307
561
  }
1308
- if (!(sentPlan && predictedStage === 'planning')) {
1309
- progressManager.setStage(predictedStage);
1310
- }
1311
- if (predictedStage === 'planning') {
1312
- const followUpStage = inferProgressStage({
1313
- content: lastMessage?.content || prompt,
1314
- plannerTools: plannerProbeTools,
1315
- plannerSteps: plannerProbeSteps,
1316
- enablePlanner: false
1317
- });
1318
- if (followUpStage !== 'planning') {
1319
- const delay = Math.min(15_000, Math.max(5_000, Math.floor(routingDecision.progress.initialMs / 2)));
1320
- setTimeout(() => progressManager.setStage(followUpStage), delay);
562
+ if (command === 'skill-update') {
563
+ if (requireMain('Updating skills'))
564
+ return true;
565
+ const isGlobal = args.includes('--global');
566
+ const name = args.filter(a => a !== '--global')[0];
567
+ if (!name) {
568
+ await reply('Usage: /dotclaw skill update <name> [--global]');
569
+ return true;
570
+ }
571
+ const scope = isGlobal ? 'global' : 'group';
572
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
573
+ const result = await updateSkill({ name, targetDir, scope });
574
+ if (!result.ok) {
575
+ await reply(`Failed to update skill: ${result.error}`);
576
+ }
577
+ else {
578
+ await reply(`Skill "${name}" updated.`);
1321
579
  }
580
+ return true;
581
+ }
582
+ await reply('Unknown command. Use `/dotclaw help` for options.');
583
+ return true;
584
+ }
585
+ // ───────────────────────── Heartbeat ─────────────────────────
586
+ async function runHeartbeatOnce() {
587
+ const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
588
+ if (!entry) {
589
+ logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
590
+ return;
1322
591
  }
1323
- const abortController = new AbortController();
1324
- activeRuns.set(msg.chatId, abortController);
592
+ const [chatId, group] = entry;
593
+ const prompt = [
594
+ '[HEARTBEAT]',
595
+ 'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
596
+ 'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
597
+ ].join('\n');
598
+ const traceBase = createTraceBase({
599
+ chatId,
600
+ groupFolder: group.folder,
601
+ userId: null,
602
+ inputText: prompt,
603
+ source: 'dotclaw-heartbeat'
604
+ });
605
+ const routingStartedAt = Date.now();
606
+ const routingDecision = routePrompt(prompt);
607
+ recordRoutingDecision(routingDecision.profile);
608
+ const routerMs = Date.now() - routingStartedAt;
609
+ recordStageLatency('router', routerMs, 'scheduler');
610
+ let output = null;
611
+ let context = null;
612
+ let errorMessage = null;
613
+ const baseRecallResults = Number.isFinite(routingDecision.recallMaxResults)
614
+ ? Math.max(0, Math.floor(routingDecision.recallMaxResults))
615
+ : MEMORY_RECALL_MAX_RESULTS;
616
+ const baseRecallTokens = Number.isFinite(routingDecision.recallMaxTokens)
617
+ ? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
618
+ : MEMORY_RECALL_MAX_TOKENS;
619
+ const recallMaxResults = routingDecision.enableMemoryRecall ? Math.max(4, baseRecallResults - 2) : 0;
620
+ const recallMaxTokens = routingDecision.enableMemoryRecall ? Math.max(600, baseRecallTokens - 200) : 0;
1325
621
  try {
1326
- const recallMaxResults = routingDecision.enableMemoryRecall
1327
- ? (Number.isFinite(routingDecision.recallMaxResults)
1328
- ? Math.max(0, Math.floor(routingDecision.recallMaxResults))
1329
- : MEMORY_RECALL_MAX_RESULTS)
1330
- : 0;
1331
- const recallMaxTokens = routingDecision.enableMemoryRecall
1332
- ? (Number.isFinite(routingDecision.recallMaxTokens)
1333
- ? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
1334
- : MEMORY_RECALL_MAX_TOKENS)
1335
- : 0;
1336
622
  const execution = await executeAgentRun({
1337
623
  group,
1338
624
  prompt,
1339
- chatJid: msg.chatId,
1340
- userId: msg.senderId,
1341
- userName: msg.senderName,
1342
- recallQuery: recallQuery || msg.content,
625
+ chatJid: chatId,
626
+ userId: null,
627
+ recallQuery: prompt,
1343
628
  recallMaxResults,
1344
629
  recallMaxTokens,
1345
- toolAllow: routingDecision.toolAllow,
1346
- toolDeny: routingDecision.toolDeny,
1347
630
  sessionId: sessions[group.folder],
1348
631
  onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
632
+ isScheduledTask: true,
1349
633
  availableGroups: buildAvailableGroupsSnapshot(),
1350
634
  modelOverride: routingDecision.modelOverride,
1351
635
  modelMaxOutputTokens: routingDecision.maxOutputTokens,
1352
636
  maxToolSteps: routingDecision.maxToolSteps,
1353
637
  disablePlanner: !routingDecision.enablePlanner,
1354
638
  disableResponseValidation: !routingDecision.enableResponseValidation,
1355
- responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
1356
- disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
1357
- attachments: containerAttachments,
1358
- abortSignal: abortController.signal,
1359
- timeoutMs: AUTO_SPAWN_ENABLED && AUTO_SPAWN_FOREGROUND_TIMEOUT_MS > 0
1360
- ? AUTO_SPAWN_FOREGROUND_TIMEOUT_MS
1361
- : undefined
1362
- });
1363
- output = execution.output;
1364
- context = execution.context;
1365
- progressManager.setStage('finalizing');
1366
- if (output.status === 'error') {
1367
- errorMessage = output.error || 'Unknown error';
1368
- }
1369
- }
1370
- catch (err) {
1371
- if (err instanceof AgentExecutionError) {
1372
- context = err.context;
1373
- errorMessage = err.message;
1374
- }
1375
- else {
1376
- errorMessage = err instanceof Error ? err.message : String(err);
1377
- }
1378
- logger.error({ group: group.name, err }, 'Agent error');
1379
- }
1380
- finally {
1381
- clearInterval(typingInterval);
1382
- progressManager.stop();
1383
- activeRuns.delete(msg.chatId);
1384
- }
1385
- const extraTimings = {};
1386
- extraTimings.router_ms = routerMs;
1387
- if (classifierMs !== null)
1388
- extraTimings.classifier_ms = classifierMs;
1389
- if (plannerProbeMs !== null)
1390
- extraTimings.planner_probe_ms = plannerProbeMs;
1391
- if (!output) {
1392
- const message = errorMessage || 'No output from agent';
1393
- if (context) {
1394
- recordAgentTelemetry({
1395
- traceBase,
1396
- output: null,
1397
- context,
1398
- metricsSource: 'telegram',
1399
- toolAuditSource: 'message',
1400
- errorMessage: message,
1401
- errorType: 'agent',
1402
- extraTimings
1403
- });
1404
- }
1405
- else {
1406
- recordError('agent');
1407
- writeTrace({
1408
- trace_id: traceBase.trace_id,
1409
- timestamp: traceBase.timestamp,
1410
- created_at: traceBase.created_at,
1411
- chat_id: traceBase.chat_id,
1412
- group_folder: traceBase.group_folder,
1413
- user_id: traceBase.user_id,
1414
- input_text: traceBase.input_text,
1415
- output_text: null,
1416
- model_id: 'unknown',
1417
- memory_recall: [],
1418
- error_code: message,
1419
- source: traceBase.source
1420
- });
1421
- }
1422
- if (isTimeoutError(message)) {
1423
- const autoSpawned = await maybeAutoSpawn('timeout', message);
1424
- if (autoSpawned) {
1425
- return true;
1426
- }
1427
- }
1428
- const userMessage = humanizeError(errorMessage || 'Unknown error');
1429
- await sendMessageForQueue(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId, replyToMessageId });
1430
- return false;
1431
- }
1432
- if (output.status === 'error') {
1433
- if (context) {
1434
- recordAgentTelemetry({
1435
- traceBase,
1436
- output,
1437
- context,
1438
- metricsSource: 'telegram',
1439
- toolAuditSource: 'message',
1440
- errorMessage: errorMessage || output.error || 'Unknown error',
1441
- errorType: 'agent',
1442
- extraTimings
1443
- });
1444
- }
1445
- logger.error({ group: group.name, error: output.error }, 'Container agent error');
1446
- const errorText = errorMessage || output.error || 'Unknown error';
1447
- if (isTimeoutError(errorText)) {
1448
- const autoSpawned = await maybeAutoSpawn('timeout', errorText);
1449
- if (autoSpawned) {
1450
- return true;
1451
- }
1452
- }
1453
- const userMessage = humanizeError(errorText);
1454
- await sendMessageForQueue(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId, replyToMessageId });
1455
- return false;
1456
- }
1457
- updateChatState(msg.chatId, msg.timestamp, msg.messageId);
1458
- if (output.result && output.result.trim()) {
1459
- const sendResult = await sendMessageForQueue(msg.chatId, output.result, { messageThreadId: msg.messageThreadId, replyToMessageId });
1460
- const sentMessageId = sendResult.messageId;
1461
- // Link the sent message to the trace for feedback tracking
1462
- if (sentMessageId) {
1463
- try {
1464
- linkMessageToTrace(sentMessageId, msg.chatId, traceBase.trace_id);
1465
- }
1466
- catch {
1467
- // Don't fail if linking fails
1468
- }
1469
- }
1470
- if (output.stdoutTruncated) {
1471
- await sendMessageForQueue(msg.chatId, 'That response was cut short because it was too large. Ask me to continue or try a smaller request.', { messageThreadId: msg.messageThreadId });
1472
- }
1473
- }
1474
- else if (output.tool_calls && output.tool_calls.length > 0) {
1475
- const toolLimitHit = !output.result || !output.result.trim() || TOOL_CALL_FALLBACK_PATTERN.test(output.result);
1476
- if (toolLimitHit) {
1477
- const autoSpawned = await maybeAutoSpawn('tool_limit', 'Tool-call step limit reached');
1478
- if (autoSpawned) {
1479
- if (context) {
1480
- recordAgentTelemetry({
1481
- traceBase,
1482
- output,
1483
- context,
1484
- metricsSource: 'telegram',
1485
- toolAuditSource: 'message',
1486
- extraTimings
1487
- });
1488
- }
1489
- return true;
1490
- }
1491
- }
1492
- await sendMessageForQueue(msg.chatId, "I ran out of steps before I could finish. Try narrowing the scope or asking for a specific part.", { messageThreadId: msg.messageThreadId, replyToMessageId });
1493
- }
1494
- else {
1495
- logger.warn({ chatId: msg.chatId }, 'Agent returned empty/whitespace response');
1496
- await sendMessageForQueue(msg.chatId, "I wasn't able to come up with a response. Could you try rephrasing?", { messageThreadId: msg.messageThreadId, replyToMessageId });
1497
- }
1498
- if (context) {
1499
- recordAgentTelemetry({
1500
- traceBase,
1501
- output,
1502
- context,
1503
- metricsSource: 'telegram',
1504
- toolAuditSource: 'message',
1505
- extraTimings
1506
- });
1507
- }
1508
- return true;
1509
- }
1510
- let ipcWatcher = null;
1511
- let ipcPollingTimer = null;
1512
- let ipcStopped = false;
1513
- function stopIpcWatcher() {
1514
- ipcStopped = true;
1515
- if (ipcWatcher) {
1516
- ipcWatcher.close();
1517
- ipcWatcher = null;
1518
- }
1519
- if (ipcPollingTimer) {
1520
- clearTimeout(ipcPollingTimer);
1521
- ipcPollingTimer = null;
1522
- }
1523
- }
1524
- function startIpcWatcher() {
1525
- const ipcBaseDir = path.join(DATA_DIR, 'ipc');
1526
- fs.mkdirSync(ipcBaseDir, { recursive: true });
1527
- ipcStopped = false;
1528
- let processing = false;
1529
- let scheduled = false;
1530
- let rerunRequested = false;
1531
- const processIpcFiles = async () => {
1532
- if (processing) {
1533
- rerunRequested = true;
1534
- return;
1535
- }
1536
- processing = true;
1537
- try {
1538
- do {
1539
- rerunRequested = false;
1540
- // Scan all group IPC directories (identity determined by directory)
1541
- let groupFolders;
1542
- try {
1543
- groupFolders = fs.readdirSync(ipcBaseDir).filter(f => {
1544
- const stat = fs.statSync(path.join(ipcBaseDir, f));
1545
- return stat.isDirectory() && f !== 'errors';
1546
- });
1547
- }
1548
- catch (err) {
1549
- logger.error({ err }, 'Error reading IPC base directory');
1550
- return;
1551
- }
1552
- for (const sourceGroup of groupFolders) {
1553
- const isMain = sourceGroup === MAIN_GROUP_FOLDER;
1554
- const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
1555
- const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
1556
- const requestsDir = path.join(ipcBaseDir, sourceGroup, 'requests');
1557
- const responsesDir = path.join(ipcBaseDir, sourceGroup, 'responses');
1558
- // Process messages from this group's IPC directory
1559
- try {
1560
- if (fs.existsSync(messagesDir)) {
1561
- const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
1562
- for (const file of messageFiles) {
1563
- const filePath = path.join(messagesDir, file);
1564
- try {
1565
- const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1566
- const chatJid = data.chatJid;
1567
- const targetGroup = chatJid ? registeredGroups[chatJid] : undefined;
1568
- const isAuthorized = chatJid && (isMain || (targetGroup && targetGroup.folder === sourceGroup));
1569
- const rawReplyTo = typeof data.reply_to_message_id === 'number'
1570
- ? Math.trunc(data.reply_to_message_id)
1571
- : NaN;
1572
- const replyTo = Number.isInteger(rawReplyTo) && rawReplyTo > 0
1573
- ? rawReplyTo
1574
- : undefined;
1575
- const messageText = typeof data.text === 'string' ? data.text.trim() : '';
1576
- if (data.type === 'message' && chatJid && messageText) {
1577
- if (isAuthorized) {
1578
- await sendMessage(chatJid, messageText, { replyToMessageId: replyTo });
1579
- logger.info({ chatJid, sourceGroup }, 'IPC message sent');
1580
- }
1581
- else {
1582
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked');
1583
- }
1584
- }
1585
- else if ((data.type === 'send_file' || data.type === 'send_photo') && chatJid && data.path) {
1586
- if (isAuthorized) {
1587
- const hostPath = resolveContainerPathToHost(data.path, sourceGroup);
1588
- if (hostPath && fs.existsSync(hostPath)) {
1589
- const caption = typeof data.caption === 'string' ? data.caption : undefined;
1590
- if (data.type === 'send_photo') {
1591
- await sendPhoto(chatJid, hostPath, { caption, replyToMessageId: replyTo });
1592
- }
1593
- else {
1594
- await sendDocument(chatJid, hostPath, { caption, replyToMessageId: replyTo });
1595
- }
1596
- logger.info({ chatJid, sourceGroup, type: data.type, path: data.path }, 'IPC file sent');
1597
- }
1598
- else {
1599
- logger.warn({ chatJid, sourceGroup, path: data.path, hostPath }, 'IPC file not found or path not resolvable');
1600
- }
1601
- }
1602
- else {
1603
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC file send attempt blocked');
1604
- }
1605
- }
1606
- else if ((data.type === 'send_voice' || data.type === 'send_audio') && chatJid && data.path) {
1607
- if (isAuthorized) {
1608
- const hostPath = resolveContainerPathToHost(data.path, sourceGroup);
1609
- if (hostPath && fs.existsSync(hostPath)) {
1610
- const caption = typeof data.caption === 'string' ? data.caption : undefined;
1611
- const duration = typeof data.duration === 'number' ? data.duration : undefined;
1612
- if (data.type === 'send_voice') {
1613
- await sendVoice(chatJid, hostPath, { caption, duration, replyToMessageId: replyTo });
1614
- }
1615
- else {
1616
- const performer = typeof data.performer === 'string' ? data.performer : undefined;
1617
- const title = typeof data.title === 'string' ? data.title : undefined;
1618
- await sendAudio(chatJid, hostPath, { caption, duration, performer, title, replyToMessageId: replyTo });
1619
- }
1620
- logger.info({ chatJid, sourceGroup, type: data.type }, 'IPC audio/voice sent');
1621
- }
1622
- else {
1623
- logger.warn({ chatJid, sourceGroup, path: data.path }, 'IPC audio file not found');
1624
- }
1625
- }
1626
- else {
1627
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC audio send blocked');
1628
- }
1629
- }
1630
- else if (data.type === 'send_location' && chatJid) {
1631
- if (isAuthorized) {
1632
- const lat = typeof data.latitude === 'number' ? data.latitude : NaN;
1633
- const lng = typeof data.longitude === 'number' ? data.longitude : NaN;
1634
- if (Number.isFinite(lat)
1635
- && Number.isFinite(lng)
1636
- && lat >= -90
1637
- && lat <= 90
1638
- && lng >= -180
1639
- && lng <= 180) {
1640
- await sendLocation(chatJid, lat, lng, { replyToMessageId: replyTo });
1641
- logger.info({ chatJid, sourceGroup }, 'IPC location sent');
1642
- }
1643
- else {
1644
- logger.warn({ chatJid, sourceGroup }, 'Invalid location coordinates');
1645
- }
1646
- }
1647
- else {
1648
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC location send blocked');
1649
- }
1650
- }
1651
- else if (data.type === 'send_contact' && chatJid) {
1652
- if (isAuthorized) {
1653
- const phone = typeof data.phone_number === 'string' ? data.phone_number.trim() : '';
1654
- const firstName = typeof data.first_name === 'string' ? data.first_name.trim() : '';
1655
- const lastName = typeof data.last_name === 'string' ? data.last_name.trim() : undefined;
1656
- if (phone && firstName) {
1657
- await sendContact(chatJid, phone, firstName, { lastName, replyToMessageId: replyTo });
1658
- logger.info({ chatJid, sourceGroup }, 'IPC contact sent');
1659
- }
1660
- else {
1661
- logger.warn({ chatJid, sourceGroup }, 'Invalid contact (phone/name missing)');
1662
- }
1663
- }
1664
- else {
1665
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC contact send blocked');
1666
- }
1667
- }
1668
- else if (data.type === 'send_poll' && chatJid) {
1669
- if (isAuthorized) {
1670
- const question = typeof data.question === 'string' ? data.question.trim() : '';
1671
- const options = normalizePollOptions(data.options);
1672
- const pollType = data.poll_type === 'quiz' ? 'quiz' : 'regular';
1673
- const allowsMultipleAnswers = typeof data.allows_multiple_answers === 'boolean'
1674
- ? data.allows_multiple_answers
1675
- : undefined;
1676
- const rawCorrectOptionId = typeof data.correct_option_id === 'number' ? Math.trunc(data.correct_option_id) : undefined;
1677
- const hasValidCorrectOption = rawCorrectOptionId !== undefined
1678
- && options !== null
1679
- && rawCorrectOptionId >= 0
1680
- && rawCorrectOptionId < options.length;
1681
- const invalidQuizConfig = pollType === 'quiz' && allowsMultipleAnswers;
1682
- const unexpectedCorrectOption = pollType !== 'quiz' && rawCorrectOptionId !== undefined;
1683
- if (question && question.length <= 300 && options && !invalidQuizConfig && !unexpectedCorrectOption && (rawCorrectOptionId === undefined || hasValidCorrectOption)) {
1684
- await sendPoll(chatJid, question, options, {
1685
- is_anonymous: typeof data.is_anonymous === 'boolean' ? data.is_anonymous : undefined,
1686
- type: pollType,
1687
- allows_multiple_answers: allowsMultipleAnswers,
1688
- correct_option_id: pollType === 'quiz' ? rawCorrectOptionId : undefined,
1689
- replyToMessageId: replyTo
1690
- });
1691
- logger.info({ chatJid, sourceGroup }, 'IPC poll sent');
1692
- }
1693
- else {
1694
- logger.warn({ chatJid, sourceGroup }, 'Invalid poll payload');
1695
- }
1696
- }
1697
- else {
1698
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC poll send blocked');
1699
- }
1700
- }
1701
- else if (data.type === 'send_buttons' && chatJid) {
1702
- if (isAuthorized) {
1703
- const text = typeof data.text === 'string' ? data.text.trim() : '';
1704
- const normalizedButtons = normalizeInlineKeyboard(data.buttons);
1705
- if (text && normalizedButtons) {
1706
- // Register callback data and replace with IDs
1707
- const buttons = normalizedButtons.map(row => row.map(btn => {
1708
- if (btn.callback_data && !btn.url) {
1709
- const cbId = registerCallbackData(chatJid, btn.callback_data, btn.text);
1710
- return { text: btn.text, callback_data: cbId };
1711
- }
1712
- return btn;
1713
- }));
1714
- await sendInlineKeyboard(chatJid, text, buttons, { replyToMessageId: replyTo });
1715
- logger.info({ chatJid, sourceGroup }, 'IPC buttons sent');
1716
- }
1717
- else {
1718
- logger.warn({ chatJid, sourceGroup }, 'Invalid buttons message');
1719
- }
1720
- }
1721
- else {
1722
- logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC buttons send blocked');
1723
- }
1724
- }
1725
- fs.unlinkSync(filePath);
1726
- }
1727
- catch (err) {
1728
- logger.error({ file, sourceGroup, err }, 'Error processing IPC message');
1729
- const errorDir = path.join(ipcBaseDir, 'errors');
1730
- fs.mkdirSync(errorDir, { recursive: true });
1731
- fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1732
- }
1733
- }
1734
- }
1735
- }
1736
- catch (err) {
1737
- logger.error({ err, sourceGroup }, 'Error reading IPC messages directory');
1738
- }
1739
- // Process tasks from this group's IPC directory
1740
- try {
1741
- if (fs.existsSync(tasksDir)) {
1742
- const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
1743
- for (const file of taskFiles) {
1744
- const filePath = path.join(tasksDir, file);
1745
- try {
1746
- const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1747
- // Pass source group identity to processTaskIpc for authorization
1748
- await processTaskIpc(data, sourceGroup, isMain);
1749
- fs.unlinkSync(filePath);
1750
- }
1751
- catch (err) {
1752
- logger.error({ file, sourceGroup, err }, 'Error processing IPC task');
1753
- const errorDir = path.join(ipcBaseDir, 'errors');
1754
- fs.mkdirSync(errorDir, { recursive: true });
1755
- fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1756
- }
1757
- }
1758
- }
1759
- }
1760
- catch (err) {
1761
- logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
1762
- }
1763
- // Process request/response IPC for synchronous operations (memory, etc.)
1764
- try {
1765
- if (fs.existsSync(requestsDir)) {
1766
- fs.mkdirSync(responsesDir, { recursive: true });
1767
- const requestFiles = fs.readdirSync(requestsDir).filter(f => f.endsWith('.json'));
1768
- for (const file of requestFiles) {
1769
- const filePath = path.join(requestsDir, file);
1770
- try {
1771
- const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1772
- const response = await processRequestIpc(data, sourceGroup, isMain);
1773
- if (response?.id) {
1774
- const responsePath = path.join(responsesDir, `${response.id}.json`);
1775
- const tmpPath = responsePath + '.tmp';
1776
- fs.writeFileSync(tmpPath, JSON.stringify(response, null, 2));
1777
- fs.renameSync(tmpPath, responsePath);
1778
- }
1779
- fs.unlinkSync(filePath);
1780
- }
1781
- catch (err) {
1782
- logger.error({ file, sourceGroup, err }, 'Error processing IPC request');
1783
- const errorDir = path.join(ipcBaseDir, 'errors');
1784
- fs.mkdirSync(errorDir, { recursive: true });
1785
- fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1786
- }
1787
- }
1788
- }
1789
- }
1790
- catch (err) {
1791
- logger.error({ err, sourceGroup }, 'Error reading IPC requests directory');
1792
- }
1793
- }
1794
- } while (rerunRequested && !ipcStopped);
1795
- }
1796
- finally {
1797
- processing = false;
1798
- }
1799
- };
1800
- const scheduleProcess = () => {
1801
- if (ipcStopped)
1802
- return;
1803
- if (processing) {
1804
- rerunRequested = true;
1805
- return;
1806
- }
1807
- if (scheduled)
1808
- return;
1809
- scheduled = true;
1810
- setTimeout(async () => {
1811
- scheduled = false;
1812
- if (!ipcStopped)
1813
- await processIpcFiles();
1814
- }, 100);
1815
- };
1816
- let watcherActive = false;
1817
- try {
1818
- ipcWatcher = fs.watch(ipcBaseDir, { recursive: true }, () => {
1819
- scheduleProcess();
1820
- });
1821
- ipcWatcher.on('error', (err) => {
1822
- logger.warn({ err }, 'IPC watcher error; falling back to polling');
1823
- ipcWatcher?.close();
1824
- ipcWatcher = null;
1825
- if (!ipcPollingTimer && !ipcStopped) {
1826
- const poll = () => {
1827
- if (ipcStopped)
1828
- return;
1829
- scheduleProcess();
1830
- ipcPollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
1831
- };
1832
- poll();
1833
- }
1834
- });
1835
- watcherActive = true;
1836
- }
1837
- catch (err) {
1838
- logger.warn({ err }, 'IPC watch unsupported; falling back to polling');
1839
- }
1840
- if (!watcherActive) {
1841
- const poll = () => {
1842
- if (ipcStopped)
1843
- return;
1844
- scheduleProcess();
1845
- ipcPollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
1846
- };
1847
- poll();
1848
- }
1849
- else {
1850
- scheduleProcess();
1851
- }
1852
- if (ipcPollingTimer) {
1853
- logger.info('IPC watcher started (polling)');
1854
- }
1855
- else {
1856
- logger.info('IPC watcher started (fs.watch)');
1857
- }
1858
- }
1859
- async function runHeartbeatOnce() {
1860
- const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
1861
- if (!entry) {
1862
- logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
1863
- return;
1864
- }
1865
- const [chatId, group] = entry;
1866
- const prompt = [
1867
- '[HEARTBEAT]',
1868
- 'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
1869
- 'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
1870
- ].join('\n');
1871
- const traceBase = createTraceBase({
1872
- chatId,
1873
- groupFolder: group.folder,
1874
- userId: null,
1875
- inputText: prompt,
1876
- source: 'dotclaw-heartbeat'
1877
- });
1878
- const routingStartedAt = Date.now();
1879
- const routingDecision = routePrompt(prompt);
1880
- recordRoutingDecision(routingDecision.profile);
1881
- const routerMs = Date.now() - routingStartedAt;
1882
- recordStageLatency('router', routerMs, 'scheduler');
1883
- let output = null;
1884
- let context = null;
1885
- let errorMessage = null;
1886
- const baseRecallResults = Number.isFinite(routingDecision.recallMaxResults)
1887
- ? Math.max(0, Math.floor(routingDecision.recallMaxResults))
1888
- : MEMORY_RECALL_MAX_RESULTS;
1889
- const baseRecallTokens = Number.isFinite(routingDecision.recallMaxTokens)
1890
- ? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
1891
- : MEMORY_RECALL_MAX_TOKENS;
1892
- const recallMaxResults = routingDecision.enableMemoryRecall ? Math.max(4, baseRecallResults - 2) : 0;
1893
- const recallMaxTokens = routingDecision.enableMemoryRecall ? Math.max(600, baseRecallTokens - 200) : 0;
1894
- try {
1895
- const execution = await executeAgentRun({
1896
- group,
1897
- prompt,
1898
- chatJid: chatId,
1899
- userId: null,
1900
- recallQuery: prompt,
1901
- recallMaxResults,
1902
- recallMaxTokens,
1903
- sessionId: sessions[group.folder],
1904
- onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
1905
- isScheduledTask: true,
1906
- availableGroups: buildAvailableGroupsSnapshot(),
1907
- modelOverride: routingDecision.modelOverride,
1908
- modelMaxOutputTokens: routingDecision.maxOutputTokens,
1909
- maxToolSteps: routingDecision.maxToolSteps,
1910
- disablePlanner: !routingDecision.enablePlanner,
1911
- disableResponseValidation: !routingDecision.enableResponseValidation,
1912
- responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
1913
- disableMemoryExtraction: !routingDecision.enableMemoryExtraction
1914
- });
1915
- output = execution.output;
1916
- context = execution.context;
1917
- if (output.status === 'error') {
1918
- errorMessage = output.error || 'Unknown error';
1919
- }
1920
- }
1921
- catch (err) {
1922
- if (err instanceof AgentExecutionError) {
1923
- context = err.context;
1924
- errorMessage = err.message;
1925
- }
1926
- else {
1927
- errorMessage = err instanceof Error ? err.message : String(err);
1928
- }
1929
- logger.error({ err }, 'Heartbeat run failed');
1930
- }
1931
- if (context) {
1932
- recordAgentTelemetry({
1933
- traceBase,
1934
- output,
1935
- context,
1936
- toolAuditSource: 'heartbeat',
1937
- errorMessage: errorMessage ?? undefined,
1938
- extraTimings: { router_ms: routerMs }
1939
- });
1940
- }
1941
- else if (errorMessage) {
1942
- writeTrace({
1943
- trace_id: traceBase.trace_id,
1944
- timestamp: traceBase.timestamp,
1945
- created_at: traceBase.created_at,
1946
- chat_id: traceBase.chat_id,
1947
- group_folder: traceBase.group_folder,
1948
- user_id: traceBase.user_id,
1949
- input_text: traceBase.input_text,
1950
- output_text: null,
1951
- model_id: 'unknown',
1952
- memory_recall: [],
1953
- error_code: errorMessage,
1954
- source: traceBase.source
1955
- });
1956
- }
1957
- }
1958
- let heartbeatStopped = false;
1959
- function stopHeartbeatLoop() {
1960
- heartbeatStopped = true;
1961
- }
1962
- function startHeartbeatLoop() {
1963
- if (!HEARTBEAT_ENABLED)
1964
- return;
1965
- heartbeatStopped = false;
1966
- const loop = async () => {
1967
- if (heartbeatStopped)
1968
- return;
1969
- try {
1970
- await runHeartbeatOnce();
1971
- }
1972
- catch (err) {
1973
- logger.error({ err }, 'Heartbeat run failed');
1974
- }
1975
- if (!heartbeatStopped) {
1976
- setTimeout(loop, HEARTBEAT_INTERVAL_MS);
1977
- }
1978
- };
1979
- loop();
1980
- }
1981
- async function onWakeRecovery(sleepDurationMs) {
1982
- logger.info({ sleepDurationMs }, 'Running wake recovery');
1983
- // 1. Suppress daemon health check kills for 60s
1984
- suppressHealthChecks(60_000);
1985
- resetUnhealthyDaemons();
1986
- // 2. Reconnect Telegram
1987
- try {
1988
- setTelegramConnected(false);
1989
- telegrafBot.stop('WAKE');
1990
- await sleep(1_000);
1991
- telegrafBot.launch();
1992
- setTelegramConnected(true);
1993
- logger.info('Telegram reconnected after wake');
1994
- }
1995
- catch (err) {
1996
- logger.error({ err }, 'Failed to reconnect Telegram after wake');
1997
- }
1998
- // 3. Reset stalled messages (1s cutoff spares anything claimed post-wake)
1999
- try {
2000
- const resetCount = resetStalledMessages(1_000);
2001
- if (resetCount > 0)
2002
- logger.info({ resetCount }, 'Reset stalled messages after wake');
2003
- }
2004
- catch (err) {
2005
- logger.error({ err }, 'Failed to reset stalled messages after wake');
2006
- }
2007
- // 4. Reset stalled background jobs
2008
- try {
2009
- const resetJobCount = resetStalledBackgroundJobs();
2010
- if (resetJobCount > 0)
2011
- logger.info({ count: resetJobCount }, 'Re-queued stalled background jobs after wake');
2012
- }
2013
- catch (err) {
2014
- logger.error({ err }, 'Failed to reset stalled background jobs after wake');
2015
- }
2016
- // 5. Re-drain pending message queues
2017
- try {
2018
- const pendingChats = getChatsWithPendingMessages();
2019
- for (const chatId of pendingChats) {
2020
- if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
2021
- void drainQueue(chatId);
2022
- }
2023
- }
2024
- }
2025
- catch (err) {
2026
- logger.error({ err }, 'Failed to resume message drains after wake');
2027
- }
2028
- }
2029
- async function processTaskIpc(data, sourceGroup, isMain) {
2030
- const { CronExpressionParser } = await import('cron-parser');
2031
- switch (data.type) {
2032
- case 'schedule_task':
2033
- if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) {
2034
- // Authorization: non-main groups can only schedule for themselves
2035
- const targetGroup = data.groupFolder;
2036
- if (!isMain && targetGroup !== sourceGroup) {
2037
- logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked');
2038
- break;
2039
- }
2040
- // Resolve the correct chat ID for the target group (don't trust IPC payload)
2041
- const targetChatId = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup)?.[0];
2042
- if (!targetChatId) {
2043
- logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered');
2044
- break;
2045
- }
2046
- const scheduleType = data.schedule_type;
2047
- let taskTimezone = TIMEZONE;
2048
- if (typeof data.timezone === 'string' && data.timezone.trim()) {
2049
- const candidateTimezone = data.timezone.trim();
2050
- if (!isValidTimezone(candidateTimezone)) {
2051
- logger.warn({ timezone: data.timezone }, 'Invalid task timezone');
2052
- break;
2053
- }
2054
- taskTimezone = candidateTimezone;
2055
- }
2056
- let nextRun = null;
2057
- if (scheduleType === 'cron') {
2058
- try {
2059
- const interval = CronExpressionParser.parse(data.schedule_value, { tz: taskTimezone });
2060
- nextRun = interval.next().toISOString();
2061
- }
2062
- catch {
2063
- logger.warn({ scheduleValue: data.schedule_value, timezone: taskTimezone }, 'Invalid cron expression');
2064
- break;
2065
- }
2066
- }
2067
- else if (scheduleType === 'interval') {
2068
- const ms = parseInt(data.schedule_value, 10);
2069
- if (isNaN(ms) || ms <= 0) {
2070
- logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
2071
- break;
2072
- }
2073
- nextRun = new Date(Date.now() + ms).toISOString();
2074
- }
2075
- else if (scheduleType === 'once') {
2076
- const scheduled = parseScheduledTimestamp(data.schedule_value, taskTimezone);
2077
- if (!scheduled) {
2078
- logger.warn({ scheduleValue: data.schedule_value, timezone: taskTimezone }, 'Invalid timestamp');
2079
- break;
2080
- }
2081
- nextRun = scheduled.toISOString();
2082
- }
2083
- const taskId = generateId('task');
2084
- const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated')
2085
- ? data.context_mode
2086
- : 'isolated';
2087
- createTask({
2088
- id: taskId,
2089
- group_folder: targetGroup,
2090
- chat_jid: targetChatId,
2091
- prompt: data.prompt,
2092
- schedule_type: scheduleType,
2093
- schedule_value: data.schedule_value,
2094
- timezone: taskTimezone,
2095
- context_mode: contextMode,
2096
- next_run: nextRun,
2097
- status: 'active',
2098
- created_at: new Date().toISOString()
2099
- });
2100
- logger.info({ taskId, sourceGroup, targetGroup, contextMode, timezone: taskTimezone }, 'Task created via IPC');
2101
- }
2102
- break;
2103
- case 'pause_task':
2104
- if (data.taskId) {
2105
- const task = getTaskById(data.taskId);
2106
- if (task && (isMain || task.group_folder === sourceGroup)) {
2107
- updateTask(data.taskId, { status: 'paused' });
2108
- logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC');
2109
- }
2110
- else {
2111
- logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
2112
- }
2113
- }
2114
- break;
2115
- case 'resume_task':
2116
- if (data.taskId) {
2117
- const task = getTaskById(data.taskId);
2118
- if (task && (isMain || task.group_folder === sourceGroup)) {
2119
- updateTask(data.taskId, { status: 'active' });
2120
- logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC');
2121
- }
2122
- else {
2123
- logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
2124
- }
2125
- }
2126
- break;
2127
- case 'cancel_task':
2128
- if (data.taskId) {
2129
- const task = getTaskById(data.taskId);
2130
- if (task && (isMain || task.group_folder === sourceGroup)) {
2131
- deleteTask(data.taskId);
2132
- logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC');
2133
- }
2134
- else {
2135
- logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
2136
- }
2137
- }
2138
- break;
2139
- case 'update_task':
2140
- if (data.taskId) {
2141
- const task = getTaskById(data.taskId);
2142
- if (!task || (!isMain && task.group_folder !== sourceGroup)) {
2143
- logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt');
2144
- break;
2145
- }
2146
- const updates = {};
2147
- if (typeof data.prompt === 'string')
2148
- updates.prompt = data.prompt;
2149
- if (typeof data.context_mode === 'string')
2150
- updates.context_mode = data.context_mode;
2151
- if (typeof data.status === 'string')
2152
- updates.status = data.status;
2153
- if (typeof data.state_json === 'string')
2154
- updates.state_json = data.state_json;
2155
- if (typeof data.timezone === 'string') {
2156
- const timezoneValue = data.timezone.trim();
2157
- if (timezoneValue) {
2158
- if (!isValidTimezone(timezoneValue)) {
2159
- logger.warn({ timezone: data.timezone }, 'Invalid timezone for update_task');
2160
- break;
2161
- }
2162
- updates.timezone = timezoneValue;
2163
- }
2164
- else {
2165
- updates.timezone = normalizeTaskTimezone(task.timezone, TIMEZONE);
2166
- }
2167
- }
2168
- if (typeof data.schedule_type === 'string' && typeof data.schedule_value === 'string') {
2169
- updates.schedule_type = data.schedule_type;
2170
- updates.schedule_value = data.schedule_value;
2171
- const taskTimezone = updates.timezone || task.timezone || TIMEZONE;
2172
- let nextRun = null;
2173
- if (updates.schedule_type === 'cron') {
2174
- try {
2175
- const interval = CronExpressionParser.parse(updates.schedule_value, { tz: taskTimezone });
2176
- nextRun = interval.next().toISOString();
2177
- }
2178
- catch {
2179
- logger.warn({ scheduleValue: updates.schedule_value, timezone: taskTimezone }, 'Invalid cron expression for update_task');
2180
- }
2181
- }
2182
- else if (updates.schedule_type === 'interval') {
2183
- const ms = parseInt(updates.schedule_value, 10);
2184
- if (!isNaN(ms) && ms > 0) {
2185
- nextRun = new Date(Date.now() + ms).toISOString();
2186
- }
2187
- }
2188
- else if (updates.schedule_type === 'once') {
2189
- const scheduled = parseScheduledTimestamp(updates.schedule_value, taskTimezone);
2190
- if (scheduled) {
2191
- nextRun = scheduled.toISOString();
2192
- }
2193
- }
2194
- if (nextRun) {
2195
- updates.next_run = nextRun;
2196
- }
2197
- }
2198
- updateTask(data.taskId, updates);
2199
- logger.info({ taskId: data.taskId, sourceGroup }, 'Task updated via IPC');
2200
- }
2201
- break;
2202
- case 'register_group':
2203
- // Only main group can register new groups
2204
- if (!isMain) {
2205
- logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
2206
- break;
2207
- }
2208
- if (data.jid && data.name && data.folder) {
2209
- registerGroup(data.jid, {
2210
- name: data.name,
2211
- folder: data.folder,
2212
- trigger: data.trigger,
2213
- added_at: new Date().toISOString(),
2214
- containerConfig: data.containerConfig
2215
- });
2216
- }
2217
- else {
2218
- logger.warn({ data }, 'Invalid register_group request - missing required fields');
2219
- }
2220
- break;
2221
- case 'remove_group':
2222
- if (!isMain) {
2223
- logger.warn({ sourceGroup }, 'Unauthorized remove_group attempt blocked');
2224
- break;
2225
- }
2226
- if (!data.identifier || typeof data.identifier !== 'string') {
2227
- logger.warn({ data }, 'Invalid remove_group request - missing identifier');
2228
- break;
2229
- }
2230
- {
2231
- const result = unregisterGroup(data.identifier);
2232
- if (!result.ok) {
2233
- logger.warn({ identifier: data.identifier, error: result.error }, 'Failed to remove group');
2234
- }
2235
- }
2236
- break;
2237
- case 'set_model':
2238
- if (!isMain) {
2239
- logger.warn({ sourceGroup }, 'Unauthorized set_model attempt blocked');
2240
- break;
2241
- }
2242
- if (!data.model || typeof data.model !== 'string') {
2243
- logger.warn({ data }, 'Invalid set_model request - missing model');
2244
- break;
2245
- }
2246
- {
2247
- const defaultModel = runtime.host.defaultModel;
2248
- const config = loadModelRegistry(defaultModel);
2249
- const nextModel = data.model.trim();
2250
- if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
2251
- logger.warn({ model: nextModel }, 'Model not in allowlist; refusing set_model');
2252
- break;
2253
- }
2254
- const scope = typeof data.scope === 'string' ? data.scope : 'global';
2255
- const targetId = typeof data.target_id === 'string' ? data.target_id : undefined;
2256
- if (scope === 'user' && !targetId) {
2257
- logger.warn({ data }, 'set_model missing target_id for user scope');
2258
- break;
2259
- }
2260
- if (scope === 'group' && !targetId) {
2261
- logger.warn({ data }, 'set_model missing target_id for group scope');
2262
- break;
2263
- }
2264
- const nextConfig = { ...config };
2265
- if (scope === 'global') {
2266
- nextConfig.model = nextModel;
2267
- }
2268
- else if (scope === 'group') {
2269
- nextConfig.per_group = nextConfig.per_group || {};
2270
- nextConfig.per_group[targetId] = { model: nextModel };
2271
- }
2272
- else if (scope === 'user') {
2273
- nextConfig.per_user = nextConfig.per_user || {};
2274
- nextConfig.per_user[targetId] = { model: nextModel };
2275
- }
2276
- nextConfig.updated_at = new Date().toISOString();
2277
- saveModelRegistry(nextConfig);
2278
- logger.info({ model: nextModel, scope, targetId }, 'Model updated via IPC');
2279
- }
2280
- break;
2281
- default:
2282
- logger.warn({ type: data.type }, 'Unknown IPC task type');
2283
- }
2284
- }
2285
- async function processRequestIpc(data, sourceGroup, isMain) {
2286
- const requestId = typeof data.id === 'string' ? data.id : undefined;
2287
- const payload = data.payload || {};
2288
- const resolveGroupFolder = () => {
2289
- const target = typeof payload.target_group === 'string' ? payload.target_group : null;
2290
- if (target && isMain)
2291
- return target;
2292
- return sourceGroup;
2293
- };
2294
- try {
2295
- switch (data.type) {
2296
- case 'memory_upsert': {
2297
- const items = coerceMemoryItems(payload.items);
2298
- const groupFolder = resolveGroupFolder();
2299
- const source = typeof payload.source === 'string' ? payload.source : 'agent';
2300
- const results = upsertMemoryItems(groupFolder, items, source);
2301
- invalidatePersonalizationCache(groupFolder);
2302
- return { id: requestId, ok: true, result: { count: results.length } };
2303
- }
2304
- case 'memory_forget': {
2305
- const groupFolder = resolveGroupFolder();
2306
- const ids = Array.isArray(payload.ids) ? payload.ids : undefined;
2307
- const content = typeof payload.content === 'string' ? payload.content : undefined;
2308
- const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
2309
- const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
2310
- const count = forgetMemories({
2311
- groupFolder,
2312
- ids,
2313
- content,
2314
- scope,
2315
- userId
2316
- });
2317
- invalidatePersonalizationCache(groupFolder);
2318
- return { id: requestId, ok: true, result: { count } };
2319
- }
2320
- case 'memory_list': {
2321
- const groupFolder = resolveGroupFolder();
2322
- const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
2323
- const type = isMemoryType(payload.type) ? payload.type : undefined;
2324
- const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
2325
- const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
2326
- const items = listMemories({
2327
- groupFolder,
2328
- scope,
2329
- type,
2330
- userId,
2331
- limit
2332
- });
2333
- return { id: requestId, ok: true, result: { items } };
2334
- }
2335
- case 'memory_search': {
2336
- const groupFolder = resolveGroupFolder();
2337
- const query = typeof payload.query === 'string' ? payload.query : '';
2338
- const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
2339
- const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
2340
- const results = searchMemories({
2341
- groupFolder,
2342
- userId,
2343
- query,
2344
- limit
2345
- });
2346
- return { id: requestId, ok: true, result: { items: results } };
2347
- }
2348
- case 'memory_stats': {
2349
- const groupFolder = resolveGroupFolder();
2350
- const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
2351
- const stats = getMemoryStats({ groupFolder, userId });
2352
- return { id: requestId, ok: true, result: { stats } };
2353
- }
2354
- case 'list_groups': {
2355
- if (!isMain) {
2356
- return { id: requestId, ok: false, error: 'Only the main group can list groups.' };
2357
- }
2358
- const groups = listRegisteredGroups();
2359
- return { id: requestId, ok: true, result: { groups } };
2360
- }
2361
- case 'run_task': {
2362
- const taskId = typeof payload.task_id === 'string' ? payload.task_id : '';
2363
- if (!taskId) {
2364
- return { id: requestId, ok: false, error: 'task_id is required.' };
2365
- }
2366
- const task = getTaskById(taskId);
2367
- if (!task) {
2368
- return { id: requestId, ok: false, error: 'Task not found.' };
2369
- }
2370
- if (!isMain && task.group_folder !== sourceGroup) {
2371
- return { id: requestId, ok: false, error: 'Unauthorized task run attempt.' };
2372
- }
2373
- const result = await runTaskNow(taskId, {
2374
- sendMessage: async (jid, text) => { await sendMessage(jid, text); },
2375
- registeredGroups: () => registeredGroups,
2376
- getSessions: () => sessions,
2377
- setSession: (groupFolder, sessionId) => { sessions[groupFolder] = sessionId; }
2378
- });
2379
- return {
2380
- id: requestId,
2381
- ok: result.ok,
2382
- result: { result: result.result ?? null },
2383
- error: result.ok ? undefined : result.error
2384
- };
2385
- }
2386
- case 'spawn_job': {
2387
- const prompt = typeof payload.prompt === 'string' ? payload.prompt.trim() : '';
2388
- if (!prompt) {
2389
- return { id: requestId, ok: false, error: 'prompt is required.' };
2390
- }
2391
- const targetGroup = (typeof payload.target_group === 'string' && isMain)
2392
- ? payload.target_group
2393
- : sourceGroup;
2394
- const groupEntry = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup);
2395
- if (!groupEntry) {
2396
- return { id: requestId, ok: false, error: 'Target group not registered.' };
2397
- }
2398
- const [chatId, group] = groupEntry;
2399
- const result = spawnBackgroundJob({
2400
- prompt,
2401
- groupFolder: group.folder,
2402
- chatJid: chatId,
2403
- contextMode: (payload.context_mode === 'group' || payload.context_mode === 'isolated')
2404
- ? payload.context_mode
2405
- : undefined,
2406
- timeoutMs: typeof payload.timeout_ms === 'number' ? payload.timeout_ms : undefined,
2407
- maxToolSteps: typeof payload.max_tool_steps === 'number' ? payload.max_tool_steps : undefined,
2408
- toolAllow: Array.isArray(payload.tool_allow) ? payload.tool_allow : undefined,
2409
- toolDeny: Array.isArray(payload.tool_deny) ? payload.tool_deny : undefined,
2410
- modelOverride: typeof payload.model_override === 'string' ? payload.model_override : undefined,
2411
- priority: typeof payload.priority === 'number' ? payload.priority : undefined,
2412
- tags: Array.isArray(payload.tags) ? payload.tags : undefined,
2413
- parentTraceId: typeof payload.parent_trace_id === 'string' ? payload.parent_trace_id : undefined,
2414
- parentMessageId: typeof payload.parent_message_id === 'string' ? payload.parent_message_id : undefined
2415
- });
2416
- return {
2417
- id: requestId,
2418
- ok: result.ok,
2419
- result: result.ok ? { job_id: result.jobId } : undefined,
2420
- error: result.ok ? undefined : result.error
2421
- };
2422
- }
2423
- case 'job_status': {
2424
- const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
2425
- if (!jobId) {
2426
- return { id: requestId, ok: false, error: 'job_id is required.' };
2427
- }
2428
- const job = getBackgroundJobStatus(jobId);
2429
- if (!job) {
2430
- return { id: requestId, ok: false, error: 'Job not found.' };
2431
- }
2432
- if (!isMain && job.group_folder !== sourceGroup) {
2433
- return { id: requestId, ok: false, error: 'Unauthorized job status request.' };
2434
- }
2435
- return { id: requestId, ok: true, result: { job } };
2436
- }
2437
- case 'list_jobs': {
2438
- const targetGroup = (typeof payload.target_group === 'string' && isMain)
2439
- ? payload.target_group
2440
- : sourceGroup;
2441
- const statusRaw = typeof payload.status === 'string' ? payload.status : undefined;
2442
- const allowedStatuses = ['queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'];
2443
- const status = statusRaw && allowedStatuses.includes(statusRaw)
2444
- ? statusRaw
2445
- : undefined;
2446
- const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
2447
- const jobs = listBackgroundJobsForGroup({ groupFolder: targetGroup, status, limit });
2448
- return { id: requestId, ok: true, result: { jobs } };
2449
- }
2450
- case 'cancel_job': {
2451
- const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
2452
- if (!jobId) {
2453
- return { id: requestId, ok: false, error: 'job_id is required.' };
2454
- }
2455
- const job = getBackgroundJobStatus(jobId);
2456
- if (!job) {
2457
- return { id: requestId, ok: false, error: 'Job not found.' };
2458
- }
2459
- if (!isMain && job.group_folder !== sourceGroup) {
2460
- return { id: requestId, ok: false, error: 'Unauthorized job cancel attempt.' };
2461
- }
2462
- const result = cancelBackgroundJob(jobId);
2463
- return { id: requestId, ok: result.ok, error: result.error };
2464
- }
2465
- case 'job_update': {
2466
- const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
2467
- const message = typeof payload.message === 'string' ? payload.message.trim() : '';
2468
- const levelRaw = typeof payload.level === 'string' ? payload.level : 'progress';
2469
- const allowedLevels = ['info', 'progress', 'warn', 'error'];
2470
- const level = allowedLevels.includes(levelRaw)
2471
- ? levelRaw
2472
- : 'progress';
2473
- if (!jobId || !message) {
2474
- return { id: requestId, ok: false, error: 'job_id and message are required.' };
2475
- }
2476
- const job = getBackgroundJobStatus(jobId);
2477
- if (!job) {
2478
- return { id: requestId, ok: false, error: 'Job not found.' };
2479
- }
2480
- if (!isMain && job.group_folder !== sourceGroup) {
2481
- return { id: requestId, ok: false, error: 'Unauthorized job update attempt.' };
2482
- }
2483
- const result = recordBackgroundJobUpdate({
2484
- jobId,
2485
- level,
2486
- message,
2487
- data: typeof payload.data === 'object' && payload.data ? payload.data : undefined
2488
- });
2489
- if (result.ok && payload.notify === true && job.chat_jid) {
2490
- const nowMs = Date.now();
2491
- const previous = lastJobUpdateNotifications.get(job.id);
2492
- const isDuplicate = previous !== undefined
2493
- && previous.message === message
2494
- && (nowMs - previous.at) < JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS;
2495
- if (!isDuplicate) {
2496
- const notifyResult = await sendMessage(job.chat_jid, message);
2497
- if (!notifyResult.success) {
2498
- return { id: requestId, ok: false, error: 'Background job update saved, but notification delivery failed.' };
2499
- }
2500
- lastJobUpdateNotifications.set(job.id, { message, at: nowMs });
2501
- }
2502
- }
2503
- return { id: requestId, ok: result.ok, error: result.error };
2504
- }
2505
- case 'edit_message': {
2506
- const messageId = typeof payload.message_id === 'number' ? payload.message_id : parseInt(String(payload.message_id), 10);
2507
- const text = typeof payload.text === 'string' ? payload.text.trim() : '';
2508
- const chatJid = typeof payload.chat_jid === 'string' ? payload.chat_jid : '';
2509
- if (!Number.isFinite(messageId) || !text || !chatJid) {
2510
- return { id: requestId, ok: false, error: 'message_id, text, and chat_jid are required.' };
2511
- }
2512
- const group = Object.entries(registeredGroups).find(([id]) => id === chatJid);
2513
- if (!group) {
2514
- return { id: requestId, ok: false, error: 'Chat not registered.' };
2515
- }
2516
- if (!isMain && group[1].folder !== sourceGroup) {
2517
- return { id: requestId, ok: false, error: 'Unauthorized edit_message attempt.' };
2518
- }
2519
- await telegrafBot.telegram.editMessageText(chatJid, messageId, undefined, text);
2520
- return { id: requestId, ok: true, result: { edited: true } };
2521
- }
2522
- case 'delete_message': {
2523
- const messageId = typeof payload.message_id === 'number' ? payload.message_id : parseInt(String(payload.message_id), 10);
2524
- const chatJid = typeof payload.chat_jid === 'string' ? payload.chat_jid : '';
2525
- if (!Number.isFinite(messageId) || !chatJid) {
2526
- return { id: requestId, ok: false, error: 'message_id and chat_jid are required.' };
2527
- }
2528
- const group = Object.entries(registeredGroups).find(([id]) => id === chatJid);
2529
- if (!group) {
2530
- return { id: requestId, ok: false, error: 'Chat not registered.' };
2531
- }
2532
- if (!isMain && group[1].folder !== sourceGroup) {
2533
- return { id: requestId, ok: false, error: 'Unauthorized delete_message attempt.' };
2534
- }
2535
- await telegrafBot.telegram.deleteMessage(chatJid, messageId);
2536
- return { id: requestId, ok: true, result: { deleted: true } };
2537
- }
2538
- default:
2539
- return { id: requestId, ok: false, error: `Unknown request type: ${data.type}` };
2540
- }
2541
- }
2542
- catch (err) {
2543
- return { id: requestId, ok: false, error: err instanceof Error ? err.message : String(err) };
2544
- }
2545
- }
2546
- function formatGroups(groups) {
2547
- if (groups.length === 0)
2548
- return 'No registered groups.';
2549
- const lines = groups.map(group => {
2550
- const trigger = group.trigger ? ` (trigger: ${group.trigger})` : '';
2551
- return `- ${group.name} [${group.folder}] chat=${group.chat_id}${trigger}`;
2552
- });
2553
- return ['Registered groups:', ...lines].join('\n');
2554
- }
2555
- function applyModelOverride(params) {
2556
- const defaultModel = runtime.host.defaultModel;
2557
- const config = loadModelRegistry(defaultModel);
2558
- const nextModel = params.model.trim();
2559
- if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
2560
- return { ok: false, error: 'Model not in allowlist' };
2561
- }
2562
- const scope = params.scope || 'global';
2563
- const targetId = params.targetId;
2564
- if (scope === 'user' && !targetId) {
2565
- return { ok: false, error: 'Missing target_id for user scope' };
2566
- }
2567
- if (scope === 'group' && !targetId) {
2568
- return { ok: false, error: 'Missing target_id for group scope' };
2569
- }
2570
- const nextConfig = { ...config };
2571
- if (scope === 'global') {
2572
- nextConfig.model = nextModel;
2573
- }
2574
- else if (scope === 'group') {
2575
- nextConfig.per_group = nextConfig.per_group || {};
2576
- nextConfig.per_group[targetId] = { model: nextModel };
2577
- }
2578
- else if (scope === 'user') {
2579
- nextConfig.per_user = nextConfig.per_user || {};
2580
- nextConfig.per_user[targetId] = { model: nextModel };
2581
- }
2582
- nextConfig.updated_at = new Date().toISOString();
2583
- saveModelRegistry(nextConfig);
2584
- return { ok: true };
2585
- }
2586
- async function handleAdminCommand(params) {
2587
- const parsed = parseAdminCommand(params.content, params.botUsername);
2588
- if (!parsed)
2589
- return false;
2590
- const reply = (text) => sendMessage(params.chatId, text, { messageThreadId: params.messageThreadId });
2591
- const group = registeredGroups[params.chatId];
2592
- if (!group) {
2593
- await reply('This chat is not registered with DotClaw.');
2594
- return true;
2595
- }
2596
- const isMain = group.folder === MAIN_GROUP_FOLDER;
2597
- const command = parsed.command;
2598
- const args = parsed.args;
2599
- const requireMain = (name) => {
2600
- if (isMain)
2601
- return false;
2602
- reply(`${name} is only available in the main group.`).catch(() => undefined);
2603
- return true;
2604
- };
2605
- if (command === 'help') {
2606
- await reply([
2607
- 'DotClaw admin commands:',
2608
- '- `/dotclaw help`',
2609
- '- `/dotclaw groups` (main only)',
2610
- '- `/dotclaw add-group <chat_id> <name> [folder]` (main only)',
2611
- '- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
2612
- '- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
2613
- '- `/dotclaw remember <fact>` (main only)',
2614
- '- `/dotclaw style <concise|balanced|detailed>`',
2615
- '- `/dotclaw tools <conservative|balanced|proactive>`',
2616
- '- `/dotclaw caution <low|balanced|high>`',
2617
- '- `/dotclaw memory <strict|balanced|loose>`'
2618
- ].join('\n'));
2619
- return true;
2620
- }
2621
- if (command === 'groups') {
2622
- if (requireMain('Listing groups'))
2623
- return true;
2624
- await reply(formatGroups(listRegisteredGroups()));
2625
- return true;
2626
- }
2627
- if (command === 'add-group') {
2628
- if (requireMain('Adding groups'))
2629
- return true;
2630
- if (args.length < 1) {
2631
- await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
2632
- return true;
2633
- }
2634
- const jid = args[0];
2635
- if (registeredGroups[jid]) {
2636
- await reply('That chat id is already registered.');
2637
- return true;
2638
- }
2639
- const name = args[1] || `group-${jid}`;
2640
- const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2641
- if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
2642
- await reply('Invalid folder name. Use lowercase letters, numbers, and dashes only.');
2643
- return true;
2644
- }
2645
- registerGroup(jid, {
2646
- name,
2647
- folder: folder || `group-${jid}`,
2648
- added_at: new Date().toISOString()
2649
- });
2650
- await reply(`Registered group "${name}" with folder "${folder}".`);
2651
- return true;
2652
- }
2653
- if (command === 'remove-group') {
2654
- if (requireMain('Removing groups'))
2655
- return true;
2656
- if (args.length < 1) {
2657
- await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
2658
- return true;
2659
- }
2660
- const result = unregisterGroup(args.join(' '));
2661
- if (!result.ok) {
2662
- await reply(`Failed to remove group: ${result.error || 'unknown error'}`);
2663
- return true;
2664
- }
2665
- await reply(`Removed group "${result.group?.name}" (${result.group?.folder}).`);
2666
- return true;
2667
- }
2668
- if (command === 'set-model') {
2669
- if (requireMain('Setting models'))
2670
- return true;
2671
- if (args.length < 1) {
2672
- await reply('Usage: /dotclaw set-model <model> [global|group|user] [target_id]');
2673
- return true;
2674
- }
2675
- const model = args[0];
2676
- const scopeCandidate = (args[1] || '').toLowerCase();
2677
- const scope = (scopeCandidate === 'global' || scopeCandidate === 'group' || scopeCandidate === 'user')
2678
- ? scopeCandidate
2679
- : 'global';
2680
- const targetId = args[2] || (scope === 'group' ? group.folder : scope === 'user' ? params.senderId : undefined);
2681
- const result = applyModelOverride({ model, scope, targetId });
2682
- if (!result.ok) {
2683
- await reply(`Failed to set model: ${result.error || 'unknown error'}`);
2684
- return true;
2685
- }
2686
- await reply(`Model set to ${model} (${scope}${targetId ? `:${targetId}` : ''}).`);
2687
- return true;
2688
- }
2689
- if (command === 'remember') {
2690
- if (requireMain('Remembering facts'))
2691
- return true;
2692
- const fact = args.join(' ').trim();
2693
- if (!fact) {
2694
- await reply('Usage: /dotclaw remember <fact>');
2695
- return true;
639
+ responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
640
+ disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
641
+ profile: routingDecision.profile
642
+ });
643
+ output = execution.output;
644
+ context = execution.context;
645
+ if (output.status === 'error') {
646
+ errorMessage = output.error || 'Unknown error';
2696
647
  }
2697
- const items = [{
2698
- scope: 'global',
2699
- type: 'fact',
2700
- content: fact,
2701
- importance: 0.7,
2702
- confidence: 0.8,
2703
- tags: ['manual']
2704
- }];
2705
- upsertMemoryItems('global', items, 'admin-command');
2706
- await reply('Saved to global memory.');
2707
- return true;
2708
648
  }
2709
- if (command === 'style') {
2710
- const style = (args[0] || '').toLowerCase();
2711
- if (!['concise', 'balanced', 'detailed'].includes(style)) {
2712
- await reply('Usage: /dotclaw style <concise|balanced|detailed>');
2713
- return true;
649
+ catch (err) {
650
+ if (err instanceof AgentExecutionError) {
651
+ context = err.context;
652
+ errorMessage = err.message;
2714
653
  }
2715
- const items = [{
2716
- scope: 'user',
2717
- subject_id: params.senderId,
2718
- type: 'preference',
2719
- conflict_key: 'response_style',
2720
- content: `Prefers ${style} responses.`,
2721
- importance: 0.7,
2722
- confidence: 0.85,
2723
- tags: [`response_style:${style}`],
2724
- metadata: { response_style: style }
2725
- }];
2726
- upsertMemoryItems(group.folder, items, 'admin-command');
2727
- await reply(`Response style set to ${style}.`);
2728
- return true;
2729
- }
2730
- if (command === 'tools') {
2731
- const level = (args[0] || '').toLowerCase();
2732
- const bias = level === 'proactive' ? 0.7 : level === 'balanced' ? 0.5 : level === 'conservative' ? 0.3 : null;
2733
- if (bias === null) {
2734
- await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
2735
- return true;
654
+ else {
655
+ errorMessage = err instanceof Error ? err.message : String(err);
2736
656
  }
2737
- const items = [{
2738
- scope: 'user',
2739
- subject_id: params.senderId,
2740
- type: 'preference',
2741
- conflict_key: 'tool_calling_bias',
2742
- content: `Prefers ${level} tool usage.`,
2743
- importance: 0.65,
2744
- confidence: 0.8,
2745
- tags: [`tool_calling_bias:${bias}`],
2746
- metadata: { tool_calling_bias: bias, bias }
2747
- }];
2748
- upsertMemoryItems(group.folder, items, 'admin-command');
2749
- await reply(`Tool usage bias set to ${level}.`);
2750
- return true;
657
+ logger.error({ err }, 'Heartbeat run failed');
2751
658
  }
2752
- if (command === 'caution') {
2753
- const level = (args[0] || '').toLowerCase();
2754
- const bias = level === 'high' ? 0.7 : level === 'balanced' ? 0.5 : level === 'low' ? 0.35 : null;
2755
- if (bias === null) {
2756
- await reply('Usage: /dotclaw caution <low|balanced|high>');
2757
- return true;
2758
- }
2759
- const items = [{
2760
- scope: 'user',
2761
- subject_id: params.senderId,
2762
- type: 'preference',
2763
- conflict_key: 'caution_bias',
2764
- content: `Prefers ${level} caution in responses.`,
2765
- importance: 0.65,
2766
- confidence: 0.8,
2767
- tags: [`caution_bias:${bias}`],
2768
- metadata: { caution_bias: bias, bias }
2769
- }];
2770
- upsertMemoryItems(group.folder, items, 'admin-command');
2771
- await reply(`Caution bias set to ${level}.`);
2772
- return true;
659
+ if (context) {
660
+ recordAgentTelemetry({
661
+ traceBase,
662
+ output,
663
+ context,
664
+ toolAuditSource: 'heartbeat',
665
+ errorMessage: errorMessage ?? undefined,
666
+ extraTimings: { router_ms: routerMs }
667
+ });
2773
668
  }
2774
- if (command === 'memory') {
2775
- const level = (args[0] || '').toLowerCase();
2776
- const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
2777
- if (threshold === null) {
2778
- await reply('Usage: /dotclaw memory <strict|balanced|loose>');
2779
- return true;
2780
- }
2781
- const items = [{
2782
- scope: 'user',
2783
- subject_id: params.senderId,
2784
- type: 'preference',
2785
- conflict_key: 'memory_importance_threshold',
2786
- content: `Prefers memory strictness ${level}.`,
2787
- importance: 0.6,
2788
- confidence: 0.8,
2789
- tags: [`memory_importance_threshold:${threshold}`],
2790
- metadata: { memory_importance_threshold: threshold, threshold }
2791
- }];
2792
- upsertMemoryItems(group.folder, items, 'admin-command');
2793
- await reply(`Memory strictness set to ${level}.`);
2794
- return true;
669
+ else if (errorMessage) {
670
+ writeTrace({
671
+ trace_id: traceBase.trace_id,
672
+ timestamp: traceBase.timestamp,
673
+ created_at: traceBase.created_at,
674
+ chat_id: traceBase.chat_id,
675
+ group_folder: traceBase.group_folder,
676
+ user_id: traceBase.user_id,
677
+ input_text: traceBase.input_text,
678
+ output_text: null,
679
+ model_id: 'unknown',
680
+ memory_recall: [],
681
+ error_code: errorMessage,
682
+ source: traceBase.source
683
+ });
2795
684
  }
2796
- await reply('Unknown command. Use `/dotclaw help` for options.');
2797
- return true;
2798
685
  }
2799
- function setupTelegramHandlers() {
2800
- // Handle message reactions (👍/👎 for feedback)
2801
- telegrafBot.on('message_reaction', async (ctx) => {
686
+ let heartbeatStopped = false;
687
+ function stopHeartbeatLoop() {
688
+ heartbeatStopped = true;
689
+ }
690
+ function startHeartbeatLoop() {
691
+ if (!HEARTBEAT_ENABLED)
692
+ return;
693
+ heartbeatStopped = false;
694
+ const loop = async () => {
695
+ if (heartbeatStopped)
696
+ return;
2802
697
  try {
2803
- const update = ctx.update;
2804
- const reaction = update.message_reaction;
2805
- if (!reaction)
2806
- return;
2807
- const emoji = reaction.new_reaction?.[0]?.emoji;
2808
- if (!emoji || (emoji !== '👍' && emoji !== '👎'))
698
+ await runHeartbeatOnce();
699
+ }
700
+ catch (err) {
701
+ logger.error({ err }, 'Heartbeat run failed');
702
+ }
703
+ if (!heartbeatStopped) {
704
+ setTimeout(loop, HEARTBEAT_INTERVAL_MS);
705
+ }
706
+ };
707
+ loop();
708
+ }
709
+ // ───────────────────────── Provider Event Handlers ─────────────────────────
710
+ function createProviderHandlers(registry, pipeline) {
711
+ return {
712
+ onMessage(incoming) {
713
+ const chatId = incoming.chatId;
714
+ const group = registeredGroups[chatId];
715
+ const groupFolder = group?.folder;
716
+ // Log & persist
717
+ const chatName = incoming.rawProviderData?.chatName || incoming.senderName;
718
+ try {
719
+ upsertChat({ chatId, name: chatName, lastMessageTime: incoming.timestamp });
720
+ const dbAttachments = incoming.attachments?.map(providerAttachmentToMessageAttachment);
721
+ storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, dbAttachments);
722
+ }
723
+ catch (error) {
724
+ logger.error({ error, chatId }, 'Failed to persist message');
725
+ }
726
+ setLastMessageTime(new Date().toISOString());
727
+ recordMessage(ProviderRegistry.getPrefix(chatId));
728
+ // Admin commands (async, fire-and-forget with early return)
729
+ const providerName = ProviderRegistry.getPrefix(chatId);
730
+ const provider = registry.get(providerName);
731
+ const botUsername = provider && 'botUsername' in provider ? provider.botUsername : undefined;
732
+ void (async () => {
733
+ try {
734
+ if (incoming.content) {
735
+ const sendReply = async (cId, text, opts) => {
736
+ await registry.getProviderForChat(cId).sendMessage(cId, text, { threadId: opts?.threadId });
737
+ };
738
+ const adminHandled = await handleAdminCommand({
739
+ chatId,
740
+ senderId: incoming.senderId,
741
+ senderName: incoming.senderName,
742
+ content: incoming.content,
743
+ botUsername,
744
+ threadId: incoming.threadId,
745
+ }, sendReply);
746
+ if (adminHandled)
747
+ return;
748
+ }
749
+ // Check trigger/mention/reply
750
+ const isPrivate = incoming.chatType === 'private' || incoming.chatType === 'dm';
751
+ const isGroup = incoming.isGroup;
752
+ const mentioned = provider ? provider.isBotMentioned(incoming) : false;
753
+ const replied = provider ? provider.isBotReplied(incoming) : false;
754
+ const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
755
+ const triggered = Boolean(triggerRegex && incoming.content && triggerRegex.test(incoming.content));
756
+ const shouldProcess = isPrivate || mentioned || replied || triggered;
757
+ if (!shouldProcess)
758
+ return;
759
+ // Rate limiting — qualify key by provider to avoid cross-provider collisions
760
+ const rateKey = `${ProviderRegistry.getPrefix(chatId)}:${incoming.senderId}`;
761
+ const rateCheck = checkRateLimit(rateKey);
762
+ if (!rateCheck.allowed) {
763
+ const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
764
+ logger.warn({ senderId: incoming.senderId, retryAfterSec }, 'Rate limit exceeded');
765
+ await registry.getProviderForChat(chatId).sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { threadId: incoming.threadId });
766
+ return;
767
+ }
768
+ // Download attachments
769
+ const attachments = incoming.attachments?.map(providerAttachmentToMessageAttachment) ?? [];
770
+ if (attachments.length > 0 && groupFolder) {
771
+ let downloadedAny = false;
772
+ const failedAttachments = [];
773
+ for (const attachment of attachments) {
774
+ const fileRef = attachment.provider_file_ref;
775
+ if (!fileRef)
776
+ continue;
777
+ const filename = attachment.file_name || `${attachment.type}_${incoming.messageId}`;
778
+ const result = await provider.downloadFile(fileRef, groupFolder, filename);
779
+ if (result.path) {
780
+ attachment.local_path = result.path;
781
+ downloadedAny = true;
782
+ }
783
+ else if (result.error) {
784
+ failedAttachments.push({ name: attachment.file_name || attachment.type, error: result.error });
785
+ }
786
+ }
787
+ if (failedAttachments.length > 0) {
788
+ const maxMB = Math.floor(provider.capabilities.maxAttachmentBytes / (1024 * 1024));
789
+ const messages = failedAttachments.map(f => f.error === 'too_large'
790
+ ? `"${f.name}" is too large (over ${maxMB} MB). Try sending a smaller version.`
791
+ : `I couldn't download "${f.name}". Please try sending it again.`);
792
+ void registry.getProviderForChat(chatId).sendMessage(chatId, messages.join('\n'), { threadId: incoming.threadId });
793
+ }
794
+ // Transcribe voice messages
795
+ for (const attachment of attachments) {
796
+ if (attachment.type === 'voice' && attachment.local_path) {
797
+ try {
798
+ const transcript = await transcribeVoice(attachment.local_path);
799
+ if (transcript) {
800
+ attachment.transcript = transcript;
801
+ }
802
+ }
803
+ catch (err) {
804
+ logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Voice transcription failed');
805
+ }
806
+ }
807
+ }
808
+ if (downloadedAny) {
809
+ try {
810
+ storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, attachments);
811
+ }
812
+ catch (error) {
813
+ logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
814
+ }
815
+ }
816
+ }
817
+ void emitHook('message:received', {
818
+ chat_id: chatId,
819
+ message_id: incoming.messageId,
820
+ sender_id: incoming.senderId,
821
+ sender_name: incoming.senderName,
822
+ content: incoming.content.slice(0, 500),
823
+ is_group: isGroup,
824
+ has_attachments: attachments.length > 0,
825
+ has_transcript: attachments.some(a => !!a.transcript)
826
+ });
827
+ pipeline.enqueueMessage({
828
+ chatId,
829
+ messageId: incoming.messageId,
830
+ senderId: incoming.senderId,
831
+ senderName: incoming.senderName,
832
+ content: incoming.content,
833
+ timestamp: incoming.timestamp,
834
+ isGroup,
835
+ chatType: incoming.chatType,
836
+ threadId: incoming.threadId,
837
+ attachments: attachments.length > 0 ? attachments : undefined
838
+ });
839
+ }
840
+ catch (err) {
841
+ logger.error({ err, chatId }, 'Error processing incoming message');
842
+ }
843
+ })();
844
+ },
845
+ onReaction(chatId, messageId, userId, emoji) {
846
+ if (emoji !== '👍' && emoji !== '👎')
2809
847
  return;
2810
- const chatId = String(reaction.chat.id);
2811
- const messageId = String(reaction.message_id);
2812
- const userId = reaction.user?.id ? String(reaction.user.id) : undefined;
2813
- // Look up the trace ID for this message
2814
848
  const traceId = getTraceIdForMessage(messageId, chatId);
2815
849
  if (!traceId) {
2816
850
  logger.debug({ chatId, messageId }, 'No trace found for reacted message');
2817
851
  return;
2818
852
  }
2819
- // Record the feedback
2820
853
  const feedbackType = emoji === '👍' ? 'positive' : 'negative';
2821
854
  recordUserFeedback({
2822
855
  trace_id: traceId,
@@ -2826,48 +859,19 @@ function setupTelegramHandlers() {
2826
859
  user_id: userId
2827
860
  });
2828
861
  logger.info({ chatId, messageId, feedbackType, traceId }, 'User feedback recorded');
2829
- }
2830
- catch (err) {
2831
- logger.debug({ err }, 'Error handling message reaction');
2832
- }
2833
- });
2834
- // Handle callback queries from inline keyboard buttons
2835
- telegrafBot.on('callback_query', async (ctx) => {
2836
- try {
2837
- const cbQuery = ctx.callbackQuery;
2838
- if (!cbQuery || !('data' in cbQuery) || !cbQuery.data)
2839
- return;
2840
- const callbackId = cbQuery.data;
2841
- const entry = callbackDataStore.get(callbackId);
2842
- await ctx.answerCbQuery();
2843
- if (!entry) {
2844
- logger.debug({ callbackId }, 'Unknown callback data');
2845
- return;
2846
- }
2847
- callbackDataStore.delete(callbackId);
2848
- const callbackChatId = ctx.chat?.id ? String(ctx.chat.id) : '';
2849
- if (callbackChatId && callbackChatId !== entry.chatJid) {
2850
- logger.warn({ callbackChatId, expectedChatId: entry.chatJid }, 'Callback chat mismatch; ignoring callback');
2851
- return;
2852
- }
2853
- const chatId = callbackChatId || entry.chatJid;
2854
- const senderId = String(cbQuery.from?.id || '');
2855
- const senderName = cbQuery.from?.first_name || cbQuery.from?.username || 'User';
2856
- const timestamp = new Date().toISOString();
2857
- const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
862
+ },
863
+ onButtonClick(chatId, senderId, senderName, label, data, threadId) {
2858
864
  const group = registeredGroups[chatId];
2859
865
  if (!group)
2860
866
  return;
2861
- const chatType = ctx.chat?.type || 'private';
2862
- const isGroup = chatType === 'group' || chatType === 'supergroup';
2863
- const rawThreadId = typeof cbQuery.message === 'object' && cbQuery.message && 'message_thread_id' in cbQuery.message
2864
- ? cbQuery.message.message_thread_id
2865
- : undefined;
2866
- const messageThreadId = Number.isFinite(rawThreadId) ? Number(rawThreadId) : undefined;
2867
- const syntheticContent = `[Button clicked: "${entry.label}"] callback_data: ${entry.data}`;
867
+ const chatType = 'private'; // Best guess for callback queries
868
+ const isGroup = false;
869
+ const timestamp = new Date().toISOString();
870
+ const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
871
+ const syntheticContent = `[Button clicked: "${label}"] callback_data: ${data}`;
2868
872
  upsertChat({ chatId, lastMessageTime: timestamp });
2869
873
  storeMessage(syntheticMessageId, chatId, senderId, senderName, syntheticContent, timestamp, false);
2870
- enqueueMessage({
874
+ pipeline.enqueueMessage({
2871
875
  chatId,
2872
876
  messageId: syntheticMessageId,
2873
877
  senderId,
@@ -2876,199 +880,72 @@ function setupTelegramHandlers() {
2876
880
  timestamp,
2877
881
  isGroup,
2878
882
  chatType,
2879
- messageThreadId
2880
- });
2881
- }
2882
- catch (err) {
2883
- logger.debug({ err }, 'Error handling callback query');
2884
- }
2885
- });
2886
- // Handle all messages (text + media)
2887
- telegrafBot.on('message', async (ctx) => {
2888
- if (!ctx.message)
2889
- return;
2890
- const msg = ctx.message;
2891
- // Extract content from text or caption
2892
- const content = (typeof msg.text === 'string' ? msg.text : '')
2893
- || (typeof msg.caption === 'string' ? msg.caption : '');
2894
- // Build attachment metadata (downloaded lazily only when processing is triggered)
2895
- const attachments = [];
2896
- const chatId = String(ctx.chat.id);
2897
- const group = registeredGroups[chatId];
2898
- const groupFolder = group?.folder;
2899
- const messageId = String(msg.message_id);
2900
- if (Array.isArray(msg.photo) && msg.photo.length > 0) {
2901
- const photos = msg.photo;
2902
- const largest = photos[photos.length - 1];
2903
- const filename = `photo_${messageId}.jpg`;
2904
- attachments.push({
2905
- type: 'photo',
2906
- file_id: largest.file_id,
2907
- file_unique_id: largest.file_unique_id,
2908
- file_name: filename,
2909
- mime_type: 'image/jpeg',
2910
- file_size: largest.file_size,
2911
- width: largest.width,
2912
- height: largest.height
2913
- });
2914
- }
2915
- if (msg.document && typeof msg.document === 'object') {
2916
- const doc = msg.document;
2917
- const filename = doc.file_name || `document_${messageId}`;
2918
- attachments.push({
2919
- type: 'document',
2920
- file_id: doc.file_id,
2921
- file_unique_id: doc.file_unique_id,
2922
- file_name: filename,
2923
- mime_type: doc.mime_type,
2924
- file_size: doc.file_size
2925
- });
2926
- }
2927
- if (msg.voice && typeof msg.voice === 'object') {
2928
- const voice = msg.voice;
2929
- const filename = `voice_${messageId}.ogg`;
2930
- attachments.push({
2931
- type: 'voice',
2932
- file_id: voice.file_id,
2933
- file_unique_id: voice.file_unique_id,
2934
- file_name: filename,
2935
- mime_type: voice.mime_type || 'audio/ogg',
2936
- file_size: voice.file_size,
2937
- duration: voice.duration
883
+ threadId,
2938
884
  });
2939
885
  }
2940
- if (msg.video && typeof msg.video === 'object') {
2941
- const video = msg.video;
2942
- const filename = video.file_name || `video_${messageId}.mp4`;
2943
- attachments.push({
2944
- type: 'video',
2945
- file_id: video.file_id,
2946
- file_unique_id: video.file_unique_id,
2947
- file_name: filename,
2948
- mime_type: video.mime_type,
2949
- file_size: video.file_size,
2950
- duration: video.duration,
2951
- width: video.width,
2952
- height: video.height
2953
- });
2954
- }
2955
- if (msg.audio && typeof msg.audio === 'object') {
2956
- const audio = msg.audio;
2957
- const filename = audio.file_name || `audio_${messageId}.mp3`;
2958
- attachments.push({
2959
- type: 'audio',
2960
- file_id: audio.file_id,
2961
- file_unique_id: audio.file_unique_id,
2962
- file_name: filename,
2963
- mime_type: audio.mime_type,
2964
- file_size: audio.file_size,
2965
- duration: audio.duration
2966
- });
2967
- }
2968
- // Skip messages with no text content AND no attachments (stickers, etc.)
2969
- if (!content && attachments.length === 0) {
2970
- return;
886
+ };
887
+ }
888
+ // ───────────────────────── Wake Recovery ─────────────────────────
889
+ let providerRegistry;
890
+ let messagePipeline;
891
+ async function onWakeRecovery(sleepDurationMs) {
892
+ logger.info({ sleepDurationMs }, 'Running wake recovery');
893
+ // 1. Suppress daemon health check kills for 60s
894
+ suppressHealthChecks(60_000);
895
+ resetUnhealthyDaemons();
896
+ // 2. Reconnect all providers (skip those that were never started)
897
+ for (const provider of providerRegistry.getAllProviders()) {
898
+ if (!provider.isConnected()) {
899
+ logger.debug({ provider: provider.name }, 'Skipping wake reconnect for inactive provider');
900
+ continue;
2971
901
  }
2972
- const chatType = ctx.chat.type;
2973
- const isGroup = chatType === 'group' || chatType === 'supergroup';
2974
- const isPrivate = chatType === 'private';
2975
- const senderId = String(ctx.from?.id || ctx.chat.id);
2976
- const senderName = ctx.from?.first_name || ctx.from?.username || 'User';
2977
- const chatName = ('title' in ctx.chat && ctx.chat.title)
2978
- || ('username' in ctx.chat && ctx.chat.username)
2979
- || ctx.from?.first_name
2980
- || ctx.from?.username
2981
- || senderName;
2982
- const timestamp = new Date(msg.date * 1000).toISOString();
2983
- const rawThreadId = msg.message_thread_id;
2984
- const messageThreadId = Number.isFinite(rawThreadId) ? Number(rawThreadId) : undefined;
2985
- const storedContent = content || `[${attachments.map(a => a.type).join(', ')}]`;
2986
- const logContent = storedContent;
2987
- logger.info({ chatId, isGroup, senderName }, `Telegram message: ${logContent.substring(0, 50)}...`);
2988
902
  try {
2989
- // Store message in database
2990
- upsertChat({ chatId, name: chatName, lastMessageTime: timestamp });
2991
- storeMessage(messageId, chatId, senderId, senderName, storedContent, timestamp, false, attachments.length > 0 ? attachments : undefined);
2992
- }
2993
- catch (error) {
2994
- logger.error({ error, chatId }, 'Failed to persist Telegram message');
2995
- }
2996
- const botUsername = ctx.me;
2997
- const botId = ctx.botInfo?.id;
2998
- // Admin commands only apply to text messages
2999
- if (content) {
3000
- const adminHandled = await handleAdminCommand({
3001
- chatId,
3002
- senderId,
3003
- senderName,
3004
- content,
3005
- botUsername,
3006
- messageThreadId
3007
- });
3008
- if (adminHandled) {
3009
- return;
3010
- }
3011
- }
3012
- const entities = 'entities' in msg ? msg.entities : undefined;
3013
- const mentioned = content ? isBotMentioned(content, entities, botUsername, botId) : false;
3014
- const replied = isBotReplied(ctx.message, botId);
3015
- const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
3016
- const triggered = Boolean(triggerRegex && content && triggerRegex.test(content));
3017
- const shouldProcess = isPrivate || mentioned || replied || triggered;
3018
- if (!shouldProcess) {
3019
- return;
903
+ if (provider.name === 'telegram')
904
+ setTelegramConnected(false);
905
+ await provider.stop();
906
+ await sleep(1_000);
907
+ await provider.start(createProviderHandlers(providerRegistry, messagePipeline));
908
+ if (provider.name === 'telegram')
909
+ setTelegramConnected(true);
910
+ logger.info({ provider: provider.name }, 'Provider reconnected after wake');
3020
911
  }
3021
- // Rate limiting check
3022
- const rateCheck = checkRateLimit(senderId);
3023
- if (!rateCheck.allowed) {
3024
- const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
3025
- logger.warn({ senderId, retryAfterSec }, 'Rate limit exceeded');
3026
- await sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { messageThreadId });
3027
- return;
912
+ catch (err) {
913
+ logger.error({ err, provider: provider.name }, 'Failed to reconnect provider after wake');
3028
914
  }
3029
- if (attachments.length > 0 && groupFolder) {
3030
- let downloadedAny = false;
3031
- const failedAttachments = [];
3032
- for (const attachment of attachments) {
3033
- const filename = attachment.file_name || `${attachment.type}_${messageId}`;
3034
- const result = await downloadTelegramFile(attachment.file_id, groupFolder, filename);
3035
- if (result.path) {
3036
- attachment.local_path = result.path;
3037
- downloadedAny = true;
3038
- }
3039
- else if (result.error) {
3040
- failedAttachments.push({ name: attachment.file_name || attachment.type, error: result.error });
3041
- }
3042
- }
3043
- if (failedAttachments.length > 0) {
3044
- const messages = failedAttachments.map(f => f.error === 'too_large'
3045
- ? `"${f.name}" is too large (over 20 MB). Try sending a smaller version.`
3046
- : `I couldn't download "${f.name}". Please try sending it again.`);
3047
- void sendMessage(chatId, messages.join('\n'), { messageThreadId });
3048
- }
3049
- if (downloadedAny) {
3050
- try {
3051
- storeMessage(messageId, chatId, senderId, senderName, storedContent, timestamp, false, attachments);
3052
- }
3053
- catch (error) {
3054
- logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
3055
- }
915
+ }
916
+ // 3. Reset stalled messages
917
+ try {
918
+ const resetCount = resetStalledMessages(1_000);
919
+ if (resetCount > 0)
920
+ logger.info({ resetCount }, 'Reset stalled messages after wake');
921
+ }
922
+ catch (err) {
923
+ logger.error({ err }, 'Failed to reset stalled messages after wake');
924
+ }
925
+ // 4. Reset stalled background jobs
926
+ try {
927
+ const resetJobCount = resetStalledBackgroundJobs();
928
+ if (resetJobCount > 0)
929
+ logger.info({ count: resetJobCount }, 'Re-queued stalled background jobs after wake');
930
+ }
931
+ catch (err) {
932
+ logger.error({ err }, 'Failed to reset stalled background jobs after wake');
933
+ }
934
+ // 5. Re-drain pending message queues
935
+ try {
936
+ const pendingChats = getChatsWithPendingMessages();
937
+ const activeDrains = getActiveDrains();
938
+ for (const chatId of pendingChats) {
939
+ if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
940
+ void messagePipeline.drainQueue(chatId);
3056
941
  }
3057
942
  }
3058
- enqueueMessage({
3059
- chatId,
3060
- messageId,
3061
- senderId,
3062
- senderName,
3063
- content: storedContent,
3064
- timestamp,
3065
- isGroup,
3066
- chatType,
3067
- messageThreadId,
3068
- attachments: attachments.length > 0 ? attachments : undefined
3069
- });
3070
- });
943
+ }
944
+ catch (err) {
945
+ logger.error({ err }, 'Failed to resume message drains after wake');
946
+ }
3071
947
  }
948
+ // ───────────────────────── Docker Check ─────────────────────────
3072
949
  function ensureDockerRunning() {
3073
950
  try {
3074
951
  execSync('docker info', { stdio: 'pipe', timeout: 10000 });
@@ -3076,7 +953,6 @@ function ensureDockerRunning() {
3076
953
  }
3077
954
  catch {
3078
955
  logger.error('Docker daemon is not running');
3079
- // Intentionally using console.error for maximum visibility on fatal exit
3080
956
  console.error('\n╔════════════════════════════════════════════════════════════════╗');
3081
957
  console.error('║ FATAL: Docker is not running ║');
3082
958
  console.error('║ ║');
@@ -3089,37 +965,29 @@ function ensureDockerRunning() {
3089
965
  throw new Error('Docker is required but not running');
3090
966
  }
3091
967
  }
968
+ // ───────────────────────── Main ─────────────────────────
3092
969
  async function main() {
3093
- // Global error handlers — keep the process alive on unexpected errors
3094
970
  process.on('unhandledRejection', (reason) => {
3095
971
  logger.error({ err: reason }, 'Unhandled promise rejection');
3096
972
  });
3097
973
  process.on('uncaughtException', (err) => {
3098
974
  logger.error({ err }, 'Uncaught exception');
3099
- // Only exit for fatal system errors (out of memory, etc.)
3100
975
  if (err instanceof RangeError || err instanceof TypeError) {
3101
976
  logger.error('Fatal uncaught exception — exiting');
3102
977
  process.exit(1);
3103
978
  }
3104
979
  });
3105
- // Ensure directory structure exists before anything else
3106
980
  const { ensureDirectoryStructure } = await import('./paths.js');
3107
981
  ensureDirectoryStructure();
3108
982
  try {
3109
983
  const envStat = fs.existsSync(ENV_PATH) ? fs.statSync(ENV_PATH) : null;
3110
984
  if (!envStat || envStat.size === 0) {
3111
- logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; set TELEGRAM_BOT_TOKEN and OPENROUTER_API_KEY');
985
+ logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; run "dotclaw configure" to set up provider tokens and API keys');
3112
986
  }
3113
987
  }
3114
988
  catch (err) {
3115
989
  logger.warn({ envPath: ENV_PATH, err }, 'Failed to check .env file');
3116
990
  }
3117
- // Validate Telegram token
3118
- if (!process.env.TELEGRAM_BOT_TOKEN) {
3119
- throw new Error('TELEGRAM_BOT_TOKEN environment variable is required.\n' +
3120
- 'Create a bot with @BotFather and add the token to your .env file at: ' +
3121
- ENV_PATH);
3122
- }
3123
991
  ensureDockerRunning();
3124
992
  initDatabase();
3125
993
  const resetCount = resetStalledMessages();
@@ -3142,6 +1010,41 @@ async function main() {
3142
1010
  }
3143
1011
  startMetricsServer();
3144
1012
  loadState();
1013
+ // ──── Provider Registry ────
1014
+ providerRegistry = new ProviderRegistry();
1015
+ // Register Telegram provider (optional — only when enabled + token present)
1016
+ let telegramProvider = null;
1017
+ if (runtime.host.telegram.enabled && process.env.TELEGRAM_BOT_TOKEN) {
1018
+ telegramProvider = createTelegramProvider(runtime, GROUPS_DIR);
1019
+ providerRegistry.register(telegramProvider);
1020
+ logger.info('Telegram provider registered');
1021
+ }
1022
+ else if (runtime.host.telegram.enabled && !process.env.TELEGRAM_BOT_TOKEN) {
1023
+ logger.warn('Telegram is enabled in config but TELEGRAM_BOT_TOKEN is not set — skipping');
1024
+ }
1025
+ // Register Discord provider (optional — only when enabled + token present)
1026
+ let discordProvider = null;
1027
+ if (runtime.host.discord.enabled && process.env.DISCORD_BOT_TOKEN) {
1028
+ const { createDiscordProvider } = await import('./providers/discord/index.js');
1029
+ discordProvider = createDiscordProvider(runtime);
1030
+ providerRegistry.register(discordProvider);
1031
+ logger.info('Discord provider registered');
1032
+ }
1033
+ else if (runtime.host.discord.enabled && !process.env.DISCORD_BOT_TOKEN) {
1034
+ logger.warn('Discord is enabled in config but DISCORD_BOT_TOKEN is not set — skipping');
1035
+ }
1036
+ // ──── Message Pipeline ────
1037
+ messagePipeline = createMessagePipeline({
1038
+ registry: providerRegistry,
1039
+ registeredGroups: () => registeredGroups,
1040
+ sessions: () => sessions,
1041
+ setSession: (folder, id) => {
1042
+ sessions[folder] = id;
1043
+ setGroupSession(folder, id);
1044
+ },
1045
+ buildAvailableGroupsSnapshot,
1046
+ });
1047
+ // Warm containers
3145
1048
  if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
3146
1049
  const groups = Object.values(registeredGroups);
3147
1050
  for (const group of groups) {
@@ -3154,23 +1057,31 @@ async function main() {
3154
1057
  }
3155
1058
  }
3156
1059
  }
3157
- // Resume any pending message queues from before restart
1060
+ // Resume pending message queues from before restart
3158
1061
  const pendingChats = getChatsWithPendingMessages();
3159
1062
  for (const chatId of pendingChats) {
3160
1063
  if (registeredGroups[chatId]) {
3161
1064
  logger.info({ chatId }, 'Resuming message queue drain after restart');
3162
- void drainQueue(chatId);
1065
+ void messagePipeline.drainQueue(chatId);
3163
1066
  }
3164
1067
  }
3165
- // Set up Telegram message handlers
3166
- setupTelegramHandlers();
3167
1068
  // Start dashboard
3168
1069
  startDashboard();
3169
- // Start Telegram bot
1070
+ // ──── Start Providers ────
1071
+ const handlers = createProviderHandlers(providerRegistry, messagePipeline);
3170
1072
  try {
3171
- telegrafBot.launch();
3172
- setTelegramConnected(true);
3173
- logger.info('Telegram bot started');
1073
+ if (telegramProvider) {
1074
+ await telegramProvider.start(handlers);
1075
+ setTelegramConnected(true);
1076
+ logger.info('Telegram bot started');
1077
+ }
1078
+ if (discordProvider) {
1079
+ await discordProvider.start(handlers);
1080
+ logger.info('Discord bot started');
1081
+ }
1082
+ if (!telegramProvider && !discordProvider) {
1083
+ throw new Error('No messaging providers configured. Set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN.');
1084
+ }
3174
1085
  // Graceful shutdown
3175
1086
  let shuttingDown = false;
3176
1087
  const gracefulShutdown = async (signal) => {
@@ -3180,7 +1091,12 @@ async function main() {
3180
1091
  logger.info({ signal }, 'Graceful shutdown initiated');
3181
1092
  // 1. Stop accepting new work
3182
1093
  setTelegramConnected(false);
3183
- telegrafBot.stop(signal);
1094
+ for (const p of providerRegistry.getAllProviders()) {
1095
+ try {
1096
+ await p.stop();
1097
+ }
1098
+ catch { /* ignore */ }
1099
+ }
3184
1100
  // 2. Stop all loops and watchers
3185
1101
  clearInterval(rateLimiterInterval);
3186
1102
  stopSchedulerLoop();
@@ -3190,16 +1106,18 @@ async function main() {
3190
1106
  stopHeartbeatLoop();
3191
1107
  stopDaemonHealthCheckLoop();
3192
1108
  stopWakeDetector();
3193
- stopEmbeddingWorker();
1109
+ await stopEmbeddingWorker();
3194
1110
  // 3. Stop HTTP servers
3195
1111
  stopMetricsServer();
3196
1112
  stopDashboard();
3197
1113
  // 4. Abort active agent runs so drain loops can finish quickly
1114
+ const activeRuns = getActiveRuns();
3198
1115
  for (const [chatId, controller] of activeRuns.entries()) {
3199
1116
  logger.info({ chatId }, 'Aborting active agent run for shutdown');
3200
1117
  controller.abort();
3201
1118
  }
3202
1119
  // Wait for active drain loops to finish
1120
+ const activeDrains = getActiveDrains();
3203
1121
  const drainDeadline = Date.now() + 30_000;
3204
1122
  while (activeDrains.size > 0 && Date.now() < drainDeadline) {
3205
1123
  await new Promise(r => setTimeout(r, 200));
@@ -3210,6 +1128,7 @@ async function main() {
3210
1128
  // 5. Clean up Docker containers for this instance
3211
1129
  cleanupInstanceContainers();
3212
1130
  // 6. Close databases
1131
+ closeWorkflowStore();
3213
1132
  closeMemoryStore();
3214
1133
  closeDatabase();
3215
1134
  logger.info('Shutdown complete');
@@ -3217,10 +1136,9 @@ async function main() {
3217
1136
  };
3218
1137
  process.once('SIGINT', () => void gracefulShutdown('SIGINT'));
3219
1138
  process.once('SIGTERM', () => void gracefulShutdown('SIGTERM'));
3220
- // Start scheduler and IPC watcher
3221
- // Wrapper that matches the scheduler's expected interface (Promise<void>)
1139
+ // ──── Start Services ────
3222
1140
  const sendMessageForScheduler = async (jid, text) => {
3223
- const result = await sendMessage(jid, text);
1141
+ const result = await providerRegistry.getProviderForChat(jid).sendMessage(jid, text);
3224
1142
  if (!result.success) {
3225
1143
  throw new Error(`Failed to send message to chat ${jid}`);
3226
1144
  }
@@ -3243,15 +1161,26 @@ async function main() {
3243
1161
  setGroupSession(groupFolder, sessionId);
3244
1162
  }
3245
1163
  });
3246
- startIpcWatcher();
1164
+ startIpcWatcher({
1165
+ registry: providerRegistry,
1166
+ registeredGroups: () => registeredGroups,
1167
+ registerGroup,
1168
+ unregisterGroup,
1169
+ listRegisteredGroups,
1170
+ sessions: () => sessions,
1171
+ setSession: (folder, id) => {
1172
+ sessions[folder] = id;
1173
+ setGroupSession(folder, id);
1174
+ }
1175
+ });
3247
1176
  startMaintenanceLoop();
3248
1177
  startHeartbeatLoop();
3249
1178
  startDaemonHealthCheckLoop(() => registeredGroups, MAIN_GROUP_FOLDER);
3250
1179
  startWakeDetector((ms) => { void onWakeRecovery(ms); });
3251
- logger.info('DotClaw running on Telegram (responds to DMs and group mentions/replies)');
1180
+ logger.info('DotClaw running (responds to DMs and group mentions/replies)');
3252
1181
  }
3253
1182
  catch (error) {
3254
- logger.error({ error }, 'Failed to start Telegram bot');
1183
+ logger.error({ err: error instanceof Error ? error : new Error(String(error)) }, 'Failed to start DotClaw');
3255
1184
  process.exit(1);
3256
1185
  }
3257
1186
  }