@dotsetlabs/dotclaw 1.9.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.env.example +6 -0
  2. package/README.md +13 -8
  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 +16 -122
  6. package/config-examples/tool-policy.json +2 -15
  7. package/container/agent-runner/package-lock.json +258 -0
  8. package/container/agent-runner/package.json +2 -1
  9. package/container/agent-runner/src/agent-config.ts +62 -47
  10. package/container/agent-runner/src/browser.ts +180 -0
  11. package/container/agent-runner/src/container-protocol.ts +4 -9
  12. package/container/agent-runner/src/id.ts +3 -2
  13. package/container/agent-runner/src/index.ts +331 -846
  14. package/container/agent-runner/src/ipc.ts +3 -33
  15. package/container/agent-runner/src/mcp-client.ts +222 -0
  16. package/container/agent-runner/src/mcp-registry.ts +163 -0
  17. package/container/agent-runner/src/skill-loader.ts +375 -0
  18. package/container/agent-runner/src/tools.ts +154 -184
  19. package/container/agent-runner/src/tts.ts +61 -0
  20. package/dist/admin-commands.d.ts.map +1 -1
  21. package/dist/admin-commands.js +12 -0
  22. package/dist/admin-commands.js.map +1 -1
  23. package/dist/agent-execution.d.ts +5 -9
  24. package/dist/agent-execution.d.ts.map +1 -1
  25. package/dist/agent-execution.js +32 -20
  26. package/dist/agent-execution.js.map +1 -1
  27. package/dist/cli.js +61 -16
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config.d.ts +1 -4
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +2 -5
  32. package/dist/config.js.map +1 -1
  33. package/dist/container-protocol.d.ts +4 -9
  34. package/dist/container-protocol.d.ts.map +1 -1
  35. package/dist/container-runner.d.ts.map +1 -1
  36. package/dist/container-runner.js +3 -8
  37. package/dist/container-runner.js.map +1 -1
  38. package/dist/dashboard.d.ts +5 -6
  39. package/dist/dashboard.d.ts.map +1 -1
  40. package/dist/dashboard.js +12 -60
  41. package/dist/dashboard.js.map +1 -1
  42. package/dist/db.d.ts +1 -59
  43. package/dist/db.d.ts.map +1 -1
  44. package/dist/db.js +41 -262
  45. package/dist/db.js.map +1 -1
  46. package/dist/error-messages.d.ts.map +1 -1
  47. package/dist/error-messages.js +5 -1
  48. package/dist/error-messages.js.map +1 -1
  49. package/dist/hooks.d.ts +7 -0
  50. package/dist/hooks.d.ts.map +1 -0
  51. package/dist/hooks.js +93 -0
  52. package/dist/hooks.js.map +1 -0
  53. package/dist/id.d.ts.map +1 -1
  54. package/dist/id.js +2 -1
  55. package/dist/id.js.map +1 -1
  56. package/dist/index.js +673 -2790
  57. package/dist/index.js.map +1 -1
  58. package/dist/ipc-dispatcher.d.ts +26 -0
  59. package/dist/ipc-dispatcher.d.ts.map +1 -0
  60. package/dist/ipc-dispatcher.js +861 -0
  61. package/dist/ipc-dispatcher.js.map +1 -0
  62. package/dist/local-embeddings.d.ts +7 -0
  63. package/dist/local-embeddings.d.ts.map +1 -0
  64. package/dist/local-embeddings.js +60 -0
  65. package/dist/local-embeddings.js.map +1 -0
  66. package/dist/maintenance.d.ts.map +1 -1
  67. package/dist/maintenance.js +3 -7
  68. package/dist/maintenance.js.map +1 -1
  69. package/dist/memory-embeddings.d.ts +1 -1
  70. package/dist/memory-embeddings.d.ts.map +1 -1
  71. package/dist/memory-embeddings.js +59 -31
  72. package/dist/memory-embeddings.js.map +1 -1
  73. package/dist/memory-store.d.ts +0 -10
  74. package/dist/memory-store.d.ts.map +1 -1
  75. package/dist/memory-store.js +11 -27
  76. package/dist/memory-store.js.map +1 -1
  77. package/dist/message-pipeline.d.ts +47 -0
  78. package/dist/message-pipeline.d.ts.map +1 -0
  79. package/dist/message-pipeline.js +652 -0
  80. package/dist/message-pipeline.js.map +1 -0
  81. package/dist/metrics.d.ts +7 -10
  82. package/dist/metrics.d.ts.map +1 -1
  83. package/dist/metrics.js +2 -33
  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/paths.d.ts.map +1 -1
  90. package/dist/paths.js +2 -0
  91. package/dist/paths.js.map +1 -1
  92. package/dist/providers/discord/discord-format.d.ts +16 -0
  93. package/dist/providers/discord/discord-format.d.ts.map +1 -0
  94. package/dist/providers/discord/discord-format.js +153 -0
  95. package/dist/providers/discord/discord-format.js.map +1 -0
  96. package/dist/providers/discord/discord-provider.d.ts +50 -0
  97. package/dist/providers/discord/discord-provider.d.ts.map +1 -0
  98. package/dist/providers/discord/discord-provider.js +607 -0
  99. package/dist/providers/discord/discord-provider.js.map +1 -0
  100. package/dist/providers/discord/index.d.ts +4 -0
  101. package/dist/providers/discord/index.d.ts.map +1 -0
  102. package/dist/providers/discord/index.js +3 -0
  103. package/dist/providers/discord/index.js.map +1 -0
  104. package/dist/providers/registry.d.ts +14 -0
  105. package/dist/providers/registry.d.ts.map +1 -0
  106. package/dist/providers/registry.js +49 -0
  107. package/dist/providers/registry.js.map +1 -0
  108. package/dist/providers/telegram/index.d.ts +4 -0
  109. package/dist/providers/telegram/index.d.ts.map +1 -0
  110. package/dist/providers/telegram/index.js +3 -0
  111. package/dist/providers/telegram/index.js.map +1 -0
  112. package/dist/providers/telegram/telegram-format.d.ts +3 -0
  113. package/dist/providers/telegram/telegram-format.d.ts.map +1 -0
  114. package/dist/providers/telegram/telegram-format.js +215 -0
  115. package/dist/providers/telegram/telegram-format.js.map +1 -0
  116. package/dist/providers/telegram/telegram-provider.d.ts +51 -0
  117. package/dist/providers/telegram/telegram-provider.d.ts.map +1 -0
  118. package/dist/providers/telegram/telegram-provider.js +824 -0
  119. package/dist/providers/telegram/telegram-provider.js.map +1 -0
  120. package/dist/providers/types.d.ts +107 -0
  121. package/dist/providers/types.d.ts.map +1 -0
  122. package/dist/providers/types.js +2 -0
  123. package/dist/providers/types.js.map +1 -0
  124. package/dist/request-router.d.ts +9 -31
  125. package/dist/request-router.d.ts.map +1 -1
  126. package/dist/request-router.js +12 -142
  127. package/dist/request-router.js.map +1 -1
  128. package/dist/runtime-config.d.ts +79 -101
  129. package/dist/runtime-config.d.ts.map +1 -1
  130. package/dist/runtime-config.js +140 -208
  131. package/dist/runtime-config.js.map +1 -1
  132. package/dist/skill-manager.d.ts +39 -0
  133. package/dist/skill-manager.d.ts.map +1 -0
  134. package/dist/skill-manager.js +286 -0
  135. package/dist/skill-manager.js.map +1 -0
  136. package/dist/streaming.d.ts +58 -0
  137. package/dist/streaming.d.ts.map +1 -0
  138. package/dist/streaming.js +196 -0
  139. package/dist/streaming.js.map +1 -0
  140. package/dist/task-scheduler.d.ts.map +1 -1
  141. package/dist/task-scheduler.js +11 -45
  142. package/dist/task-scheduler.js.map +1 -1
  143. package/dist/tool-policy.d.ts.map +1 -1
  144. package/dist/tool-policy.js +13 -5
  145. package/dist/tool-policy.js.map +1 -1
  146. package/dist/transcription.d.ts +8 -0
  147. package/dist/transcription.d.ts.map +1 -0
  148. package/dist/transcription.js +174 -0
  149. package/dist/transcription.js.map +1 -0
  150. package/dist/types.d.ts +2 -50
  151. package/dist/types.d.ts.map +1 -1
  152. package/package.json +15 -4
  153. package/scripts/bootstrap.js +40 -4
  154. package/scripts/configure.js +129 -7
  155. package/scripts/doctor.js +30 -4
  156. package/scripts/init.js +13 -6
  157. package/scripts/install.sh +1 -1
  158. package/config-examples/plugin-http.json +0 -18
  159. package/container/skills/agent-browser.md +0 -159
  160. package/dist/background-job-classifier.d.ts +0 -20
  161. package/dist/background-job-classifier.d.ts.map +0 -1
  162. package/dist/background-job-classifier.js +0 -145
  163. package/dist/background-job-classifier.js.map +0 -1
  164. package/dist/background-jobs.d.ts +0 -56
  165. package/dist/background-jobs.d.ts.map +0 -1
  166. package/dist/background-jobs.js +0 -550
  167. package/dist/background-jobs.js.map +0 -1
  168. package/dist/planner-probe.d.ts +0 -14
  169. package/dist/planner-probe.d.ts.map +0 -1
  170. package/dist/planner-probe.js +0 -97
  171. package/dist/planner-probe.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,39 +1,41 @@
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, } from './db.js';
9
+ import { startSchedulerLoop, stopSchedulerLoop } from './task-scheduler.js';
12
10
  import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
13
- import { hostPathToContainerGroupPath, resolveContainerGroupPathToHost } from './path-mapping.js';
14
11
  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';
12
+ import { initMemoryStore, closeMemoryStore, cleanupExpiredMemories, upsertMemoryItems } from './memory-store.js';
17
13
  import { startEmbeddingWorker, stopEmbeddingWorker } from './memory-embeddings.js';
18
- import { createProgressManager, DEFAULT_PROGRESS_MESSAGES, DEFAULT_PROGRESS_STAGES, formatProgressWithPlan, formatPlanStepList } from './progress.js';
19
14
  import { parseAdminCommand } from './admin-commands.js';
20
15
  import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
21
- import { startMetricsServer, stopMetricsServer, recordMessage, recordError, recordRoutingDecision, recordStageLatency } from './metrics.js';
16
+ import { startMetricsServer, stopMetricsServer, recordMessage } from './metrics.js';
22
17
  import { startMaintenanceLoop, stopMaintenanceLoop } from './maintenance.js';
23
18
  import { warmGroupContainer, startDaemonHealthCheckLoop, stopDaemonHealthCheckLoop, cleanupInstanceContainers, suppressHealthChecks, resetUnhealthyDaemons } from './container-runner.js';
24
19
  import { startWakeDetector, stopWakeDetector } from './wake-detector.js';
25
20
  import { loadRuntimeConfig } from './runtime-config.js';
21
+ import { transcribeVoice } from './transcription.js';
22
+ import { emitHook } from './hooks.js';
26
23
  import { invalidatePersonalizationCache } from './personalization.js';
24
+ import { installSkill, removeSkill, listSkills, updateSkill } from './skill-manager.js';
27
25
  import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionError } from './agent-execution.js';
28
26
  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';
27
+ import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime } from './dashboard.js';
28
+ import { routePrompt } from './request-router.js';
29
+ // Provider system
30
+ import { ProviderRegistry } from './providers/registry.js';
31
+ import { createTelegramProvider } from './providers/telegram/index.js';
32
+ import { createMessagePipeline, getActiveDrains, getActiveRuns, providerAttachmentToMessageAttachment } from './message-pipeline.js';
33
+ import { startIpcWatcher, stopIpcWatcher } from './ipc-dispatcher.js';
36
34
  const runtime = loadRuntimeConfig();
35
+ // ───────────────────────── State ─────────────────────────
36
+ let sessions = {};
37
+ let registeredGroups = {};
38
+ // ───────────────────────── Helpers ─────────────────────────
37
39
  function buildTriggerRegex(pattern) {
38
40
  if (!pattern)
39
41
  return null;
@@ -52,87 +54,23 @@ function buildAvailableGroupsSnapshot() {
52
54
  isRegistered: true
53
55
  }));
54
56
  }
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;
57
+ function sleep(ms) {
58
+ return new Promise(resolve => setTimeout(resolve, ms));
116
59
  }
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
60
+ // ───────────────────────── Rate Limiter ─────────────────────────
61
+ const RATE_LIMIT_MAX_MESSAGES = 20;
62
+ const RATE_LIMIT_WINDOW_MS = 60_000;
120
63
  const rateLimiter = new Map();
121
- const JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS = 120_000;
122
- const lastJobUpdateNotifications = new Map();
123
64
  function checkRateLimit(userId) {
124
65
  const now = Date.now();
125
66
  const entry = rateLimiter.get(userId);
126
67
  if (!entry || now > entry.resetAt) {
127
- // New window
128
68
  rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
129
69
  return { allowed: true };
130
70
  }
131
71
  if (entry.count >= RATE_LIMIT_MAX_MESSAGES) {
132
- // Rate limited
133
72
  return { allowed: false, retryAfterMs: entry.resetAt - now };
134
73
  }
135
- // Increment counter
136
74
  entry.count += 1;
137
75
  return { allowed: true };
138
76
  }
@@ -144,171 +82,32 @@ function cleanupRateLimiter() {
144
82
  }
145
83
  }
146
84
  }
147
- // Clean up expired rate limit entries periodically
148
85
  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;
152
- const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
153
- const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
154
- const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
86
+ // ───────────────────────── Config Constants ─────────────────────────
155
87
  const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
156
88
  const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
157
89
  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;
90
+ // ───────────────────────── State Management ─────────────────────────
91
+ function loadState() {
92
+ sessions = {};
93
+ const rawGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
94
+ // Migrate: prefix unprefixed chat IDs with 'telegram:'
95
+ let migrated = false;
96
+ const loadedGroups = {};
97
+ for (const [chatId, group] of Object.entries(rawGroups)) {
98
+ if (!chatId.includes(':')) {
99
+ // Unprefixed add telegram: prefix
100
+ loadedGroups[ProviderRegistry.addPrefix('telegram', chatId)] = group;
101
+ migrated = true;
263
102
  }
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;
103
+ else {
104
+ loadedGroups[chatId] = group;
269
105
  }
270
106
  }
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;
107
+ if (migrated) {
108
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), loadedGroups);
109
+ logger.info('Migrated registered_groups.json chat IDs with telegram: prefix');
295
110
  }
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
111
  const sanitizedGroups = {};
313
112
  const usedFolders = new Set();
314
113
  let invalidCount = 0;
@@ -327,2222 +126,97 @@ function loadState() {
327
126
  if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
328
127
  logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with invalid folder');
329
128
  invalidCount += 1;
330
- continue;
331
- }
332
- if (usedFolders.has(group.folder)) {
333
- logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with duplicate folder');
334
- duplicateCount += 1;
335
- continue;
336
- }
337
- usedFolders.add(group.folder);
338
- sanitizedGroups[chatId] = group;
339
- }
340
- registeredGroups = sanitizedGroups;
341
- if (invalidCount > 0 || duplicateCount > 0) {
342
- logger.error({ invalidCount, duplicateCount }, 'Registered groups contained invalid or duplicate folders');
343
- }
344
- logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
345
- const finalSessions = getAllGroupSessions();
346
- sessions = finalSessions.reduce((acc, row) => {
347
- acc[row.group_folder] = row.session_id;
348
- return acc;
349
- }, {});
350
- }
351
- function registerGroup(chatId, group) {
352
- if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
353
- logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with invalid folder');
354
- return;
355
- }
356
- const folderCollision = Object.values(registeredGroups).some(g => g.folder === group.folder);
357
- if (folderCollision) {
358
- logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with duplicate folder');
359
- return;
360
- }
361
- registeredGroups[chatId] = group;
362
- saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
363
- // Create group folder
364
- const groupDir = path.join(GROUPS_DIR, group.folder);
365
- fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
366
- logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
367
- if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
368
- try {
369
- warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
370
- logger.info({ group: group.folder }, 'Warmed daemon container for new group');
371
- }
372
- catch (err) {
373
- logger.warn({ group: group.folder, err }, 'Failed to warm container for new group');
374
- }
375
- }
376
- }
377
- function listRegisteredGroups() {
378
- return Object.entries(registeredGroups).map(([chatId, group]) => ({
379
- chat_id: chatId,
380
- name: group.name,
381
- folder: group.folder,
382
- trigger: group.trigger,
383
- added_at: group.added_at
384
- }));
385
- }
386
- function resolveGroupIdentifier(identifier) {
387
- const trimmed = identifier.trim();
388
- if (!trimmed)
389
- return null;
390
- const normalized = trimmed.toLowerCase();
391
- for (const [chatId, group] of Object.entries(registeredGroups)) {
392
- if (chatId === trimmed)
393
- return chatId;
394
- if (group.name.toLowerCase() === normalized)
395
- return chatId;
396
- if (group.folder.toLowerCase() === normalized)
397
- return chatId;
398
- }
399
- return null;
400
- }
401
- function unregisterGroup(identifier) {
402
- const chatId = resolveGroupIdentifier(identifier);
403
- if (!chatId) {
404
- return { ok: false, error: 'Group not found' };
405
- }
406
- const group = registeredGroups[chatId];
407
- if (!group) {
408
- return { ok: false, error: 'Group not found' };
409
- }
410
- if (group.folder === MAIN_GROUP_FOLDER) {
411
- return { ok: false, error: 'Cannot remove main group' };
412
- }
413
- delete registeredGroups[chatId];
414
- saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
415
- delete sessions[group.folder];
416
- deleteGroupSession(group.folder);
417
- pauseTasksForGroup(group.folder);
418
- logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
419
- return { ok: true, group: { ...group, chat_id: chatId } };
420
- }
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;
429
- }
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 };
483
- }
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
- }
514
- }
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
- }
540
- }
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 };
614
- }
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' };
624
- }
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
- }
642
- }
643
- }
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
- }
696
- }
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 };
717
- }
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);
726
- }
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 };
735
- }
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 };
743
- }
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 };
752
- }
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 };
775
- }
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 };
783
- }
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 };
792
- }
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);
815
- }
816
- }
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 });
869
- }
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');
890
- }
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;
901
- }
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);
919
- }
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);
982
- }
983
- }
984
- finally {
985
- if (!reschedule) {
986
- activeDrains.delete(chatId);
987
- }
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;
996
- }
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)
1006
- 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
- }
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
1028
- }];
1029
- }
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
- }
1090
- }
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);
1146
- }
1147
- if (routingDecision.estimatedMinutes) {
1148
- tags.push(`eta:${routingDecision.estimatedMinutes}`);
1149
- }
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;
1173
- }
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
- 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
- }
1218
- if (routingDecision.shouldBackground) {
1219
- const autoSpawned = await maybeAutoSpawn('router', routingDecision.reason);
1220
- if (autoSpawned) {
1221
- return true;
1222
- }
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
- }
1259
- }
1260
- catch (err) {
1261
- logger.warn({ chatId: msg.chatId, err }, 'Background job classifier failed');
1262
- }
1263
- }
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;
1303
- }
1304
- else {
1305
- progressManager.notify(DEFAULT_PROGRESS_STAGES.ack);
1306
- }
1307
- }
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);
1321
- }
1322
- }
1323
- const abortController = new AbortController();
1324
- activeRuns.set(msg.chatId, abortController);
1325
- 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
- const execution = await executeAgentRun({
1337
- group,
1338
- prompt,
1339
- chatJid: msg.chatId,
1340
- userId: msg.senderId,
1341
- userName: msg.senderName,
1342
- recallQuery: recallQuery || msg.content,
1343
- recallMaxResults,
1344
- recallMaxTokens,
1345
- toolAllow: routingDecision.toolAllow,
1346
- toolDeny: routingDecision.toolDeny,
1347
- sessionId: sessions[group.folder],
1348
- onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
1349
- availableGroups: buildAvailableGroupsSnapshot(),
1350
- modelOverride: routingDecision.modelOverride,
1351
- modelMaxOutputTokens: routingDecision.maxOutputTokens,
1352
- maxToolSteps: routingDecision.maxToolSteps,
1353
- disablePlanner: !routingDecision.enablePlanner,
1354
- 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');
129
+ continue;
130
+ }
131
+ if (usedFolders.has(group.folder)) {
132
+ logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with duplicate folder');
133
+ duplicateCount += 1;
134
+ continue;
135
+ }
136
+ usedFolders.add(group.folder);
137
+ sanitizedGroups[chatId] = group;
2003
138
  }
2004
- catch (err) {
2005
- logger.error({ err }, 'Failed to reset stalled messages after wake');
139
+ registeredGroups = sanitizedGroups;
140
+ if (invalidCount > 0 || duplicateCount > 0) {
141
+ logger.error({ invalidCount, duplicateCount }, 'Registered groups contained invalid or duplicate folders');
2006
142
  }
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');
143
+ logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
144
+ const finalSessions = getAllGroupSessions();
145
+ sessions = finalSessions.reduce((acc, row) => {
146
+ acc[row.group_folder] = row.session_id;
147
+ return acc;
148
+ }, {});
149
+ }
150
+ function registerGroup(chatId, group) {
151
+ if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
152
+ logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with invalid folder');
153
+ return;
2012
154
  }
2013
- catch (err) {
2014
- logger.error({ err }, 'Failed to reset stalled background jobs after wake');
155
+ const folderCollision = Object.values(registeredGroups).some(g => g.folder === group.folder);
156
+ if (folderCollision) {
157
+ logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with duplicate folder');
158
+ return;
2015
159
  }
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
- }
160
+ registeredGroups[chatId] = group;
161
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
162
+ const groupDir = path.join(GROUPS_DIR, group.folder);
163
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
164
+ logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
165
+ if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
166
+ try {
167
+ warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
168
+ logger.info({ group: group.folder }, 'Warmed daemon container for new group');
169
+ }
170
+ catch (err) {
171
+ logger.warn({ group: group.folder, err }, 'Failed to warm container for new group');
2023
172
  }
2024
- }
2025
- catch (err) {
2026
- logger.error({ err }, 'Failed to resume message drains after wake');
2027
173
  }
2028
174
  }
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');
175
+ function listRegisteredGroups() {
176
+ return Object.entries(registeredGroups).map(([chatId, group]) => ({
177
+ chat_id: chatId,
178
+ name: group.name,
179
+ folder: group.folder,
180
+ trigger: group.trigger,
181
+ added_at: group.added_at
182
+ }));
183
+ }
184
+ function resolveGroupIdentifier(identifier) {
185
+ const trimmed = identifier.trim();
186
+ if (!trimmed)
187
+ return null;
188
+ const normalized = trimmed.toLowerCase();
189
+ for (const [chatId, group] of Object.entries(registeredGroups)) {
190
+ if (chatId === trimmed)
191
+ return chatId;
192
+ if (group.name.toLowerCase() === normalized)
193
+ return chatId;
194
+ if (group.folder.toLowerCase() === normalized)
195
+ return chatId;
2283
196
  }
197
+ return null;
2284
198
  }
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
- }
199
+ function unregisterGroup(identifier) {
200
+ const chatId = resolveGroupIdentifier(identifier);
201
+ if (!chatId) {
202
+ return { ok: false, error: 'Group not found' };
2541
203
  }
2542
- catch (err) {
2543
- return { id: requestId, ok: false, error: err instanceof Error ? err.message : String(err) };
204
+ const group = registeredGroups[chatId];
205
+ if (!group) {
206
+ return { ok: false, error: 'Group not found' };
207
+ }
208
+ if (group.folder === MAIN_GROUP_FOLDER) {
209
+ return { ok: false, error: 'Cannot remove main group' };
2544
210
  }
211
+ delete registeredGroups[chatId];
212
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
213
+ delete sessions[group.folder];
214
+ deleteGroupSession(group.folder);
215
+ pauseTasksForGroup(group.folder);
216
+ logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
217
+ return { ok: true, group: { ...group, chat_id: chatId } };
2545
218
  }
219
+ // ───────────────────────── Admin Commands ─────────────────────────
2546
220
  function formatGroups(groups) {
2547
221
  if (groups.length === 0)
2548
222
  return 'No registered groups.';
@@ -2583,11 +257,11 @@ function applyModelOverride(params) {
2583
257
  saveModelRegistry(nextConfig);
2584
258
  return { ok: true };
2585
259
  }
2586
- async function handleAdminCommand(params) {
260
+ async function handleAdminCommand(params, sendReply) {
2587
261
  const parsed = parseAdminCommand(params.content, params.botUsername);
2588
262
  if (!parsed)
2589
263
  return false;
2590
- const reply = (text) => sendMessage(params.chatId, text, { messageThreadId: params.messageThreadId });
264
+ const reply = (text) => sendReply(params.chatId, text, { threadId: params.threadId });
2591
265
  const group = registeredGroups[params.chatId];
2592
266
  if (!group) {
2593
267
  await reply('This chat is not registered with DotClaw.');
@@ -2611,6 +285,10 @@ async function handleAdminCommand(params) {
2611
285
  '- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
2612
286
  '- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
2613
287
  '- `/dotclaw remember <fact>` (main only)',
288
+ '- `/dotclaw skill install <url> [--global]` (main only)',
289
+ '- `/dotclaw skill remove <name> [--global]` (main only)',
290
+ '- `/dotclaw skill list [--global]` (main only)',
291
+ '- `/dotclaw skill update <name> [--global]` (main only)',
2614
292
  '- `/dotclaw style <concise|balanced|detailed>`',
2615
293
  '- `/dotclaw tools <conservative|balanced|proactive>`',
2616
294
  '- `/dotclaw caution <low|balanced|high>`',
@@ -2627,27 +305,28 @@ async function handleAdminCommand(params) {
2627
305
  if (command === 'add-group') {
2628
306
  if (requireMain('Adding groups'))
2629
307
  return true;
2630
- if (args.length < 1) {
308
+ if (args.length < 2) {
2631
309
  await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
2632
310
  return true;
2633
311
  }
2634
- const jid = args[0];
2635
- if (registeredGroups[jid]) {
2636
- await reply('That chat id is already registered.');
312
+ const newChatId = args[0];
313
+ const name = args[1];
314
+ const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 50);
315
+ if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
316
+ await reply(`Invalid folder name: "${folder}"`);
2637
317
  return true;
2638
318
  }
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.');
319
+ if (registeredGroups[newChatId]) {
320
+ await reply(`Chat ${newChatId} is already registered.`);
2643
321
  return true;
2644
322
  }
2645
- registerGroup(jid, {
323
+ const newGroup = {
2646
324
  name,
2647
- folder: folder || `group-${jid}`,
325
+ folder,
2648
326
  added_at: new Date().toISOString()
2649
- });
2650
- await reply(`Registered group "${name}" with folder "${folder}".`);
327
+ };
328
+ registerGroup(newChatId, newGroup);
329
+ await reply(`Group "${name}" registered (folder: ${folder}).`);
2651
330
  return true;
2652
331
  }
2653
332
  if (command === 'remove-group') {
@@ -2657,12 +336,12 @@ async function handleAdminCommand(params) {
2657
336
  await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
2658
337
  return true;
2659
338
  }
2660
- const result = unregisterGroup(args.join(' '));
339
+ const result = unregisterGroup(args[0]);
2661
340
  if (!result.ok) {
2662
- await reply(`Failed to remove group: ${result.error || 'unknown error'}`);
341
+ await reply(`Failed to remove group: ${result.error}`);
2663
342
  return true;
2664
343
  }
2665
- await reply(`Removed group "${result.group?.name}" (${result.group?.folder}).`);
344
+ await reply(`Group "${result.group.name}" removed.`);
2666
345
  return true;
2667
346
  }
2668
347
  if (command === 'set-model') {
@@ -2703,12 +382,17 @@ async function handleAdminCommand(params) {
2703
382
  tags: ['manual']
2704
383
  }];
2705
384
  upsertMemoryItems('global', items, 'admin-command');
2706
- await reply('Saved to global memory.');
385
+ await reply(`Remembered: "${fact}"`);
2707
386
  return true;
2708
387
  }
2709
388
  if (command === 'style') {
2710
- const style = (args[0] || '').toLowerCase();
2711
- if (!['concise', 'balanced', 'detailed'].includes(style)) {
389
+ const level = (args[0] || '').toLowerCase();
390
+ const mapping = {
391
+ concise: 'Prefers concise, short responses.',
392
+ balanced: 'Prefers balanced-length responses.',
393
+ detailed: 'Prefers detailed, thorough responses.'
394
+ };
395
+ if (!mapping[level]) {
2712
396
  await reply('Usage: /dotclaw style <concise|balanced|detailed>');
2713
397
  return true;
2714
398
  }
@@ -2717,20 +401,24 @@ async function handleAdminCommand(params) {
2717
401
  subject_id: params.senderId,
2718
402
  type: 'preference',
2719
403
  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 }
404
+ content: mapping[level],
405
+ importance: 0.6,
406
+ confidence: 0.8,
407
+ tags: [`response_style:${level}`]
2725
408
  }];
2726
409
  upsertMemoryItems(group.folder, items, 'admin-command');
2727
- await reply(`Response style set to ${style}.`);
410
+ invalidatePersonalizationCache(group.folder, params.senderId);
411
+ await reply(`Response style set to ${level}.`);
2728
412
  return true;
2729
413
  }
2730
414
  if (command === 'tools') {
2731
415
  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) {
416
+ const mapping = {
417
+ conservative: 'Prefers conservative tool usage.',
418
+ balanced: 'Prefers balanced tool usage.',
419
+ proactive: 'Prefers proactive tool usage.'
420
+ };
421
+ if (!mapping[level]) {
2734
422
  await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
2735
423
  return true;
2736
424
  }
@@ -2738,85 +426,408 @@ async function handleAdminCommand(params) {
2738
426
  scope: 'user',
2739
427
  subject_id: params.senderId,
2740
428
  type: 'preference',
2741
- conflict_key: 'tool_calling_bias',
2742
- content: `Prefers ${level} tool usage.`,
2743
- importance: 0.65,
429
+ conflict_key: 'tool_usage',
430
+ content: mapping[level],
431
+ importance: 0.6,
432
+ confidence: 0.8,
433
+ tags: [`tool_usage:${level}`]
434
+ }];
435
+ upsertMemoryItems(group.folder, items, 'admin-command');
436
+ invalidatePersonalizationCache(group.folder, params.senderId);
437
+ await reply(`Tool usage set to ${level}.`);
438
+ return true;
439
+ }
440
+ if (command === 'caution') {
441
+ const level = (args[0] || '').toLowerCase();
442
+ const mapping = {
443
+ low: 'Prefers low caution.',
444
+ balanced: 'Prefers balanced caution.',
445
+ high: 'Prefers high caution.'
446
+ };
447
+ if (!mapping[level]) {
448
+ await reply('Usage: /dotclaw caution <low|balanced|high>');
449
+ return true;
450
+ }
451
+ const items = [{
452
+ scope: 'user',
453
+ subject_id: params.senderId,
454
+ type: 'preference',
455
+ conflict_key: 'caution_level',
456
+ content: mapping[level],
457
+ importance: 0.6,
458
+ confidence: 0.8,
459
+ tags: [`caution_level:${level}`]
460
+ }];
461
+ upsertMemoryItems(group.folder, items, 'admin-command');
462
+ invalidatePersonalizationCache(group.folder, params.senderId);
463
+ await reply(`Caution level set to ${level}.`);
464
+ return true;
465
+ }
466
+ if (command === 'memory') {
467
+ const level = (args[0] || '').toLowerCase();
468
+ const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
469
+ if (threshold === null) {
470
+ await reply('Usage: /dotclaw memory <strict|balanced|loose>');
471
+ return true;
472
+ }
473
+ const items = [{
474
+ scope: 'user',
475
+ subject_id: params.senderId,
476
+ type: 'preference',
477
+ conflict_key: 'memory_importance_threshold',
478
+ content: `Prefers memory strictness ${level}.`,
479
+ importance: 0.6,
2744
480
  confidence: 0.8,
2745
- tags: [`tool_calling_bias:${bias}`],
2746
- metadata: { tool_calling_bias: bias, bias }
481
+ tags: [`memory_importance_threshold:${threshold}`],
482
+ metadata: { memory_importance_threshold: threshold, threshold }
2747
483
  }];
2748
484
  upsertMemoryItems(group.folder, items, 'admin-command');
2749
- await reply(`Tool usage bias set to ${level}.`);
485
+ await reply(`Memory strictness set to ${level}.`);
486
+ return true;
487
+ }
488
+ if (command === 'skill-help') {
489
+ await reply([
490
+ 'Skill commands:',
491
+ '- `/dotclaw skill install <url> [--global]` — install from git repo or URL',
492
+ '- `/dotclaw skill remove <name> [--global]` — remove a skill',
493
+ '- `/dotclaw skill list [--global]` — list installed skills',
494
+ '- `/dotclaw skill update <name> [--global]` — re-pull from source'
495
+ ].join('\n'));
496
+ return true;
497
+ }
498
+ if (command === 'skill-install') {
499
+ if (requireMain('Installing skills'))
500
+ return true;
501
+ if (!runtime.agent.skills.installEnabled) {
502
+ await reply('Skill installation is disabled in runtime config (`agent.skills.installEnabled`).');
503
+ return true;
504
+ }
505
+ const isGlobal = args.includes('--global');
506
+ const source = args.filter(a => a !== '--global')[0];
507
+ if (!source) {
508
+ await reply('Usage: /dotclaw skill install <url> [--global]');
509
+ return true;
510
+ }
511
+ const scope = isGlobal ? 'global' : 'group';
512
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
513
+ await reply(`Installing skill from ${source}...`);
514
+ const result = await installSkill({ source, targetDir, scope });
515
+ if (!result.ok) {
516
+ await reply(`Failed to install skill: ${result.error}`);
517
+ }
518
+ else {
519
+ await reply(`Skill "${result.name}" installed (${scope}). Available on next agent run.`);
520
+ }
521
+ return true;
522
+ }
523
+ if (command === 'skill-remove') {
524
+ if (requireMain('Removing skills'))
525
+ return true;
526
+ const isGlobal = args.includes('--global');
527
+ const name = args.filter(a => a !== '--global')[0];
528
+ if (!name) {
529
+ await reply('Usage: /dotclaw skill remove <name> [--global]');
530
+ return true;
531
+ }
532
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
533
+ const result = removeSkill({ name, targetDir });
534
+ if (!result.ok) {
535
+ await reply(`Failed to remove skill: ${result.error}`);
536
+ }
537
+ else {
538
+ await reply(`Skill "${name}" removed.`);
539
+ }
2750
540
  return true;
2751
541
  }
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>');
542
+ if (command === 'skill-list') {
543
+ if (requireMain('Listing skills'))
2757
544
  return true;
545
+ const isGlobal = args.includes('--global');
546
+ const scope = isGlobal ? 'global' : 'group';
547
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
548
+ const skills = listSkills(targetDir, scope);
549
+ if (skills.length === 0) {
550
+ await reply(`No skills installed (${scope}).`);
551
+ }
552
+ else {
553
+ const lines = skills.map(s => `- ${s.name} (v${s.version}, source: ${s.source === 'local' ? 'local' : 'remote'})`);
554
+ await reply(`Installed skills (${scope}):\n${lines.join('\n')}`);
2758
555
  }
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
556
  return true;
2773
557
  }
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>');
558
+ if (command === 'skill-update') {
559
+ if (requireMain('Updating skills'))
560
+ return true;
561
+ const isGlobal = args.includes('--global');
562
+ const name = args.filter(a => a !== '--global')[0];
563
+ if (!name) {
564
+ await reply('Usage: /dotclaw skill update <name> [--global]');
2779
565
  return true;
2780
566
  }
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}.`);
567
+ const scope = isGlobal ? 'global' : 'group';
568
+ const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
569
+ const result = await updateSkill({ name, targetDir, scope });
570
+ if (!result.ok) {
571
+ await reply(`Failed to update skill: ${result.error}`);
572
+ }
573
+ else {
574
+ await reply(`Skill "${name}" updated.`);
575
+ }
2794
576
  return true;
2795
577
  }
2796
578
  await reply('Unknown command. Use `/dotclaw help` for options.');
2797
579
  return true;
2798
580
  }
2799
- function setupTelegramHandlers() {
2800
- // Handle message reactions (👍/👎 for feedback)
2801
- telegrafBot.on('message_reaction', async (ctx) => {
581
+ // ───────────────────────── Heartbeat ─────────────────────────
582
+ async function runHeartbeatOnce() {
583
+ const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
584
+ if (!entry) {
585
+ logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
586
+ return;
587
+ }
588
+ const [chatId, group] = entry;
589
+ const prompt = [
590
+ '[HEARTBEAT]',
591
+ 'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
592
+ 'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
593
+ ].join('\n');
594
+ const traceBase = createTraceBase({
595
+ chatId,
596
+ groupFolder: group.folder,
597
+ userId: null,
598
+ inputText: prompt,
599
+ source: 'dotclaw-heartbeat'
600
+ });
601
+ const routingDecision = routePrompt(prompt);
602
+ let output = null;
603
+ let context = null;
604
+ let errorMessage = null;
605
+ try {
606
+ const execution = await executeAgentRun({
607
+ group,
608
+ prompt,
609
+ chatJid: chatId,
610
+ userId: null,
611
+ recallQuery: prompt,
612
+ recallMaxResults: Math.max(4, routingDecision.recallMaxResults - 2),
613
+ recallMaxTokens: Math.max(600, routingDecision.recallMaxTokens - 200),
614
+ sessionId: sessions[group.folder],
615
+ onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
616
+ isScheduledTask: true,
617
+ availableGroups: buildAvailableGroupsSnapshot(),
618
+ modelOverride: routingDecision.model,
619
+ modelMaxOutputTokens: routingDecision.maxOutputTokens,
620
+ maxToolSteps: routingDecision.maxToolSteps,
621
+ });
622
+ output = execution.output;
623
+ context = execution.context;
624
+ if (output.status === 'error') {
625
+ errorMessage = output.error || 'Unknown error';
626
+ }
627
+ }
628
+ catch (err) {
629
+ if (err instanceof AgentExecutionError) {
630
+ context = err.context;
631
+ errorMessage = err.message;
632
+ }
633
+ else {
634
+ errorMessage = err instanceof Error ? err.message : String(err);
635
+ }
636
+ logger.error({ err }, 'Heartbeat run failed');
637
+ }
638
+ if (context) {
639
+ recordAgentTelemetry({
640
+ traceBase,
641
+ output,
642
+ context,
643
+ toolAuditSource: 'heartbeat',
644
+ errorMessage: errorMessage ?? undefined,
645
+ });
646
+ }
647
+ else if (errorMessage) {
648
+ writeTrace({
649
+ trace_id: traceBase.trace_id,
650
+ timestamp: traceBase.timestamp,
651
+ created_at: traceBase.created_at,
652
+ chat_id: traceBase.chat_id,
653
+ group_folder: traceBase.group_folder,
654
+ user_id: traceBase.user_id,
655
+ input_text: traceBase.input_text,
656
+ output_text: null,
657
+ model_id: 'unknown',
658
+ memory_recall: [],
659
+ error_code: errorMessage,
660
+ source: traceBase.source
661
+ });
662
+ }
663
+ }
664
+ let heartbeatStopped = false;
665
+ function stopHeartbeatLoop() {
666
+ heartbeatStopped = true;
667
+ }
668
+ function startHeartbeatLoop() {
669
+ if (!HEARTBEAT_ENABLED)
670
+ return;
671
+ heartbeatStopped = false;
672
+ const loop = async () => {
673
+ if (heartbeatStopped)
674
+ return;
2802
675
  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 !== '👎'))
676
+ await runHeartbeatOnce();
677
+ }
678
+ catch (err) {
679
+ logger.error({ err }, 'Heartbeat run failed');
680
+ }
681
+ if (!heartbeatStopped) {
682
+ setTimeout(loop, HEARTBEAT_INTERVAL_MS);
683
+ }
684
+ };
685
+ loop();
686
+ }
687
+ // ───────────────────────── Provider Event Handlers ─────────────────────────
688
+ function createProviderHandlers(registry, pipeline) {
689
+ return {
690
+ onMessage(incoming) {
691
+ const chatId = incoming.chatId;
692
+ const group = registeredGroups[chatId];
693
+ const groupFolder = group?.folder;
694
+ // Log & persist
695
+ const chatName = incoming.rawProviderData?.chatName || incoming.senderName;
696
+ try {
697
+ upsertChat({ chatId, name: chatName, lastMessageTime: incoming.timestamp });
698
+ const dbAttachments = incoming.attachments?.map(providerAttachmentToMessageAttachment);
699
+ storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, dbAttachments);
700
+ }
701
+ catch (error) {
702
+ logger.error({ error, chatId }, 'Failed to persist message');
703
+ }
704
+ setLastMessageTime(new Date().toISOString());
705
+ recordMessage(ProviderRegistry.getPrefix(chatId));
706
+ // Admin commands (async, fire-and-forget with early return)
707
+ const providerName = ProviderRegistry.getPrefix(chatId);
708
+ const provider = registry.get(providerName);
709
+ const botUsername = provider && 'botUsername' in provider ? provider.botUsername : undefined;
710
+ void (async () => {
711
+ try {
712
+ if (incoming.content) {
713
+ const sendReply = async (cId, text, opts) => {
714
+ await registry.getProviderForChat(cId).sendMessage(cId, text, { threadId: opts?.threadId });
715
+ };
716
+ const adminHandled = await handleAdminCommand({
717
+ chatId,
718
+ senderId: incoming.senderId,
719
+ senderName: incoming.senderName,
720
+ content: incoming.content,
721
+ botUsername,
722
+ threadId: incoming.threadId,
723
+ }, sendReply);
724
+ if (adminHandled)
725
+ return;
726
+ }
727
+ // Check trigger/mention/reply
728
+ const isPrivate = incoming.chatType === 'private' || incoming.chatType === 'dm';
729
+ const isGroup = incoming.isGroup;
730
+ const mentioned = provider ? provider.isBotMentioned(incoming) : false;
731
+ const replied = provider ? provider.isBotReplied(incoming) : false;
732
+ const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
733
+ const triggered = Boolean(triggerRegex && incoming.content && triggerRegex.test(incoming.content));
734
+ const shouldProcess = isPrivate || mentioned || replied || triggered;
735
+ if (!shouldProcess)
736
+ return;
737
+ // Rate limiting — qualify key by provider to avoid cross-provider collisions
738
+ const rateKey = `${ProviderRegistry.getPrefix(chatId)}:${incoming.senderId}`;
739
+ const rateCheck = checkRateLimit(rateKey);
740
+ if (!rateCheck.allowed) {
741
+ const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
742
+ logger.warn({ senderId: incoming.senderId, retryAfterSec }, 'Rate limit exceeded');
743
+ await registry.getProviderForChat(chatId).sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { threadId: incoming.threadId });
744
+ return;
745
+ }
746
+ // Download attachments
747
+ const attachments = incoming.attachments?.map(providerAttachmentToMessageAttachment) ?? [];
748
+ if (attachments.length > 0 && groupFolder) {
749
+ let downloadedAny = false;
750
+ const failedAttachments = [];
751
+ for (const attachment of attachments) {
752
+ const fileRef = attachment.provider_file_ref;
753
+ if (!fileRef)
754
+ continue;
755
+ const filename = attachment.file_name || `${attachment.type}_${incoming.messageId}`;
756
+ const result = await provider.downloadFile(fileRef, groupFolder, filename);
757
+ if (result.path) {
758
+ attachment.local_path = result.path;
759
+ downloadedAny = true;
760
+ }
761
+ else if (result.error) {
762
+ failedAttachments.push({ name: attachment.file_name || attachment.type, error: result.error });
763
+ }
764
+ }
765
+ if (failedAttachments.length > 0) {
766
+ const maxMB = Math.floor(provider.capabilities.maxAttachmentBytes / (1024 * 1024));
767
+ const messages = failedAttachments.map(f => f.error === 'too_large'
768
+ ? `"${f.name}" is too large (over ${maxMB} MB). Try sending a smaller version.`
769
+ : `I couldn't download "${f.name}". Please try sending it again.`);
770
+ void registry.getProviderForChat(chatId).sendMessage(chatId, messages.join('\n'), { threadId: incoming.threadId });
771
+ }
772
+ // Transcribe voice messages
773
+ for (const attachment of attachments) {
774
+ if (attachment.type === 'voice' && attachment.local_path) {
775
+ try {
776
+ const transcript = await transcribeVoice(attachment.local_path);
777
+ if (transcript) {
778
+ attachment.transcript = transcript;
779
+ }
780
+ }
781
+ catch (err) {
782
+ logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Voice transcription failed');
783
+ }
784
+ }
785
+ }
786
+ if (downloadedAny) {
787
+ try {
788
+ storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, attachments);
789
+ }
790
+ catch (error) {
791
+ logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
792
+ }
793
+ }
794
+ }
795
+ void emitHook('message:received', {
796
+ chat_id: chatId,
797
+ message_id: incoming.messageId,
798
+ sender_id: incoming.senderId,
799
+ sender_name: incoming.senderName,
800
+ content: incoming.content.slice(0, 500),
801
+ is_group: isGroup,
802
+ has_attachments: attachments.length > 0,
803
+ has_transcript: attachments.some(a => !!a.transcript)
804
+ });
805
+ pipeline.enqueueMessage({
806
+ chatId,
807
+ messageId: incoming.messageId,
808
+ senderId: incoming.senderId,
809
+ senderName: incoming.senderName,
810
+ content: incoming.content,
811
+ timestamp: incoming.timestamp,
812
+ isGroup,
813
+ chatType: incoming.chatType,
814
+ threadId: incoming.threadId,
815
+ attachments: attachments.length > 0 ? attachments : undefined
816
+ });
817
+ }
818
+ catch (err) {
819
+ logger.error({ err, chatId }, 'Error processing incoming message');
820
+ }
821
+ })();
822
+ },
823
+ onReaction(chatId, messageId, userId, emoji) {
824
+ if (emoji !== '👍' && emoji !== '👎')
2809
825
  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
826
  const traceId = getTraceIdForMessage(messageId, chatId);
2815
827
  if (!traceId) {
2816
828
  logger.debug({ chatId, messageId }, 'No trace found for reacted message');
2817
829
  return;
2818
830
  }
2819
- // Record the feedback
2820
831
  const feedbackType = emoji === '👍' ? 'positive' : 'negative';
2821
832
  recordUserFeedback({
2822
833
  trace_id: traceId,
@@ -2826,48 +837,19 @@ function setupTelegramHandlers() {
2826
837
  user_id: userId
2827
838
  });
2828
839
  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));
840
+ },
841
+ onButtonClick(chatId, senderId, senderName, label, data, threadId) {
2858
842
  const group = registeredGroups[chatId];
2859
843
  if (!group)
2860
844
  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}`;
845
+ const chatType = 'private'; // Best guess for callback queries
846
+ const isGroup = false;
847
+ const timestamp = new Date().toISOString();
848
+ const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
849
+ const syntheticContent = `[Button clicked: "${label}"] callback_data: ${data}`;
2868
850
  upsertChat({ chatId, lastMessageTime: timestamp });
2869
851
  storeMessage(syntheticMessageId, chatId, senderId, senderName, syntheticContent, timestamp, false);
2870
- enqueueMessage({
852
+ pipeline.enqueueMessage({
2871
853
  chatId,
2872
854
  messageId: syntheticMessageId,
2873
855
  senderId,
@@ -2876,199 +858,63 @@ function setupTelegramHandlers() {
2876
858
  timestamp,
2877
859
  isGroup,
2878
860
  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
2938
- });
2939
- }
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
861
+ threadId,
2966
862
  });
2967
863
  }
2968
- // Skip messages with no text content AND no attachments (stickers, etc.)
2969
- if (!content && attachments.length === 0) {
2970
- return;
864
+ };
865
+ }
866
+ // ───────────────────────── Wake Recovery ─────────────────────────
867
+ let providerRegistry;
868
+ let messagePipeline;
869
+ async function onWakeRecovery(sleepDurationMs) {
870
+ logger.info({ sleepDurationMs }, 'Running wake recovery');
871
+ // 1. Suppress daemon health check kills for 60s
872
+ suppressHealthChecks(60_000);
873
+ resetUnhealthyDaemons();
874
+ // 2. Reconnect all providers (skip those that were never started)
875
+ for (const provider of providerRegistry.getAllProviders()) {
876
+ if (!provider.isConnected()) {
877
+ logger.debug({ provider: provider.name }, 'Skipping wake reconnect for inactive provider');
878
+ continue;
2971
879
  }
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
880
  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
- }
881
+ if (provider.name === 'telegram')
882
+ setTelegramConnected(false);
883
+ await provider.stop();
884
+ await sleep(1_000);
885
+ await provider.start(createProviderHandlers(providerRegistry, messagePipeline));
886
+ if (provider.name === 'telegram')
887
+ setTelegramConnected(true);
888
+ logger.info({ provider: provider.name }, 'Provider reconnected after wake');
3011
889
  }
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;
3020
- }
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;
890
+ catch (err) {
891
+ logger.error({ err, provider: provider.name }, 'Failed to reconnect provider after wake');
3028
892
  }
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
- }
893
+ }
894
+ // 3. Reset stalled messages
895
+ try {
896
+ const resetCount = resetStalledMessages(1_000);
897
+ if (resetCount > 0)
898
+ logger.info({ resetCount }, 'Reset stalled messages after wake');
899
+ }
900
+ catch (err) {
901
+ logger.error({ err }, 'Failed to reset stalled messages after wake');
902
+ }
903
+ // 4. Re-drain pending message queues
904
+ try {
905
+ const pendingChats = getChatsWithPendingMessages();
906
+ const activeDrains = getActiveDrains();
907
+ for (const chatId of pendingChats) {
908
+ if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
909
+ void messagePipeline.drainQueue(chatId);
3056
910
  }
3057
911
  }
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
- });
912
+ }
913
+ catch (err) {
914
+ logger.error({ err }, 'Failed to resume message drains after wake');
915
+ }
3071
916
  }
917
+ // ───────────────────────── Docker Check ─────────────────────────
3072
918
  function ensureDockerRunning() {
3073
919
  try {
3074
920
  execSync('docker info', { stdio: 'pipe', timeout: 10000 });
@@ -3076,7 +922,6 @@ function ensureDockerRunning() {
3076
922
  }
3077
923
  catch {
3078
924
  logger.error('Docker daemon is not running');
3079
- // Intentionally using console.error for maximum visibility on fatal exit
3080
925
  console.error('\n╔════════════════════════════════════════════════════════════════╗');
3081
926
  console.error('║ FATAL: Docker is not running ║');
3082
927
  console.error('║ ║');
@@ -3089,47 +934,35 @@ function ensureDockerRunning() {
3089
934
  throw new Error('Docker is required but not running');
3090
935
  }
3091
936
  }
937
+ // ───────────────────────── Main ─────────────────────────
3092
938
  async function main() {
3093
- // Global error handlers — keep the process alive on unexpected errors
3094
939
  process.on('unhandledRejection', (reason) => {
3095
940
  logger.error({ err: reason }, 'Unhandled promise rejection');
3096
941
  });
3097
942
  process.on('uncaughtException', (err) => {
3098
943
  logger.error({ err }, 'Uncaught exception');
3099
- // Only exit for fatal system errors (out of memory, etc.)
3100
944
  if (err instanceof RangeError || err instanceof TypeError) {
3101
945
  logger.error('Fatal uncaught exception — exiting');
3102
946
  process.exit(1);
3103
947
  }
3104
948
  });
3105
- // Ensure directory structure exists before anything else
3106
949
  const { ensureDirectoryStructure } = await import('./paths.js');
3107
950
  ensureDirectoryStructure();
3108
951
  try {
3109
952
  const envStat = fs.existsSync(ENV_PATH) ? fs.statSync(ENV_PATH) : null;
3110
953
  if (!envStat || envStat.size === 0) {
3111
- logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; set TELEGRAM_BOT_TOKEN and OPENROUTER_API_KEY');
954
+ logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; run "dotclaw configure" to set up provider tokens and API keys');
3112
955
  }
3113
956
  }
3114
957
  catch (err) {
3115
958
  logger.warn({ envPath: ENV_PATH, err }, 'Failed to check .env file');
3116
959
  }
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
960
  ensureDockerRunning();
3124
961
  initDatabase();
3125
962
  const resetCount = resetStalledMessages();
3126
963
  if (resetCount > 0) {
3127
964
  logger.info({ resetCount }, 'Reset stalled queue messages to pending');
3128
965
  }
3129
- const resetJobCount = resetStalledBackgroundJobs();
3130
- if (resetJobCount > 0) {
3131
- logger.info({ count: resetJobCount }, 'Re-queued running background jobs after restart');
3132
- }
3133
966
  initMemoryStore();
3134
967
  startEmbeddingWorker();
3135
968
  const expiredMemories = cleanupExpiredMemories();
@@ -3142,6 +975,41 @@ async function main() {
3142
975
  }
3143
976
  startMetricsServer();
3144
977
  loadState();
978
+ // ──── Provider Registry ────
979
+ providerRegistry = new ProviderRegistry();
980
+ // Register Telegram provider (optional — only when enabled + token present)
981
+ let telegramProvider = null;
982
+ if (runtime.host.telegram.enabled && process.env.TELEGRAM_BOT_TOKEN) {
983
+ telegramProvider = createTelegramProvider(runtime, GROUPS_DIR);
984
+ providerRegistry.register(telegramProvider);
985
+ logger.info('Telegram provider registered');
986
+ }
987
+ else if (runtime.host.telegram.enabled && !process.env.TELEGRAM_BOT_TOKEN) {
988
+ logger.warn('Telegram is enabled in config but TELEGRAM_BOT_TOKEN is not set — skipping');
989
+ }
990
+ // Register Discord provider (optional — only when enabled + token present)
991
+ let discordProvider = null;
992
+ if (runtime.host.discord.enabled && process.env.DISCORD_BOT_TOKEN) {
993
+ const { createDiscordProvider } = await import('./providers/discord/index.js');
994
+ discordProvider = createDiscordProvider(runtime);
995
+ providerRegistry.register(discordProvider);
996
+ logger.info('Discord provider registered');
997
+ }
998
+ else if (runtime.host.discord.enabled && !process.env.DISCORD_BOT_TOKEN) {
999
+ logger.warn('Discord is enabled in config but DISCORD_BOT_TOKEN is not set — skipping');
1000
+ }
1001
+ // ──── Message Pipeline ────
1002
+ messagePipeline = createMessagePipeline({
1003
+ registry: providerRegistry,
1004
+ registeredGroups: () => registeredGroups,
1005
+ sessions: () => sessions,
1006
+ setSession: (folder, id) => {
1007
+ sessions[folder] = id;
1008
+ setGroupSession(folder, id);
1009
+ },
1010
+ buildAvailableGroupsSnapshot,
1011
+ });
1012
+ // Warm containers
3145
1013
  if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
3146
1014
  const groups = Object.values(registeredGroups);
3147
1015
  for (const group of groups) {
@@ -3154,23 +1022,31 @@ async function main() {
3154
1022
  }
3155
1023
  }
3156
1024
  }
3157
- // Resume any pending message queues from before restart
1025
+ // Resume pending message queues from before restart
3158
1026
  const pendingChats = getChatsWithPendingMessages();
3159
1027
  for (const chatId of pendingChats) {
3160
1028
  if (registeredGroups[chatId]) {
3161
1029
  logger.info({ chatId }, 'Resuming message queue drain after restart');
3162
- void drainQueue(chatId);
1030
+ void messagePipeline.drainQueue(chatId);
3163
1031
  }
3164
1032
  }
3165
- // Set up Telegram message handlers
3166
- setupTelegramHandlers();
3167
1033
  // Start dashboard
3168
1034
  startDashboard();
3169
- // Start Telegram bot
1035
+ // ──── Start Providers ────
1036
+ const handlers = createProviderHandlers(providerRegistry, messagePipeline);
3170
1037
  try {
3171
- telegrafBot.launch();
3172
- setTelegramConnected(true);
3173
- logger.info('Telegram bot started');
1038
+ if (telegramProvider) {
1039
+ await telegramProvider.start(handlers);
1040
+ setTelegramConnected(true);
1041
+ logger.info('Telegram bot started');
1042
+ }
1043
+ if (discordProvider) {
1044
+ await discordProvider.start(handlers);
1045
+ logger.info('Discord bot started');
1046
+ }
1047
+ if (!telegramProvider && !discordProvider) {
1048
+ throw new Error('No messaging providers configured. Set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN.');
1049
+ }
3174
1050
  // Graceful shutdown
3175
1051
  let shuttingDown = false;
3176
1052
  const gracefulShutdown = async (signal) => {
@@ -3180,26 +1056,32 @@ async function main() {
3180
1056
  logger.info({ signal }, 'Graceful shutdown initiated');
3181
1057
  // 1. Stop accepting new work
3182
1058
  setTelegramConnected(false);
3183
- telegrafBot.stop(signal);
1059
+ for (const p of providerRegistry.getAllProviders()) {
1060
+ try {
1061
+ await p.stop();
1062
+ }
1063
+ catch { /* ignore */ }
1064
+ }
3184
1065
  // 2. Stop all loops and watchers
3185
1066
  clearInterval(rateLimiterInterval);
3186
1067
  stopSchedulerLoop();
3187
- await stopBackgroundJobLoop();
3188
1068
  stopIpcWatcher();
3189
1069
  stopMaintenanceLoop();
3190
1070
  stopHeartbeatLoop();
3191
1071
  stopDaemonHealthCheckLoop();
3192
1072
  stopWakeDetector();
3193
- stopEmbeddingWorker();
1073
+ await stopEmbeddingWorker();
3194
1074
  // 3. Stop HTTP servers
3195
1075
  stopMetricsServer();
3196
1076
  stopDashboard();
3197
1077
  // 4. Abort active agent runs so drain loops can finish quickly
1078
+ const activeRuns = getActiveRuns();
3198
1079
  for (const [chatId, controller] of activeRuns.entries()) {
3199
1080
  logger.info({ chatId }, 'Aborting active agent run for shutdown');
3200
1081
  controller.abort();
3201
1082
  }
3202
1083
  // Wait for active drain loops to finish
1084
+ const activeDrains = getActiveDrains();
3203
1085
  const drainDeadline = Date.now() + 30_000;
3204
1086
  while (activeDrains.size > 0 && Date.now() < drainDeadline) {
3205
1087
  await new Promise(r => setTimeout(r, 200));
@@ -3217,10 +1099,9 @@ async function main() {
3217
1099
  };
3218
1100
  process.once('SIGINT', () => void gracefulShutdown('SIGINT'));
3219
1101
  process.once('SIGTERM', () => void gracefulShutdown('SIGTERM'));
3220
- // Start scheduler and IPC watcher
3221
- // Wrapper that matches the scheduler's expected interface (Promise<void>)
1102
+ // ──── Start Services ────
3222
1103
  const sendMessageForScheduler = async (jid, text) => {
3223
- const result = await sendMessage(jid, text);
1104
+ const result = await providerRegistry.getProviderForChat(jid).sendMessage(jid, text);
3224
1105
  if (!result.success) {
3225
1106
  throw new Error(`Failed to send message to chat ${jid}`);
3226
1107
  }
@@ -3234,24 +1115,26 @@ async function main() {
3234
1115
  setGroupSession(groupFolder, sessionId);
3235
1116
  }
3236
1117
  });
3237
- startBackgroundJobLoop({
3238
- sendMessage: sendMessageForScheduler,
1118
+ startIpcWatcher({
1119
+ registry: providerRegistry,
3239
1120
  registeredGroups: () => registeredGroups,
3240
- getSessions: () => sessions,
3241
- setSession: (groupFolder, sessionId) => {
3242
- sessions[groupFolder] = sessionId;
3243
- setGroupSession(groupFolder, sessionId);
1121
+ registerGroup,
1122
+ unregisterGroup,
1123
+ listRegisteredGroups,
1124
+ sessions: () => sessions,
1125
+ setSession: (folder, id) => {
1126
+ sessions[folder] = id;
1127
+ setGroupSession(folder, id);
3244
1128
  }
3245
1129
  });
3246
- startIpcWatcher();
3247
1130
  startMaintenanceLoop();
3248
1131
  startHeartbeatLoop();
3249
1132
  startDaemonHealthCheckLoop(() => registeredGroups, MAIN_GROUP_FOLDER);
3250
1133
  startWakeDetector((ms) => { void onWakeRecovery(ms); });
3251
- logger.info('DotClaw running on Telegram (responds to DMs and group mentions/replies)');
1134
+ logger.info('DotClaw running (responds to DMs and group mentions/replies)');
3252
1135
  }
3253
1136
  catch (error) {
3254
- logger.error({ error }, 'Failed to start Telegram bot');
1137
+ logger.error({ err: error instanceof Error ? error : new Error(String(error)) }, 'Failed to start DotClaw');
3255
1138
  process.exit(1);
3256
1139
  }
3257
1140
  }