@dotsetlabs/dotclaw 1.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 (170) hide show
  1. package/.env.example +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/config-examples/groups/global/CLAUDE.md +21 -0
  5. package/config-examples/groups/main/CLAUDE.md +47 -0
  6. package/config-examples/mount-allowlist.json +25 -0
  7. package/config-examples/plugin-http.json +18 -0
  8. package/config-examples/runtime.json +30 -0
  9. package/config-examples/tool-budgets.json +24 -0
  10. package/config-examples/tool-policy.json +51 -0
  11. package/container/.dockerignore +6 -0
  12. package/container/Dockerfile +74 -0
  13. package/container/agent-runner/package-lock.json +92 -0
  14. package/container/agent-runner/package.json +20 -0
  15. package/container/agent-runner/src/agent-config.ts +295 -0
  16. package/container/agent-runner/src/container-protocol.ts +73 -0
  17. package/container/agent-runner/src/daemon.ts +91 -0
  18. package/container/agent-runner/src/index.ts +1428 -0
  19. package/container/agent-runner/src/ipc.ts +321 -0
  20. package/container/agent-runner/src/memory.ts +336 -0
  21. package/container/agent-runner/src/prompt-packs.ts +341 -0
  22. package/container/agent-runner/src/tools.ts +1720 -0
  23. package/container/agent-runner/tsconfig.json +19 -0
  24. package/container/build.sh +23 -0
  25. package/container/skills/agent-browser.md +159 -0
  26. package/dist/admin-commands.d.ts +7 -0
  27. package/dist/admin-commands.d.ts.map +1 -0
  28. package/dist/admin-commands.js +87 -0
  29. package/dist/admin-commands.js.map +1 -0
  30. package/dist/agent-context.d.ts +42 -0
  31. package/dist/agent-context.d.ts.map +1 -0
  32. package/dist/agent-context.js +92 -0
  33. package/dist/agent-context.js.map +1 -0
  34. package/dist/agent-execution.d.ts +68 -0
  35. package/dist/agent-execution.d.ts.map +1 -0
  36. package/dist/agent-execution.js +169 -0
  37. package/dist/agent-execution.js.map +1 -0
  38. package/dist/agent-semaphore.d.ts +2 -0
  39. package/dist/agent-semaphore.d.ts.map +1 -0
  40. package/dist/agent-semaphore.js +52 -0
  41. package/dist/agent-semaphore.js.map +1 -0
  42. package/dist/behavior-config.d.ts +14 -0
  43. package/dist/behavior-config.d.ts.map +1 -0
  44. package/dist/behavior-config.js +52 -0
  45. package/dist/behavior-config.js.map +1 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.d.ts.map +1 -0
  48. package/dist/cli.js +626 -0
  49. package/dist/cli.js.map +1 -0
  50. package/dist/config.d.ts +31 -0
  51. package/dist/config.d.ts.map +1 -0
  52. package/dist/config.js +38 -0
  53. package/dist/config.js.map +1 -0
  54. package/dist/container-protocol.d.ts +72 -0
  55. package/dist/container-protocol.d.ts.map +1 -0
  56. package/dist/container-protocol.js +3 -0
  57. package/dist/container-protocol.js.map +1 -0
  58. package/dist/container-runner.d.ts +59 -0
  59. package/dist/container-runner.d.ts.map +1 -0
  60. package/dist/container-runner.js +813 -0
  61. package/dist/container-runner.js.map +1 -0
  62. package/dist/cost.d.ts +9 -0
  63. package/dist/cost.d.ts.map +1 -0
  64. package/dist/cost.js +11 -0
  65. package/dist/cost.js.map +1 -0
  66. package/dist/dashboard.d.ts +58 -0
  67. package/dist/dashboard.d.ts.map +1 -0
  68. package/dist/dashboard.js +471 -0
  69. package/dist/dashboard.js.map +1 -0
  70. package/dist/db.d.ts +99 -0
  71. package/dist/db.d.ts.map +1 -0
  72. package/dist/db.js +423 -0
  73. package/dist/db.js.map +1 -0
  74. package/dist/error-messages.d.ts +17 -0
  75. package/dist/error-messages.d.ts.map +1 -0
  76. package/dist/error-messages.js +109 -0
  77. package/dist/error-messages.js.map +1 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +2072 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/locks.d.ts +2 -0
  83. package/dist/locks.d.ts.map +1 -0
  84. package/dist/locks.js +26 -0
  85. package/dist/locks.js.map +1 -0
  86. package/dist/logger.d.ts +4 -0
  87. package/dist/logger.d.ts.map +1 -0
  88. package/dist/logger.js +15 -0
  89. package/dist/logger.js.map +1 -0
  90. package/dist/maintenance.d.ts +13 -0
  91. package/dist/maintenance.d.ts.map +1 -0
  92. package/dist/maintenance.js +151 -0
  93. package/dist/maintenance.js.map +1 -0
  94. package/dist/memory-embeddings.d.ts +13 -0
  95. package/dist/memory-embeddings.d.ts.map +1 -0
  96. package/dist/memory-embeddings.js +126 -0
  97. package/dist/memory-embeddings.js.map +1 -0
  98. package/dist/memory-recall.d.ts +8 -0
  99. package/dist/memory-recall.d.ts.map +1 -0
  100. package/dist/memory-recall.js +127 -0
  101. package/dist/memory-recall.js.map +1 -0
  102. package/dist/memory-store.d.ts +149 -0
  103. package/dist/memory-store.d.ts.map +1 -0
  104. package/dist/memory-store.js +787 -0
  105. package/dist/memory-store.js.map +1 -0
  106. package/dist/metrics.d.ts +12 -0
  107. package/dist/metrics.d.ts.map +1 -0
  108. package/dist/metrics.js +134 -0
  109. package/dist/metrics.js.map +1 -0
  110. package/dist/model-registry.d.ts +67 -0
  111. package/dist/model-registry.d.ts.map +1 -0
  112. package/dist/model-registry.js +230 -0
  113. package/dist/model-registry.js.map +1 -0
  114. package/dist/mount-security.d.ts +37 -0
  115. package/dist/mount-security.d.ts.map +1 -0
  116. package/dist/mount-security.js +284 -0
  117. package/dist/mount-security.js.map +1 -0
  118. package/dist/paths.d.ts +80 -0
  119. package/dist/paths.d.ts.map +1 -0
  120. package/dist/paths.js +149 -0
  121. package/dist/paths.js.map +1 -0
  122. package/dist/personalization.d.ts +6 -0
  123. package/dist/personalization.d.ts.map +1 -0
  124. package/dist/personalization.js +180 -0
  125. package/dist/personalization.js.map +1 -0
  126. package/dist/progress.d.ts +15 -0
  127. package/dist/progress.d.ts.map +1 -0
  128. package/dist/progress.js +92 -0
  129. package/dist/progress.js.map +1 -0
  130. package/dist/runtime-config.d.ts +227 -0
  131. package/dist/runtime-config.d.ts.map +1 -0
  132. package/dist/runtime-config.js +297 -0
  133. package/dist/runtime-config.js.map +1 -0
  134. package/dist/task-scheduler.d.ts +9 -0
  135. package/dist/task-scheduler.d.ts.map +1 -0
  136. package/dist/task-scheduler.js +195 -0
  137. package/dist/task-scheduler.js.map +1 -0
  138. package/dist/telegram-format.d.ts +3 -0
  139. package/dist/telegram-format.d.ts.map +1 -0
  140. package/dist/telegram-format.js +200 -0
  141. package/dist/telegram-format.js.map +1 -0
  142. package/dist/tool-budgets.d.ts +16 -0
  143. package/dist/tool-budgets.d.ts.map +1 -0
  144. package/dist/tool-budgets.js +83 -0
  145. package/dist/tool-budgets.js.map +1 -0
  146. package/dist/tool-policy.d.ts +18 -0
  147. package/dist/tool-policy.d.ts.map +1 -0
  148. package/dist/tool-policy.js +84 -0
  149. package/dist/tool-policy.js.map +1 -0
  150. package/dist/trace-writer.d.ts +39 -0
  151. package/dist/trace-writer.d.ts.map +1 -0
  152. package/dist/trace-writer.js +27 -0
  153. package/dist/trace-writer.js.map +1 -0
  154. package/dist/types.d.ts +81 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +2 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/utils.d.ts +4 -0
  159. package/dist/utils.d.ts.map +1 -0
  160. package/dist/utils.js +30 -0
  161. package/dist/utils.js.map +1 -0
  162. package/launchd/com.dotclaw.plist +32 -0
  163. package/package.json +89 -0
  164. package/scripts/autotune.js +53 -0
  165. package/scripts/bootstrap.js +348 -0
  166. package/scripts/configure.js +200 -0
  167. package/scripts/doctor.js +164 -0
  168. package/scripts/init.js +209 -0
  169. package/scripts/install.sh +219 -0
  170. package/systemd/dotclaw.service +22 -0
package/dist/index.js ADDED
@@ -0,0 +1,2072 @@
1
+ import dotenv from 'dotenv';
2
+ import { Telegraf } from 'telegraf';
3
+ import { execSync } from 'child_process';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, IPC_POLL_INTERVAL, TIMEZONE, CONTAINER_MODE, WARM_START_ENABLED, ENV_PATH } from './config.js';
7
+ // Load .env from the canonical location (~/.dotclaw/.env)
8
+ dotenv.config({ path: ENV_PATH });
9
+ import { initDatabase, storeMessage, getMessagesSinceCursor, getChatState, updateChatState, createTask, updateTask, deleteTask, getTaskById, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, linkMessageToTrace, getTraceIdForMessage, recordUserFeedback } from './db.js';
10
+ import { startSchedulerLoop } from './task-scheduler.js';
11
+ import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
12
+ import { writeTrace } from './trace-writer.js';
13
+ import { formatTelegramMessage, TELEGRAM_PARSE_MODE } from './telegram-format.js';
14
+ import { initMemoryStore, getMemoryStats, upsertMemoryItems, searchMemories, listMemories, forgetMemories, cleanupExpiredMemories } from './memory-store.js';
15
+ import { startEmbeddingWorker } from './memory-embeddings.js';
16
+ import { createProgressNotifier, DEFAULT_PROGRESS_MESSAGES } from './progress.js';
17
+ import { parseAdminCommand } from './admin-commands.js';
18
+ import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
19
+ import { startMetricsServer, recordMessage, recordError } from './metrics.js';
20
+ import { startMaintenanceLoop } from './maintenance.js';
21
+ import { warmGroupContainer, startDaemonHealthCheckLoop } from './container-runner.js';
22
+ import { loadRuntimeConfig } from './runtime-config.js';
23
+ import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionError } from './agent-execution.js';
24
+ import { logger } from './logger.js';
25
+ import { startDashboard, setTelegramConnected, setLastMessageTime, setMessageQueueDepth } from './dashboard.js';
26
+ import { humanizeError } from './error-messages.js';
27
+ const runtime = loadRuntimeConfig();
28
+ function buildTriggerRegex(pattern) {
29
+ if (!pattern)
30
+ return null;
31
+ try {
32
+ return new RegExp(pattern, 'i');
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function isPreemptedError(err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ return message.toLowerCase().includes('preempt');
41
+ }
42
+ function buildAvailableGroupsSnapshot() {
43
+ return Object.entries(registeredGroups).map(([jid, info]) => ({
44
+ jid,
45
+ name: info.name,
46
+ lastActivity: getChatState(jid)?.last_agent_timestamp || info.added_at,
47
+ isRegistered: true
48
+ }));
49
+ }
50
+ function isRecord(value) {
51
+ return typeof value === 'object' && value !== null;
52
+ }
53
+ function isMemoryScope(value) {
54
+ return value === 'user' || value === 'group' || value === 'global';
55
+ }
56
+ function isMemoryType(value) {
57
+ return value === 'identity'
58
+ || value === 'preference'
59
+ || value === 'fact'
60
+ || value === 'relationship'
61
+ || value === 'project'
62
+ || value === 'task'
63
+ || value === 'note'
64
+ || value === 'archive';
65
+ }
66
+ function isMemoryKind(value) {
67
+ return value === 'semantic'
68
+ || value === 'episodic'
69
+ || value === 'procedural'
70
+ || value === 'preference';
71
+ }
72
+ function clampInputMessage(content, maxChars) {
73
+ if (!content)
74
+ return '';
75
+ if (!Number.isFinite(maxChars) || maxChars <= 0)
76
+ return content;
77
+ if (content.length <= maxChars)
78
+ return content;
79
+ return `${content.slice(0, maxChars)}\n\n[Message truncated for length]`;
80
+ }
81
+ function coerceMemoryItems(input) {
82
+ if (!Array.isArray(input))
83
+ return [];
84
+ const items = [];
85
+ for (const raw of input) {
86
+ if (!isRecord(raw))
87
+ continue;
88
+ const scope = raw.scope;
89
+ const type = raw.type;
90
+ const kind = raw.kind;
91
+ const content = raw.content;
92
+ if (!isMemoryScope(scope) || !isMemoryType(type) || typeof content !== 'string' || !content.trim()) {
93
+ continue;
94
+ }
95
+ items.push({
96
+ scope,
97
+ type,
98
+ kind: isMemoryKind(kind) ? kind : undefined,
99
+ conflict_key: typeof raw.conflict_key === 'string' ? raw.conflict_key : undefined,
100
+ content: content.trim(),
101
+ subject_id: typeof raw.subject_id === 'string' ? raw.subject_id : null,
102
+ importance: typeof raw.importance === 'number' ? raw.importance : undefined,
103
+ confidence: typeof raw.confidence === 'number' ? raw.confidence : undefined,
104
+ tags: Array.isArray(raw.tags) ? raw.tags.filter((tag) => typeof tag === 'string') : undefined,
105
+ ttl_days: typeof raw.ttl_days === 'number' ? raw.ttl_days : undefined,
106
+ source: typeof raw.source === 'string' ? raw.source : undefined,
107
+ metadata: isRecord(raw.metadata) ? raw.metadata : undefined
108
+ });
109
+ }
110
+ return items;
111
+ }
112
+ // Rate limiting configuration
113
+ const RATE_LIMIT_MAX_MESSAGES = 20; // Max messages per user per window
114
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window
115
+ const rateLimiter = new Map();
116
+ function checkRateLimit(userId) {
117
+ const now = Date.now();
118
+ const entry = rateLimiter.get(userId);
119
+ if (!entry || now > entry.resetAt) {
120
+ // New window
121
+ rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
122
+ return { allowed: true };
123
+ }
124
+ if (entry.count >= RATE_LIMIT_MAX_MESSAGES) {
125
+ // Rate limited
126
+ return { allowed: false, retryAfterMs: entry.resetAt - now };
127
+ }
128
+ // Increment counter
129
+ entry.count += 1;
130
+ return { allowed: true };
131
+ }
132
+ function cleanupRateLimiter() {
133
+ const now = Date.now();
134
+ for (const [key, entry] of rateLimiter.entries()) {
135
+ if (now > entry.resetAt) {
136
+ rateLimiter.delete(key);
137
+ }
138
+ }
139
+ }
140
+ // Clean up expired rate limit entries periodically
141
+ setInterval(cleanupRateLimiter, 60_000);
142
+ const TELEGRAM_HANDLER_TIMEOUT_MS = runtime.host.telegram.handlerTimeoutMs;
143
+ const TELEGRAM_SEND_RETRIES = runtime.host.telegram.sendRetries;
144
+ const TELEGRAM_SEND_RETRY_DELAY_MS = runtime.host.telegram.sendRetryDelayMs;
145
+ const TELEGRAM_STREAM_MODE = runtime.host.telegram.streamMode.toLowerCase();
146
+ const TELEGRAM_STREAM_MIN_INTERVAL_MS = runtime.host.telegram.streamMinIntervalMs;
147
+ const TELEGRAM_STREAM_MIN_CHARS = runtime.host.telegram.streamMinChars;
148
+ const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
149
+ const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
150
+ const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
151
+ const PROGRESS_ENABLED = runtime.host.progress.enabled;
152
+ const PROGRESS_INITIAL_MS = runtime.host.progress.initialMs;
153
+ const PROGRESS_INTERVAL_MS = runtime.host.progress.intervalMs;
154
+ const PROGRESS_MAX_UPDATES = runtime.host.progress.maxUpdates;
155
+ const PROGRESS_MESSAGES = runtime.host.progress.messages.length > 0
156
+ ? runtime.host.progress.messages
157
+ : DEFAULT_PROGRESS_MESSAGES;
158
+ const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
159
+ const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
160
+ const HEARTBEAT_GROUP_FOLDER = (runtime.host.heartbeat.groupFolder || MAIN_GROUP_FOLDER).trim() || MAIN_GROUP_FOLDER;
161
+ const BACKGROUND_TASKS_ENABLED = runtime.host.backgroundTasks.enabled;
162
+ const BACKGROUND_TRIGGER_REGEX = runtime.host.backgroundTasks.triggerRegex;
163
+ const BACKGROUND_TRIGGER = buildTriggerRegex(BACKGROUND_TRIGGER_REGEX);
164
+ const BACKGROUND_ACK_MESSAGE = runtime.host.backgroundTasks.ackMessage;
165
+ const BACKGROUND_TOOL_DENY = runtime.host.backgroundTasks.toolDeny;
166
+ const PREEMPT_ON_NEW_MESSAGE = runtime.host.backgroundTasks.preemptOnNewMessage;
167
+ function shouldRunInBackground(content) {
168
+ if (!BACKGROUND_TASKS_ENABLED)
169
+ return false;
170
+ const trimmed = content.trim();
171
+ if (!trimmed)
172
+ return false;
173
+ if (BACKGROUND_TRIGGER && BACKGROUND_TRIGGER.test(trimmed))
174
+ return true;
175
+ return false;
176
+ }
177
+ // Initialize Telegram bot with extended timeout for long-running agent tasks
178
+ const telegrafBot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN, {
179
+ handlerTimeout: TELEGRAM_HANDLER_TIMEOUT_MS
180
+ });
181
+ telegrafBot.catch((err, ctx) => {
182
+ logger.error({ err, chatId: ctx?.chat?.id }, 'Unhandled Telegraf error');
183
+ });
184
+ let sessions = {};
185
+ let registeredGroups = {};
186
+ const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
187
+ const TELEGRAM_SEND_DELAY_MS = 250;
188
+ const messageQueues = new Map();
189
+ const inFlightRuns = new Map();
190
+ const draftSessions = new Map();
191
+ function parseTelegramStreamMode(value) {
192
+ const normalized = value.trim().toLowerCase();
193
+ if (normalized === 'draft' || normalized === 'edit' || normalized === 'auto' || normalized === 'off') {
194
+ return normalized;
195
+ }
196
+ return 'off';
197
+ }
198
+ function getDraftKey(chatId, draftId) {
199
+ return `${chatId}:${draftId}`;
200
+ }
201
+ function createDraftId() {
202
+ const max = 2_147_483_647;
203
+ return Math.floor(Math.random() * (max - 1)) + 1;
204
+ }
205
+ async function setTyping(chatId) {
206
+ try {
207
+ await telegrafBot.telegram.sendChatAction(chatId, 'typing');
208
+ }
209
+ catch (err) {
210
+ logger.debug({ chatId, err }, 'Failed to set typing indicator');
211
+ }
212
+ }
213
+ function canUseTelegramDraft(msg) {
214
+ return msg.chatType === 'private' && Number.isFinite(msg.messageThreadId);
215
+ }
216
+ function registerDraftSession(msg) {
217
+ const mode = parseTelegramStreamMode(TELEGRAM_STREAM_MODE);
218
+ if (mode === 'off')
219
+ return null;
220
+ if (msg.chatType !== 'private')
221
+ return null;
222
+ const supportsDraft = canUseTelegramDraft(msg);
223
+ const resolvedMode = mode === 'auto'
224
+ ? (supportsDraft ? 'draft' : 'edit')
225
+ : (mode === 'draft' ? (supportsDraft ? 'draft' : 'edit') : (mode === 'edit' ? 'edit' : null));
226
+ if (!resolvedMode)
227
+ return null;
228
+ const draftId = createDraftId();
229
+ draftSessions.set(getDraftKey(msg.chatId, draftId), {
230
+ mode: resolvedMode,
231
+ messageThreadId: msg.messageThreadId,
232
+ started: false,
233
+ lastSentAt: 0,
234
+ lastChunk: undefined
235
+ });
236
+ return { mode: resolvedMode, draftId };
237
+ }
238
+ function clearDraftSession(chatId, draftId) {
239
+ draftSessions.delete(getDraftKey(chatId, draftId));
240
+ }
241
+ function sleep(ms) {
242
+ return new Promise(resolve => setTimeout(resolve, ms));
243
+ }
244
+ function isBotMentioned(text, entities, botUsername, botId) {
245
+ if (!entities || entities.length === 0)
246
+ return false;
247
+ const normalized = botUsername ? botUsername.toLowerCase() : '';
248
+ for (const entity of entities) {
249
+ const segment = text.slice(entity.offset, entity.offset + entity.length);
250
+ if (entity.type === 'mention') {
251
+ if (segment.toLowerCase() === `@${normalized}`)
252
+ return true;
253
+ }
254
+ if (entity.type === 'text_mention' && botId && entity.user?.id === botId)
255
+ return true;
256
+ if (entity.type === 'bot_command') {
257
+ if (segment.toLowerCase().includes(`@${normalized}`))
258
+ return true;
259
+ }
260
+ }
261
+ return false;
262
+ }
263
+ function isBotReplied(message, botId) {
264
+ if (!message?.reply_to_message?.from?.id || !botId)
265
+ return false;
266
+ return message.reply_to_message.from.id === botId;
267
+ }
268
+ function getTelegramErrorCode(err) {
269
+ const anyErr = err;
270
+ if (typeof anyErr?.code === 'number')
271
+ return anyErr.code;
272
+ if (typeof anyErr?.response?.error_code === 'number')
273
+ return anyErr.response.error_code;
274
+ return null;
275
+ }
276
+ function getTelegramRetryAfterMs(err) {
277
+ const anyErr = err;
278
+ const retryAfter = anyErr?.parameters?.retry_after ?? anyErr?.response?.parameters?.retry_after;
279
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter))
280
+ return retryAfter * 1000;
281
+ if (typeof retryAfter === 'string') {
282
+ const parsed = Number.parseInt(retryAfter, 10);
283
+ if (Number.isFinite(parsed))
284
+ return parsed * 1000;
285
+ }
286
+ return null;
287
+ }
288
+ function isRetryableTelegramError(err) {
289
+ const code = getTelegramErrorCode(err);
290
+ if (code === 429)
291
+ return true;
292
+ if (code && code >= 500 && code < 600)
293
+ return true;
294
+ const anyErr = err;
295
+ if (!anyErr?.code)
296
+ return false;
297
+ return ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND'].includes(anyErr.code);
298
+ }
299
+ function loadState() {
300
+ sessions = {};
301
+ const loadedGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
302
+ const sanitizedGroups = {};
303
+ const usedFolders = new Set();
304
+ let invalidCount = 0;
305
+ let duplicateCount = 0;
306
+ for (const [chatId, group] of Object.entries(loadedGroups)) {
307
+ if (!group || typeof group !== 'object') {
308
+ logger.warn({ chatId }, 'Skipping registered group with invalid entry');
309
+ invalidCount += 1;
310
+ continue;
311
+ }
312
+ if (typeof group.name !== 'string' || group.name.trim() === '') {
313
+ logger.warn({ chatId }, 'Skipping registered group with invalid name');
314
+ invalidCount += 1;
315
+ continue;
316
+ }
317
+ if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
318
+ logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with invalid folder');
319
+ invalidCount += 1;
320
+ continue;
321
+ }
322
+ if (usedFolders.has(group.folder)) {
323
+ logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with duplicate folder');
324
+ duplicateCount += 1;
325
+ continue;
326
+ }
327
+ usedFolders.add(group.folder);
328
+ sanitizedGroups[chatId] = group;
329
+ }
330
+ registeredGroups = sanitizedGroups;
331
+ if (invalidCount > 0 || duplicateCount > 0) {
332
+ logger.error({ invalidCount, duplicateCount }, 'Registered groups contained invalid or duplicate folders');
333
+ }
334
+ logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
335
+ const finalSessions = getAllGroupSessions();
336
+ sessions = finalSessions.reduce((acc, row) => {
337
+ acc[row.group_folder] = row.session_id;
338
+ return acc;
339
+ }, {});
340
+ }
341
+ function registerGroup(chatId, group) {
342
+ if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
343
+ logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with invalid folder');
344
+ return;
345
+ }
346
+ const folderCollision = Object.values(registeredGroups).some(g => g.folder === group.folder);
347
+ if (folderCollision) {
348
+ logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with duplicate folder');
349
+ return;
350
+ }
351
+ registeredGroups[chatId] = group;
352
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
353
+ // Create group folder
354
+ const groupDir = path.join(GROUPS_DIR, group.folder);
355
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
356
+ logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
357
+ if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
358
+ try {
359
+ warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
360
+ logger.info({ group: group.folder }, 'Warmed daemon container for new group');
361
+ }
362
+ catch (err) {
363
+ logger.warn({ group: group.folder, err }, 'Failed to warm container for new group');
364
+ }
365
+ }
366
+ }
367
+ function listRegisteredGroups() {
368
+ return Object.entries(registeredGroups).map(([chatId, group]) => ({
369
+ chat_id: chatId,
370
+ name: group.name,
371
+ folder: group.folder,
372
+ trigger: group.trigger,
373
+ added_at: group.added_at
374
+ }));
375
+ }
376
+ function resolveGroupIdentifier(identifier) {
377
+ const trimmed = identifier.trim();
378
+ if (!trimmed)
379
+ return null;
380
+ const normalized = trimmed.toLowerCase();
381
+ for (const [chatId, group] of Object.entries(registeredGroups)) {
382
+ if (chatId === trimmed)
383
+ return chatId;
384
+ if (group.name.toLowerCase() === normalized)
385
+ return chatId;
386
+ if (group.folder.toLowerCase() === normalized)
387
+ return chatId;
388
+ }
389
+ return null;
390
+ }
391
+ function unregisterGroup(identifier) {
392
+ const chatId = resolveGroupIdentifier(identifier);
393
+ if (!chatId) {
394
+ return { ok: false, error: 'Group not found' };
395
+ }
396
+ const group = registeredGroups[chatId];
397
+ if (!group) {
398
+ return { ok: false, error: 'Group not found' };
399
+ }
400
+ if (group.folder === MAIN_GROUP_FOLDER) {
401
+ return { ok: false, error: 'Cannot remove main group' };
402
+ }
403
+ delete registeredGroups[chatId];
404
+ saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
405
+ delete sessions[group.folder];
406
+ deleteGroupSession(group.folder);
407
+ pauseTasksForGroup(group.folder);
408
+ logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
409
+ return { ok: true, group: { ...group, chat_id: chatId } };
410
+ }
411
+ function splitPlainText(text, maxLength) {
412
+ if (text.length <= maxLength)
413
+ return [text];
414
+ const chunks = [];
415
+ for (let i = 0; i < text.length; i += maxLength) {
416
+ chunks.push(text.slice(i, i + maxLength));
417
+ }
418
+ return chunks;
419
+ }
420
+ async function sendMessage(chatId, text, options) {
421
+ const parseMode = options?.parseMode === undefined ? TELEGRAM_PARSE_MODE : options.parseMode;
422
+ const chunks = parseMode
423
+ ? formatTelegramMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH)
424
+ : splitPlainText(text, TELEGRAM_MAX_MESSAGE_LENGTH);
425
+ let firstMessageId;
426
+ const sendChunk = async (chunk) => {
427
+ for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
428
+ try {
429
+ const payload = {};
430
+ if (parseMode)
431
+ payload.parse_mode = parseMode;
432
+ if (options?.messageThreadId)
433
+ payload.message_thread_id = options.messageThreadId;
434
+ const sent = await telegrafBot.telegram.sendMessage(chatId, chunk, payload);
435
+ if (!firstMessageId) {
436
+ firstMessageId = String(sent.message_id);
437
+ }
438
+ return true;
439
+ }
440
+ catch (err) {
441
+ const retryAfterMs = getTelegramRetryAfterMs(err);
442
+ const retryable = isRetryableTelegramError(err);
443
+ if (!retryable || attempt === TELEGRAM_SEND_RETRIES) {
444
+ logger.error({ chatId, attempt, err }, 'Failed to send Telegram message chunk');
445
+ return false;
446
+ }
447
+ const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
448
+ logger.warn({ chatId, attempt, delayMs }, 'Telegram send failed; retrying');
449
+ await sleep(delayMs);
450
+ }
451
+ }
452
+ return false;
453
+ };
454
+ try {
455
+ // Telegram bots send messages as themselves, no prefix needed
456
+ for (let i = 0; i < chunks.length; i += 1) {
457
+ const ok = await sendChunk(chunks[i]);
458
+ if (!ok)
459
+ return { success: false };
460
+ if (i < chunks.length - 1) {
461
+ await sleep(TELEGRAM_SEND_DELAY_MS);
462
+ }
463
+ }
464
+ logger.info({ chatId, length: text.length }, 'Message sent');
465
+ return { success: true, messageId: firstMessageId };
466
+ }
467
+ catch (err) {
468
+ logger.error({ chatId, err }, 'Failed to send message');
469
+ return { success: false };
470
+ }
471
+ }
472
+ async function sendDraftUpdate(chatId, draftId, text) {
473
+ const key = getDraftKey(chatId, draftId);
474
+ const session = draftSessions.get(key);
475
+ if (!session)
476
+ return;
477
+ if (!text || !text.trim())
478
+ return;
479
+ const now = Date.now();
480
+ if (now - session.lastSentAt < TELEGRAM_STREAM_MIN_INTERVAL_MS)
481
+ return;
482
+ session.lastSentAt = now;
483
+ const chunk = splitPlainText(text, TELEGRAM_MAX_MESSAGE_LENGTH)[0] ?? '';
484
+ if (!chunk)
485
+ return;
486
+ if (session.lastChunk === chunk)
487
+ return;
488
+ session.lastChunk = chunk;
489
+ if (session.mode === 'draft') {
490
+ try {
491
+ await telegrafBot.telegram
492
+ .callApi('sendMessageDraft', {
493
+ chat_id: chatId,
494
+ draft_id: draftId,
495
+ text: chunk,
496
+ message_thread_id: session.messageThreadId
497
+ });
498
+ session.started = true;
499
+ return;
500
+ }
501
+ catch (err) {
502
+ logger.warn({ chatId, err }, 'sendMessageDraft failed; switching to edit fallback');
503
+ session.mode = 'edit';
504
+ }
505
+ }
506
+ if (!session.messageId) {
507
+ try {
508
+ const payload = {};
509
+ if (session.messageThreadId)
510
+ payload.message_thread_id = session.messageThreadId;
511
+ const sent = await telegrafBot.telegram.sendMessage(chatId, chunk, payload);
512
+ session.messageId = sent.message_id;
513
+ session.started = true;
514
+ return;
515
+ }
516
+ catch (err) {
517
+ logger.warn({ chatId, err }, 'Failed to send draft placeholder');
518
+ return;
519
+ }
520
+ }
521
+ try {
522
+ await telegrafBot.telegram.editMessageText(chatId, session.messageId, undefined, chunk);
523
+ session.started = true;
524
+ }
525
+ catch (err) {
526
+ logger.debug({ chatId, err }, 'Failed to edit draft message');
527
+ }
528
+ }
529
+ function isTelegramNotModifiedError(err) {
530
+ const description = err?.response?.description;
531
+ if (typeof description === 'string' && description.toLowerCase().includes('message is not modified')) {
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+ async function finalizeStreamedMessage(msg, draftId, text) {
537
+ if (!draftId) {
538
+ await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId });
539
+ return;
540
+ }
541
+ const key = getDraftKey(msg.chatId, draftId);
542
+ const session = draftSessions.get(key);
543
+ if (!session) {
544
+ await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId });
545
+ return;
546
+ }
547
+ if (session.mode === 'edit' && session.messageId) {
548
+ const chunks = formatTelegramMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH);
549
+ if (chunks.length === 0) {
550
+ clearDraftSession(msg.chatId, draftId);
551
+ return;
552
+ }
553
+ const firstChunk = chunks[0];
554
+ const firstChunkMatches = session.lastChunk === firstChunk;
555
+ try {
556
+ if (!firstChunkMatches) {
557
+ await telegrafBot.telegram.editMessageText(msg.chatId, session.messageId, undefined, firstChunk, { parse_mode: TELEGRAM_PARSE_MODE });
558
+ }
559
+ for (let i = firstChunkMatches ? 1 : 1; i < chunks.length; i += 1) {
560
+ await sendMessage(msg.chatId, chunks[i], { messageThreadId: msg.messageThreadId });
561
+ }
562
+ clearDraftSession(msg.chatId, draftId);
563
+ return;
564
+ }
565
+ catch (err) {
566
+ if (isTelegramNotModifiedError(err)) {
567
+ for (let i = firstChunkMatches ? 1 : 1; i < chunks.length; i += 1) {
568
+ await sendMessage(msg.chatId, chunks[i], { messageThreadId: msg.messageThreadId });
569
+ }
570
+ clearDraftSession(msg.chatId, draftId);
571
+ return;
572
+ }
573
+ logger.warn({ chatId: msg.chatId, err }, 'Failed to finalize streamed edit; sending new message');
574
+ }
575
+ }
576
+ await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId });
577
+ clearDraftSession(msg.chatId, draftId);
578
+ }
579
+ function enqueueMessage(msg) {
580
+ if (PREEMPT_ON_NEW_MESSAGE) {
581
+ const inFlight = inFlightRuns.get(msg.chatId);
582
+ if (inFlight && !inFlight.controller.signal.aborted) {
583
+ logger.warn({ chatId: msg.chatId }, 'Preempting in-flight agent run');
584
+ inFlight.controller.abort();
585
+ }
586
+ }
587
+ const existing = messageQueues.get(msg.chatId);
588
+ if (existing) {
589
+ existing.pendingMessage = msg;
590
+ if (!existing.inFlight) {
591
+ void drainQueue(msg.chatId);
592
+ }
593
+ setMessageQueueDepth(messageQueues.size);
594
+ return;
595
+ }
596
+ messageQueues.set(msg.chatId, { inFlight: false, pendingMessage: msg });
597
+ setMessageQueueDepth(messageQueues.size);
598
+ void drainQueue(msg.chatId);
599
+ }
600
+ async function drainQueue(chatId) {
601
+ const state = messageQueues.get(chatId);
602
+ if (!state || state.inFlight)
603
+ return;
604
+ state.inFlight = true;
605
+ try {
606
+ while (state.pendingMessage) {
607
+ const next = state.pendingMessage;
608
+ state.pendingMessage = undefined;
609
+ await processMessage(next);
610
+ }
611
+ }
612
+ catch (err) {
613
+ logger.error({ chatId, err }, 'Error draining message queue');
614
+ }
615
+ finally {
616
+ state.inFlight = false;
617
+ if (state.pendingMessage) {
618
+ void drainQueue(chatId);
619
+ }
620
+ else {
621
+ messageQueues.delete(chatId);
622
+ }
623
+ setMessageQueueDepth(messageQueues.size);
624
+ }
625
+ }
626
+ async function processMessage(msg) {
627
+ const group = registeredGroups[msg.chatId];
628
+ if (!group) {
629
+ logger.debug({ chatId: msg.chatId }, 'Message from unregistered Telegram chat');
630
+ return false;
631
+ }
632
+ recordMessage('telegram');
633
+ setLastMessageTime(msg.timestamp);
634
+ // Get all messages since last agent interaction so the session has full context
635
+ const chatState = getChatState(msg.chatId);
636
+ let missedMessages = getMessagesSinceCursor(msg.chatId, chatState?.last_agent_timestamp || null, chatState?.last_agent_message_id || null);
637
+ if (missedMessages.length === 0) {
638
+ logger.warn({ chatId: msg.chatId }, 'No missed messages found; falling back to current message');
639
+ missedMessages = [{
640
+ id: msg.messageId,
641
+ chat_jid: msg.chatId,
642
+ sender: msg.senderId,
643
+ sender_name: msg.senderName,
644
+ content: msg.content,
645
+ timestamp: msg.timestamp
646
+ }];
647
+ }
648
+ const lines = missedMessages.map(m => {
649
+ // Escape XML special characters in content
650
+ const escapeXml = (s) => s
651
+ .replace(/&/g, '&amp;')
652
+ .replace(/</g, '&lt;')
653
+ .replace(/>/g, '&gt;')
654
+ .replace(/"/g, '&quot;');
655
+ const safeContent = clampInputMessage(m.content, INPUT_MESSAGE_MAX_CHARS);
656
+ return `<message sender="${escapeXml(m.sender_name)}" sender_id="${escapeXml(m.sender)}" time="${m.timestamp}">${escapeXml(safeContent)}</message>`;
657
+ });
658
+ const prompt = `<messages>
659
+ ${lines.join('\n')}
660
+ </messages>`;
661
+ const lastMessage = missedMessages[missedMessages.length - 1];
662
+ if (lastMessage && shouldRunInBackground(lastMessage.content)) {
663
+ logger.info({ group: group.name }, 'Routing message to background task');
664
+ updateChatState(msg.chatId, lastMessage.timestamp, lastMessage.id);
665
+ await sendMessage(msg.chatId, BACKGROUND_ACK_MESSAGE, { messageThreadId: msg.messageThreadId });
666
+ void runBackgroundTask({
667
+ msg,
668
+ group,
669
+ prompt,
670
+ missedMessages
671
+ });
672
+ return true;
673
+ }
674
+ const traceBase = createTraceBase({
675
+ chatId: msg.chatId,
676
+ groupFolder: group.folder,
677
+ userId: msg.senderId,
678
+ inputText: prompt,
679
+ source: 'dotclaw'
680
+ });
681
+ logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing message');
682
+ await setTyping(msg.chatId);
683
+ const recallQuery = missedMessages.map(entry => entry.content).join('\n');
684
+ const draftSession = registerDraftSession(msg);
685
+ const draftId = draftSession?.draftId ?? null;
686
+ const streamingEnabled = Boolean(draftSession && draftId);
687
+ let output = null;
688
+ let context = null;
689
+ let errorMessage = null;
690
+ const progressNotifier = createProgressNotifier({
691
+ enabled: PROGRESS_ENABLED && !streamingEnabled,
692
+ initialDelayMs: PROGRESS_INITIAL_MS,
693
+ intervalMs: PROGRESS_INTERVAL_MS,
694
+ maxUpdates: PROGRESS_MAX_UPDATES,
695
+ messages: PROGRESS_MESSAGES,
696
+ send: async (text) => { await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId }); },
697
+ onError: (err) => logger.debug({ chatId: msg.chatId, err }, 'Failed to send progress update')
698
+ });
699
+ progressNotifier.start();
700
+ try {
701
+ const execution = await executeAgentRun({
702
+ group,
703
+ prompt,
704
+ chatJid: msg.chatId,
705
+ userId: msg.senderId,
706
+ userName: msg.senderName,
707
+ recallQuery: recallQuery || msg.content,
708
+ recallMaxResults: MEMORY_RECALL_MAX_RESULTS,
709
+ recallMaxTokens: MEMORY_RECALL_MAX_TOKENS,
710
+ sessionId: sessions[group.folder],
711
+ onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
712
+ streaming: streamingEnabled && draftId
713
+ ? {
714
+ enabled: true,
715
+ draftId,
716
+ minIntervalMs: TELEGRAM_STREAM_MIN_INTERVAL_MS,
717
+ minChars: TELEGRAM_STREAM_MIN_CHARS
718
+ }
719
+ : undefined,
720
+ availableGroups: buildAvailableGroupsSnapshot()
721
+ });
722
+ output = execution.output;
723
+ context = execution.context;
724
+ if (output.status === 'error') {
725
+ errorMessage = output.error || 'Unknown error';
726
+ }
727
+ }
728
+ catch (err) {
729
+ if (err instanceof AgentExecutionError) {
730
+ context = err.context;
731
+ errorMessage = err.message;
732
+ }
733
+ else {
734
+ errorMessage = err instanceof Error ? err.message : String(err);
735
+ }
736
+ logger.error({ group: group.name, err }, 'Agent error');
737
+ }
738
+ finally {
739
+ progressNotifier.stop();
740
+ }
741
+ if (!output) {
742
+ const message = errorMessage || 'No output from agent';
743
+ if (context) {
744
+ recordAgentTelemetry({
745
+ traceBase,
746
+ output: null,
747
+ context,
748
+ metricsSource: 'telegram',
749
+ toolAuditSource: 'message',
750
+ errorMessage: message,
751
+ errorType: 'agent'
752
+ });
753
+ }
754
+ else {
755
+ recordError('agent');
756
+ writeTrace({
757
+ trace_id: traceBase.trace_id,
758
+ timestamp: traceBase.timestamp,
759
+ created_at: traceBase.created_at,
760
+ chat_id: traceBase.chat_id,
761
+ group_folder: traceBase.group_folder,
762
+ user_id: traceBase.user_id,
763
+ input_text: traceBase.input_text,
764
+ output_text: null,
765
+ model_id: 'unknown',
766
+ memory_recall: [],
767
+ error_code: message,
768
+ source: traceBase.source
769
+ });
770
+ }
771
+ const userMessage = humanizeError(errorMessage || 'Unknown error');
772
+ await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
773
+ if (draftId) {
774
+ clearDraftSession(msg.chatId, draftId);
775
+ }
776
+ return false;
777
+ }
778
+ if (output.status === 'error') {
779
+ if (context) {
780
+ recordAgentTelemetry({
781
+ traceBase,
782
+ output,
783
+ context,
784
+ metricsSource: 'telegram',
785
+ toolAuditSource: 'message',
786
+ errorMessage: errorMessage || output.error || 'Unknown error',
787
+ errorType: 'agent'
788
+ });
789
+ }
790
+ logger.error({ group: group.name, error: output.error }, 'Container agent error');
791
+ const userMessage = humanizeError(errorMessage || output.error || 'Unknown error');
792
+ await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
793
+ if (draftId) {
794
+ clearDraftSession(msg.chatId, draftId);
795
+ }
796
+ return false;
797
+ }
798
+ if (lastMessage) {
799
+ updateChatState(msg.chatId, lastMessage.timestamp, lastMessage.id);
800
+ }
801
+ if (output.result && output.result.trim()) {
802
+ let sentMessageId;
803
+ if (streamingEnabled && draftId) {
804
+ await finalizeStreamedMessage(msg, draftId, output.result);
805
+ // Note: streaming doesn't easily give us the message ID
806
+ }
807
+ else {
808
+ const sendResult = await sendMessage(msg.chatId, output.result, { messageThreadId: msg.messageThreadId });
809
+ sentMessageId = sendResult.messageId;
810
+ }
811
+ // Link the sent message to the trace for feedback tracking
812
+ if (sentMessageId) {
813
+ try {
814
+ linkMessageToTrace(sentMessageId, msg.chatId, traceBase.trace_id);
815
+ }
816
+ catch {
817
+ // Don't fail if linking fails
818
+ }
819
+ }
820
+ }
821
+ else if (output.tool_calls && output.tool_calls.length > 0) {
822
+ await sendMessage(msg.chatId, 'I hit my tool-call step limit before I could finish. If you want me to keep going, please narrow the scope or ask for a specific subtask.', { messageThreadId: msg.messageThreadId });
823
+ if (draftId) {
824
+ clearDraftSession(msg.chatId, draftId);
825
+ }
826
+ }
827
+ else {
828
+ logger.warn({ chatId: msg.chatId }, 'Agent returned empty/whitespace response');
829
+ if (draftId) {
830
+ clearDraftSession(msg.chatId, draftId);
831
+ }
832
+ }
833
+ if (context) {
834
+ recordAgentTelemetry({
835
+ traceBase,
836
+ output,
837
+ context,
838
+ metricsSource: 'telegram',
839
+ toolAuditSource: 'message'
840
+ });
841
+ }
842
+ return true;
843
+ }
844
+ async function runBackgroundTask(params) {
845
+ const { msg, group, prompt, missedMessages } = params;
846
+ const traceBase = createTraceBase({
847
+ chatId: msg.chatId,
848
+ groupFolder: group.folder,
849
+ userId: msg.senderId,
850
+ inputText: prompt,
851
+ source: 'dotclaw-background'
852
+ });
853
+ const recallQuery = missedMessages.map(entry => entry.content).join('\n');
854
+ const runId = `bg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
855
+ const abortController = new AbortController();
856
+ if (PREEMPT_ON_NEW_MESSAGE) {
857
+ inFlightRuns.set(msg.chatId, { controller: abortController, runId });
858
+ }
859
+ let output = null;
860
+ let context = null;
861
+ let errorMessage = null;
862
+ try {
863
+ const execution = await executeAgentRun({
864
+ group,
865
+ prompt,
866
+ chatJid: msg.chatId,
867
+ userId: msg.senderId,
868
+ userName: msg.senderName,
869
+ recallQuery: recallQuery || msg.content,
870
+ recallMaxResults: MEMORY_RECALL_MAX_RESULTS,
871
+ recallMaxTokens: MEMORY_RECALL_MAX_TOKENS,
872
+ toolDeny: BACKGROUND_TOOL_DENY,
873
+ sessionId: runId,
874
+ persistSession: false,
875
+ useGroupLock: false,
876
+ abortSignal: abortController.signal,
877
+ isBackgroundTask: true,
878
+ availableGroups: buildAvailableGroupsSnapshot()
879
+ });
880
+ output = execution.output;
881
+ context = execution.context;
882
+ if (output.status === 'error') {
883
+ errorMessage = output.error || 'Unknown error';
884
+ }
885
+ }
886
+ catch (err) {
887
+ if (err instanceof AgentExecutionError) {
888
+ context = err.context;
889
+ errorMessage = err.message;
890
+ }
891
+ else {
892
+ errorMessage = err instanceof Error ? err.message : String(err);
893
+ }
894
+ if (abortController.signal.aborted || isPreemptedError(err)) {
895
+ if (context) {
896
+ recordAgentTelemetry({
897
+ traceBase,
898
+ output,
899
+ context,
900
+ metricsSource: 'telegram',
901
+ toolAuditSource: 'background',
902
+ errorMessage: 'preempted'
903
+ });
904
+ }
905
+ else {
906
+ writeTrace({
907
+ trace_id: traceBase.trace_id,
908
+ timestamp: traceBase.timestamp,
909
+ created_at: traceBase.created_at,
910
+ chat_id: traceBase.chat_id,
911
+ group_folder: traceBase.group_folder,
912
+ user_id: traceBase.user_id,
913
+ input_text: traceBase.input_text,
914
+ output_text: null,
915
+ model_id: 'unknown',
916
+ memory_recall: [],
917
+ error_code: 'preempted',
918
+ source: traceBase.source
919
+ });
920
+ }
921
+ logger.warn({ chatId: msg.chatId }, 'Background run preempted; skipping response');
922
+ return;
923
+ }
924
+ logger.error({ group: group.name, err }, 'Background agent error');
925
+ }
926
+ finally {
927
+ if (PREEMPT_ON_NEW_MESSAGE) {
928
+ const current = inFlightRuns.get(msg.chatId);
929
+ if (current?.runId === runId) {
930
+ inFlightRuns.delete(msg.chatId);
931
+ }
932
+ }
933
+ }
934
+ if (!output) {
935
+ if (context) {
936
+ recordAgentTelemetry({
937
+ traceBase,
938
+ output: null,
939
+ context,
940
+ metricsSource: 'telegram',
941
+ toolAuditSource: 'background',
942
+ errorMessage: errorMessage || 'No output from background agent',
943
+ errorType: 'agent'
944
+ });
945
+ }
946
+ else {
947
+ recordError('agent');
948
+ writeTrace({
949
+ trace_id: traceBase.trace_id,
950
+ timestamp: traceBase.timestamp,
951
+ created_at: traceBase.created_at,
952
+ chat_id: traceBase.chat_id,
953
+ group_folder: traceBase.group_folder,
954
+ user_id: traceBase.user_id,
955
+ input_text: traceBase.input_text,
956
+ output_text: null,
957
+ model_id: 'unknown',
958
+ memory_recall: [],
959
+ error_code: errorMessage || 'No output from background agent',
960
+ source: traceBase.source
961
+ });
962
+ }
963
+ return;
964
+ }
965
+ if (output.status === 'error') {
966
+ if (abortController.signal.aborted || isPreemptedError(output.error)) {
967
+ if (context) {
968
+ recordAgentTelemetry({
969
+ traceBase,
970
+ output,
971
+ context,
972
+ metricsSource: 'telegram',
973
+ toolAuditSource: 'background',
974
+ errorMessage: 'preempted'
975
+ });
976
+ }
977
+ logger.warn({ chatId: msg.chatId }, 'Background run preempted; skipping response');
978
+ return;
979
+ }
980
+ if (context) {
981
+ recordAgentTelemetry({
982
+ traceBase,
983
+ output,
984
+ context,
985
+ metricsSource: 'telegram',
986
+ toolAuditSource: 'background',
987
+ errorMessage: errorMessage || output.error || 'Unknown error',
988
+ errorType: 'agent'
989
+ });
990
+ }
991
+ logger.error({ group: group.name, error: output.error }, 'Background agent error');
992
+ const userMessage = humanizeError(errorMessage || output.error || 'Unknown error');
993
+ await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
994
+ return;
995
+ }
996
+ if (output.result && output.result.trim()) {
997
+ const sendResult = await sendMessage(msg.chatId, output.result, { messageThreadId: msg.messageThreadId });
998
+ // Link the sent message to the trace for feedback tracking
999
+ if (sendResult.messageId) {
1000
+ try {
1001
+ linkMessageToTrace(sendResult.messageId, msg.chatId, traceBase.trace_id);
1002
+ }
1003
+ catch {
1004
+ // Don't fail if linking fails
1005
+ }
1006
+ }
1007
+ }
1008
+ else if (output.tool_calls && output.tool_calls.length > 0) {
1009
+ await sendMessage(msg.chatId, 'I hit my tool-call step limit before I could finish. If you want me to keep going, please narrow the scope or ask for a specific subtask.', { messageThreadId: msg.messageThreadId });
1010
+ }
1011
+ else {
1012
+ await sendMessage(msg.chatId, "I wasn't able to generate a response this time. Please try again or rephrase your request.", { messageThreadId: msg.messageThreadId });
1013
+ }
1014
+ if (context) {
1015
+ recordAgentTelemetry({
1016
+ traceBase,
1017
+ output,
1018
+ context,
1019
+ metricsSource: 'telegram',
1020
+ toolAuditSource: 'background'
1021
+ });
1022
+ }
1023
+ }
1024
+ function startIpcWatcher() {
1025
+ const ipcBaseDir = path.join(DATA_DIR, 'ipc');
1026
+ fs.mkdirSync(ipcBaseDir, { recursive: true });
1027
+ let processing = false;
1028
+ let scheduled = false;
1029
+ let pollingTimer = null;
1030
+ const processIpcFiles = async () => {
1031
+ if (processing)
1032
+ return;
1033
+ processing = true;
1034
+ // Scan all group IPC directories (identity determined by directory)
1035
+ let groupFolders;
1036
+ try {
1037
+ groupFolders = fs.readdirSync(ipcBaseDir).filter(f => {
1038
+ const stat = fs.statSync(path.join(ipcBaseDir, f));
1039
+ return stat.isDirectory() && f !== 'errors';
1040
+ });
1041
+ }
1042
+ catch (err) {
1043
+ logger.error({ err }, 'Error reading IPC base directory');
1044
+ processing = false;
1045
+ return;
1046
+ }
1047
+ for (const sourceGroup of groupFolders) {
1048
+ const isMain = sourceGroup === MAIN_GROUP_FOLDER;
1049
+ const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
1050
+ const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
1051
+ const requestsDir = path.join(ipcBaseDir, sourceGroup, 'requests');
1052
+ const responsesDir = path.join(ipcBaseDir, sourceGroup, 'responses');
1053
+ // Process messages from this group's IPC directory
1054
+ try {
1055
+ if (fs.existsSync(messagesDir)) {
1056
+ const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
1057
+ for (const file of messageFiles) {
1058
+ const filePath = path.join(messagesDir, file);
1059
+ try {
1060
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1061
+ if ((data.type === 'message' || data.type === 'message_draft') && data.chatJid && data.text) {
1062
+ // Authorization: verify this group can send to this chatJid
1063
+ const targetGroup = registeredGroups[data.chatJid];
1064
+ if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) {
1065
+ if (data.type === 'message_draft') {
1066
+ const draftId = Number.isFinite(data.draftId) ? Number(data.draftId) : NaN;
1067
+ if (!Number.isFinite(draftId)) {
1068
+ logger.warn({ chatJid: data.chatJid, sourceGroup }, 'IPC draft missing draftId');
1069
+ }
1070
+ else {
1071
+ await sendDraftUpdate(data.chatJid, draftId, data.text);
1072
+ }
1073
+ }
1074
+ else {
1075
+ await sendMessage(data.chatJid, data.text);
1076
+ logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent');
1077
+ }
1078
+ }
1079
+ else {
1080
+ logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked');
1081
+ }
1082
+ }
1083
+ fs.unlinkSync(filePath);
1084
+ }
1085
+ catch (err) {
1086
+ logger.error({ file, sourceGroup, err }, 'Error processing IPC message');
1087
+ const errorDir = path.join(ipcBaseDir, 'errors');
1088
+ fs.mkdirSync(errorDir, { recursive: true });
1089
+ fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ catch (err) {
1095
+ logger.error({ err, sourceGroup }, 'Error reading IPC messages directory');
1096
+ }
1097
+ // Process tasks from this group's IPC directory
1098
+ try {
1099
+ if (fs.existsSync(tasksDir)) {
1100
+ const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
1101
+ for (const file of taskFiles) {
1102
+ const filePath = path.join(tasksDir, file);
1103
+ try {
1104
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1105
+ // Pass source group identity to processTaskIpc for authorization
1106
+ await processTaskIpc(data, sourceGroup, isMain);
1107
+ fs.unlinkSync(filePath);
1108
+ }
1109
+ catch (err) {
1110
+ logger.error({ file, sourceGroup, err }, 'Error processing IPC task');
1111
+ const errorDir = path.join(ipcBaseDir, 'errors');
1112
+ fs.mkdirSync(errorDir, { recursive: true });
1113
+ fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1114
+ }
1115
+ }
1116
+ }
1117
+ }
1118
+ catch (err) {
1119
+ logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
1120
+ }
1121
+ // Process request/response IPC for synchronous operations (memory, etc.)
1122
+ try {
1123
+ if (fs.existsSync(requestsDir)) {
1124
+ fs.mkdirSync(responsesDir, { recursive: true });
1125
+ const requestFiles = fs.readdirSync(requestsDir).filter(f => f.endsWith('.json'));
1126
+ for (const file of requestFiles) {
1127
+ const filePath = path.join(requestsDir, file);
1128
+ try {
1129
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1130
+ const response = await processRequestIpc(data, sourceGroup, isMain);
1131
+ if (response?.id) {
1132
+ const responsePath = path.join(responsesDir, `${response.id}.json`);
1133
+ fs.writeFileSync(responsePath, JSON.stringify(response, null, 2));
1134
+ }
1135
+ fs.unlinkSync(filePath);
1136
+ }
1137
+ catch (err) {
1138
+ logger.error({ file, sourceGroup, err }, 'Error processing IPC request');
1139
+ const errorDir = path.join(ipcBaseDir, 'errors');
1140
+ fs.mkdirSync(errorDir, { recursive: true });
1141
+ fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ catch (err) {
1147
+ logger.error({ err, sourceGroup }, 'Error reading IPC requests directory');
1148
+ }
1149
+ }
1150
+ processing = false;
1151
+ };
1152
+ const scheduleProcess = () => {
1153
+ if (scheduled)
1154
+ return;
1155
+ scheduled = true;
1156
+ setTimeout(async () => {
1157
+ scheduled = false;
1158
+ await processIpcFiles();
1159
+ }, 100);
1160
+ };
1161
+ let watcherActive = false;
1162
+ let watcher = null;
1163
+ try {
1164
+ watcher = fs.watch(ipcBaseDir, { recursive: true }, () => {
1165
+ scheduleProcess();
1166
+ });
1167
+ watcher.on('error', (err) => {
1168
+ logger.warn({ err }, 'IPC watcher error; falling back to polling');
1169
+ watcher?.close();
1170
+ if (!pollingTimer) {
1171
+ const poll = () => {
1172
+ scheduleProcess();
1173
+ pollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
1174
+ };
1175
+ poll();
1176
+ }
1177
+ });
1178
+ watcherActive = true;
1179
+ }
1180
+ catch (err) {
1181
+ logger.warn({ err }, 'IPC watch unsupported; falling back to polling');
1182
+ }
1183
+ if (!watcherActive) {
1184
+ const poll = () => {
1185
+ scheduleProcess();
1186
+ pollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
1187
+ };
1188
+ poll();
1189
+ }
1190
+ else {
1191
+ scheduleProcess();
1192
+ }
1193
+ if (pollingTimer) {
1194
+ logger.info('IPC watcher started (polling)');
1195
+ }
1196
+ else {
1197
+ logger.info('IPC watcher started (fs.watch)');
1198
+ }
1199
+ }
1200
+ async function runHeartbeatOnce() {
1201
+ const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
1202
+ if (!entry) {
1203
+ logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
1204
+ return;
1205
+ }
1206
+ const [chatId, group] = entry;
1207
+ const prompt = [
1208
+ '[HEARTBEAT]',
1209
+ 'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
1210
+ 'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
1211
+ ].join('\n');
1212
+ const traceBase = createTraceBase({
1213
+ chatId,
1214
+ groupFolder: group.folder,
1215
+ userId: null,
1216
+ inputText: prompt,
1217
+ source: 'dotclaw-heartbeat'
1218
+ });
1219
+ let output = null;
1220
+ let context = null;
1221
+ let errorMessage = null;
1222
+ const recallMaxResults = Math.max(4, MEMORY_RECALL_MAX_RESULTS - 2);
1223
+ const recallMaxTokens = Math.max(600, MEMORY_RECALL_MAX_TOKENS - 200);
1224
+ try {
1225
+ const execution = await executeAgentRun({
1226
+ group,
1227
+ prompt,
1228
+ chatJid: chatId,
1229
+ userId: null,
1230
+ recallQuery: prompt,
1231
+ recallMaxResults,
1232
+ recallMaxTokens,
1233
+ sessionId: sessions[group.folder],
1234
+ onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
1235
+ isScheduledTask: true,
1236
+ availableGroups: buildAvailableGroupsSnapshot()
1237
+ });
1238
+ output = execution.output;
1239
+ context = execution.context;
1240
+ if (output.status === 'error') {
1241
+ errorMessage = output.error || 'Unknown error';
1242
+ }
1243
+ }
1244
+ catch (err) {
1245
+ if (err instanceof AgentExecutionError) {
1246
+ context = err.context;
1247
+ errorMessage = err.message;
1248
+ }
1249
+ else {
1250
+ errorMessage = err instanceof Error ? err.message : String(err);
1251
+ }
1252
+ logger.error({ err }, 'Heartbeat run failed');
1253
+ }
1254
+ if (context) {
1255
+ recordAgentTelemetry({
1256
+ traceBase,
1257
+ output,
1258
+ context,
1259
+ toolAuditSource: 'heartbeat',
1260
+ errorMessage: errorMessage ?? undefined
1261
+ });
1262
+ }
1263
+ else if (errorMessage) {
1264
+ writeTrace({
1265
+ trace_id: traceBase.trace_id,
1266
+ timestamp: traceBase.timestamp,
1267
+ created_at: traceBase.created_at,
1268
+ chat_id: traceBase.chat_id,
1269
+ group_folder: traceBase.group_folder,
1270
+ user_id: traceBase.user_id,
1271
+ input_text: traceBase.input_text,
1272
+ output_text: null,
1273
+ model_id: 'unknown',
1274
+ memory_recall: [],
1275
+ error_code: errorMessage,
1276
+ source: traceBase.source
1277
+ });
1278
+ }
1279
+ }
1280
+ function startHeartbeatLoop() {
1281
+ if (!HEARTBEAT_ENABLED)
1282
+ return;
1283
+ const loop = async () => {
1284
+ try {
1285
+ await runHeartbeatOnce();
1286
+ }
1287
+ catch (err) {
1288
+ logger.error({ err }, 'Heartbeat run failed');
1289
+ }
1290
+ setTimeout(loop, HEARTBEAT_INTERVAL_MS);
1291
+ };
1292
+ loop();
1293
+ }
1294
+ async function processTaskIpc(data, sourceGroup, isMain) {
1295
+ const { CronExpressionParser } = await import('cron-parser');
1296
+ switch (data.type) {
1297
+ case 'schedule_task':
1298
+ if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) {
1299
+ // Authorization: non-main groups can only schedule for themselves
1300
+ const targetGroup = data.groupFolder;
1301
+ if (!isMain && targetGroup !== sourceGroup) {
1302
+ logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked');
1303
+ break;
1304
+ }
1305
+ // Resolve the correct chat ID for the target group (don't trust IPC payload)
1306
+ const targetChatId = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup)?.[0];
1307
+ if (!targetChatId) {
1308
+ logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered');
1309
+ break;
1310
+ }
1311
+ const scheduleType = data.schedule_type;
1312
+ let nextRun = null;
1313
+ if (scheduleType === 'cron') {
1314
+ try {
1315
+ const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE });
1316
+ nextRun = interval.next().toISOString();
1317
+ }
1318
+ catch {
1319
+ logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression');
1320
+ break;
1321
+ }
1322
+ }
1323
+ else if (scheduleType === 'interval') {
1324
+ const ms = parseInt(data.schedule_value, 10);
1325
+ if (isNaN(ms) || ms <= 0) {
1326
+ logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
1327
+ break;
1328
+ }
1329
+ nextRun = new Date(Date.now() + ms).toISOString();
1330
+ }
1331
+ else if (scheduleType === 'once') {
1332
+ const scheduled = new Date(data.schedule_value);
1333
+ if (isNaN(scheduled.getTime())) {
1334
+ logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp');
1335
+ break;
1336
+ }
1337
+ nextRun = scheduled.toISOString();
1338
+ }
1339
+ const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1340
+ const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated')
1341
+ ? data.context_mode
1342
+ : 'isolated';
1343
+ createTask({
1344
+ id: taskId,
1345
+ group_folder: targetGroup,
1346
+ chat_jid: targetChatId,
1347
+ prompt: data.prompt,
1348
+ schedule_type: scheduleType,
1349
+ schedule_value: data.schedule_value,
1350
+ context_mode: contextMode,
1351
+ next_run: nextRun,
1352
+ status: 'active',
1353
+ created_at: new Date().toISOString()
1354
+ });
1355
+ logger.info({ taskId, sourceGroup, targetGroup, contextMode }, 'Task created via IPC');
1356
+ }
1357
+ break;
1358
+ case 'pause_task':
1359
+ if (data.taskId) {
1360
+ const task = getTaskById(data.taskId);
1361
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1362
+ updateTask(data.taskId, { status: 'paused' });
1363
+ logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC');
1364
+ }
1365
+ else {
1366
+ logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
1367
+ }
1368
+ }
1369
+ break;
1370
+ case 'resume_task':
1371
+ if (data.taskId) {
1372
+ const task = getTaskById(data.taskId);
1373
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1374
+ updateTask(data.taskId, { status: 'active' });
1375
+ logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC');
1376
+ }
1377
+ else {
1378
+ logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
1379
+ }
1380
+ }
1381
+ break;
1382
+ case 'cancel_task':
1383
+ if (data.taskId) {
1384
+ const task = getTaskById(data.taskId);
1385
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1386
+ deleteTask(data.taskId);
1387
+ logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC');
1388
+ }
1389
+ else {
1390
+ logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
1391
+ }
1392
+ }
1393
+ break;
1394
+ case 'update_task':
1395
+ if (data.taskId) {
1396
+ const task = getTaskById(data.taskId);
1397
+ if (!task || (!isMain && task.group_folder !== sourceGroup)) {
1398
+ logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt');
1399
+ break;
1400
+ }
1401
+ const updates = {};
1402
+ if (typeof data.prompt === 'string')
1403
+ updates.prompt = data.prompt;
1404
+ if (typeof data.context_mode === 'string')
1405
+ updates.context_mode = data.context_mode;
1406
+ if (typeof data.status === 'string')
1407
+ updates.status = data.status;
1408
+ if (typeof data.state_json === 'string')
1409
+ updates.state_json = data.state_json;
1410
+ if (typeof data.schedule_type === 'string' && typeof data.schedule_value === 'string') {
1411
+ updates.schedule_type = data.schedule_type;
1412
+ updates.schedule_value = data.schedule_value;
1413
+ let nextRun = null;
1414
+ if (updates.schedule_type === 'cron') {
1415
+ try {
1416
+ const interval = CronExpressionParser.parse(updates.schedule_value, { tz: TIMEZONE });
1417
+ nextRun = interval.next().toISOString();
1418
+ }
1419
+ catch {
1420
+ logger.warn({ scheduleValue: updates.schedule_value }, 'Invalid cron expression for update_task');
1421
+ }
1422
+ }
1423
+ else if (updates.schedule_type === 'interval') {
1424
+ const ms = parseInt(updates.schedule_value, 10);
1425
+ if (!isNaN(ms) && ms > 0) {
1426
+ nextRun = new Date(Date.now() + ms).toISOString();
1427
+ }
1428
+ }
1429
+ else if (updates.schedule_type === 'once') {
1430
+ const scheduled = new Date(updates.schedule_value);
1431
+ if (!isNaN(scheduled.getTime())) {
1432
+ nextRun = scheduled.toISOString();
1433
+ }
1434
+ }
1435
+ if (nextRun) {
1436
+ updates.next_run = nextRun;
1437
+ }
1438
+ }
1439
+ updateTask(data.taskId, updates);
1440
+ logger.info({ taskId: data.taskId, sourceGroup }, 'Task updated via IPC');
1441
+ }
1442
+ break;
1443
+ case 'register_group':
1444
+ // Only main group can register new groups
1445
+ if (!isMain) {
1446
+ logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
1447
+ break;
1448
+ }
1449
+ if (data.jid && data.name && data.folder) {
1450
+ registerGroup(data.jid, {
1451
+ name: data.name,
1452
+ folder: data.folder,
1453
+ trigger: data.trigger,
1454
+ added_at: new Date().toISOString(),
1455
+ containerConfig: data.containerConfig
1456
+ });
1457
+ }
1458
+ else {
1459
+ logger.warn({ data }, 'Invalid register_group request - missing required fields');
1460
+ }
1461
+ break;
1462
+ case 'remove_group':
1463
+ if (!isMain) {
1464
+ logger.warn({ sourceGroup }, 'Unauthorized remove_group attempt blocked');
1465
+ break;
1466
+ }
1467
+ if (!data.identifier || typeof data.identifier !== 'string') {
1468
+ logger.warn({ data }, 'Invalid remove_group request - missing identifier');
1469
+ break;
1470
+ }
1471
+ {
1472
+ const result = unregisterGroup(data.identifier);
1473
+ if (!result.ok) {
1474
+ logger.warn({ identifier: data.identifier, error: result.error }, 'Failed to remove group');
1475
+ }
1476
+ }
1477
+ break;
1478
+ case 'set_model':
1479
+ if (!isMain) {
1480
+ logger.warn({ sourceGroup }, 'Unauthorized set_model attempt blocked');
1481
+ break;
1482
+ }
1483
+ if (!data.model || typeof data.model !== 'string') {
1484
+ logger.warn({ data }, 'Invalid set_model request - missing model');
1485
+ break;
1486
+ }
1487
+ {
1488
+ const defaultModel = runtime.host.defaultModel;
1489
+ const config = loadModelRegistry(defaultModel);
1490
+ const nextModel = data.model.trim();
1491
+ if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
1492
+ logger.warn({ model: nextModel }, 'Model not in allowlist; refusing set_model');
1493
+ break;
1494
+ }
1495
+ const scope = typeof data.scope === 'string' ? data.scope : 'global';
1496
+ const targetId = typeof data.target_id === 'string' ? data.target_id : undefined;
1497
+ if (scope === 'user' && !targetId) {
1498
+ logger.warn({ data }, 'set_model missing target_id for user scope');
1499
+ break;
1500
+ }
1501
+ if (scope === 'group' && !targetId) {
1502
+ logger.warn({ data }, 'set_model missing target_id for group scope');
1503
+ break;
1504
+ }
1505
+ const nextConfig = { ...config };
1506
+ if (scope === 'global') {
1507
+ nextConfig.model = nextModel;
1508
+ }
1509
+ else if (scope === 'group') {
1510
+ nextConfig.per_group = nextConfig.per_group || {};
1511
+ nextConfig.per_group[targetId] = { model: nextModel };
1512
+ }
1513
+ else if (scope === 'user') {
1514
+ nextConfig.per_user = nextConfig.per_user || {};
1515
+ nextConfig.per_user[targetId] = { model: nextModel };
1516
+ }
1517
+ nextConfig.updated_at = new Date().toISOString();
1518
+ saveModelRegistry(nextConfig);
1519
+ logger.info({ model: nextModel, scope, targetId }, 'Model updated via IPC');
1520
+ }
1521
+ break;
1522
+ default:
1523
+ logger.warn({ type: data.type }, 'Unknown IPC task type');
1524
+ }
1525
+ }
1526
+ async function processRequestIpc(data, sourceGroup, isMain) {
1527
+ const requestId = typeof data.id === 'string' ? data.id : undefined;
1528
+ const payload = data.payload || {};
1529
+ const resolveGroupFolder = () => {
1530
+ const target = typeof payload.target_group === 'string' ? payload.target_group : null;
1531
+ if (target && isMain)
1532
+ return target;
1533
+ return sourceGroup;
1534
+ };
1535
+ try {
1536
+ switch (data.type) {
1537
+ case 'memory_upsert': {
1538
+ const items = coerceMemoryItems(payload.items);
1539
+ const groupFolder = resolveGroupFolder();
1540
+ const source = typeof payload.source === 'string' ? payload.source : 'agent';
1541
+ const results = upsertMemoryItems(groupFolder, items, source);
1542
+ return { id: requestId, ok: true, result: { count: results.length } };
1543
+ }
1544
+ case 'memory_forget': {
1545
+ const groupFolder = resolveGroupFolder();
1546
+ const ids = Array.isArray(payload.ids) ? payload.ids : undefined;
1547
+ const content = typeof payload.content === 'string' ? payload.content : undefined;
1548
+ const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
1549
+ const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
1550
+ const count = forgetMemories({
1551
+ groupFolder,
1552
+ ids,
1553
+ content,
1554
+ scope,
1555
+ userId
1556
+ });
1557
+ return { id: requestId, ok: true, result: { count } };
1558
+ }
1559
+ case 'memory_list': {
1560
+ const groupFolder = resolveGroupFolder();
1561
+ const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
1562
+ const type = isMemoryType(payload.type) ? payload.type : undefined;
1563
+ const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
1564
+ const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
1565
+ const items = listMemories({
1566
+ groupFolder,
1567
+ scope,
1568
+ type,
1569
+ userId,
1570
+ limit
1571
+ });
1572
+ return { id: requestId, ok: true, result: { items } };
1573
+ }
1574
+ case 'memory_search': {
1575
+ const groupFolder = resolveGroupFolder();
1576
+ const query = typeof payload.query === 'string' ? payload.query : '';
1577
+ const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
1578
+ const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
1579
+ const results = searchMemories({
1580
+ groupFolder,
1581
+ userId,
1582
+ query,
1583
+ limit
1584
+ });
1585
+ return { id: requestId, ok: true, result: { items: results } };
1586
+ }
1587
+ case 'memory_stats': {
1588
+ const groupFolder = resolveGroupFolder();
1589
+ const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
1590
+ const stats = getMemoryStats({ groupFolder, userId });
1591
+ return { id: requestId, ok: true, result: { stats } };
1592
+ }
1593
+ case 'list_groups': {
1594
+ if (!isMain) {
1595
+ return { id: requestId, ok: false, error: 'Only the main group can list groups.' };
1596
+ }
1597
+ const groups = listRegisteredGroups();
1598
+ return { id: requestId, ok: true, result: { groups } };
1599
+ }
1600
+ default:
1601
+ return { id: requestId, ok: false, error: `Unknown request type: ${data.type}` };
1602
+ }
1603
+ }
1604
+ catch (err) {
1605
+ return { id: requestId, ok: false, error: err instanceof Error ? err.message : String(err) };
1606
+ }
1607
+ }
1608
+ function formatGroups(groups) {
1609
+ if (groups.length === 0)
1610
+ return 'No registered groups.';
1611
+ const lines = groups.map(group => {
1612
+ const trigger = group.trigger ? ` (trigger: ${group.trigger})` : '';
1613
+ return `- ${group.name} [${group.folder}] chat=${group.chat_id}${trigger}`;
1614
+ });
1615
+ return ['Registered groups:', ...lines].join('\n');
1616
+ }
1617
+ function applyModelOverride(params) {
1618
+ const defaultModel = runtime.host.defaultModel;
1619
+ const config = loadModelRegistry(defaultModel);
1620
+ const nextModel = params.model.trim();
1621
+ if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
1622
+ return { ok: false, error: 'Model not in allowlist' };
1623
+ }
1624
+ const scope = params.scope || 'global';
1625
+ const targetId = params.targetId;
1626
+ if (scope === 'user' && !targetId) {
1627
+ return { ok: false, error: 'Missing target_id for user scope' };
1628
+ }
1629
+ if (scope === 'group' && !targetId) {
1630
+ return { ok: false, error: 'Missing target_id for group scope' };
1631
+ }
1632
+ const nextConfig = { ...config };
1633
+ if (scope === 'global') {
1634
+ nextConfig.model = nextModel;
1635
+ }
1636
+ else if (scope === 'group') {
1637
+ nextConfig.per_group = nextConfig.per_group || {};
1638
+ nextConfig.per_group[targetId] = { model: nextModel };
1639
+ }
1640
+ else if (scope === 'user') {
1641
+ nextConfig.per_user = nextConfig.per_user || {};
1642
+ nextConfig.per_user[targetId] = { model: nextModel };
1643
+ }
1644
+ nextConfig.updated_at = new Date().toISOString();
1645
+ saveModelRegistry(nextConfig);
1646
+ return { ok: true };
1647
+ }
1648
+ async function handleAdminCommand(params) {
1649
+ const parsed = parseAdminCommand(params.content, params.botUsername);
1650
+ if (!parsed)
1651
+ return false;
1652
+ const reply = (text) => sendMessage(params.chatId, text, { messageThreadId: params.messageThreadId });
1653
+ const group = registeredGroups[params.chatId];
1654
+ if (!group) {
1655
+ await reply('This chat is not registered with DotClaw.');
1656
+ return true;
1657
+ }
1658
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
1659
+ const command = parsed.command;
1660
+ const args = parsed.args;
1661
+ const requireMain = (name) => {
1662
+ if (isMain)
1663
+ return false;
1664
+ reply(`${name} is only available in the main group.`).catch(() => undefined);
1665
+ return true;
1666
+ };
1667
+ if (command === 'help') {
1668
+ await reply([
1669
+ 'DotClaw admin commands:',
1670
+ '- `/dotclaw help`',
1671
+ '- `/dotclaw groups` (main only)',
1672
+ '- `/dotclaw add-group <chat_id> <name> [folder]` (main only)',
1673
+ '- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
1674
+ '- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
1675
+ '- `/dotclaw remember <fact>` (main only)',
1676
+ '- `/dotclaw style <concise|balanced|detailed>`',
1677
+ '- `/dotclaw tools <conservative|balanced|proactive>`',
1678
+ '- `/dotclaw caution <low|balanced|high>`',
1679
+ '- `/dotclaw memory <strict|balanced|loose>`'
1680
+ ].join('\n'));
1681
+ return true;
1682
+ }
1683
+ if (command === 'groups') {
1684
+ if (requireMain('Listing groups'))
1685
+ return true;
1686
+ await reply(formatGroups(listRegisteredGroups()));
1687
+ return true;
1688
+ }
1689
+ if (command === 'add-group') {
1690
+ if (requireMain('Adding groups'))
1691
+ return true;
1692
+ if (args.length < 1) {
1693
+ await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
1694
+ return true;
1695
+ }
1696
+ const jid = args[0];
1697
+ if (registeredGroups[jid]) {
1698
+ await reply('That chat id is already registered.');
1699
+ return true;
1700
+ }
1701
+ const name = args[1] || `group-${jid}`;
1702
+ const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1703
+ if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
1704
+ await reply('Invalid folder name. Use lowercase letters, numbers, and dashes only.');
1705
+ return true;
1706
+ }
1707
+ registerGroup(jid, {
1708
+ name,
1709
+ folder: folder || `group-${jid}`,
1710
+ added_at: new Date().toISOString()
1711
+ });
1712
+ await reply(`Registered group "${name}" with folder "${folder}".`);
1713
+ return true;
1714
+ }
1715
+ if (command === 'remove-group') {
1716
+ if (requireMain('Removing groups'))
1717
+ return true;
1718
+ if (args.length < 1) {
1719
+ await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
1720
+ return true;
1721
+ }
1722
+ const result = unregisterGroup(args.join(' '));
1723
+ if (!result.ok) {
1724
+ await reply(`Failed to remove group: ${result.error || 'unknown error'}`);
1725
+ return true;
1726
+ }
1727
+ await reply(`Removed group "${result.group?.name}" (${result.group?.folder}).`);
1728
+ return true;
1729
+ }
1730
+ if (command === 'set-model') {
1731
+ if (requireMain('Setting models'))
1732
+ return true;
1733
+ if (args.length < 1) {
1734
+ await reply('Usage: /dotclaw set-model <model> [global|group|user] [target_id]');
1735
+ return true;
1736
+ }
1737
+ const model = args[0];
1738
+ const scopeCandidate = (args[1] || '').toLowerCase();
1739
+ const scope = (scopeCandidate === 'global' || scopeCandidate === 'group' || scopeCandidate === 'user')
1740
+ ? scopeCandidate
1741
+ : 'global';
1742
+ const targetId = args[2] || (scope === 'group' ? group.folder : scope === 'user' ? params.senderId : undefined);
1743
+ const result = applyModelOverride({ model, scope, targetId });
1744
+ if (!result.ok) {
1745
+ await reply(`Failed to set model: ${result.error || 'unknown error'}`);
1746
+ return true;
1747
+ }
1748
+ await reply(`Model set to ${model} (${scope}${targetId ? `:${targetId}` : ''}).`);
1749
+ return true;
1750
+ }
1751
+ if (command === 'remember') {
1752
+ if (requireMain('Remembering facts'))
1753
+ return true;
1754
+ const fact = args.join(' ').trim();
1755
+ if (!fact) {
1756
+ await reply('Usage: /dotclaw remember <fact>');
1757
+ return true;
1758
+ }
1759
+ const items = [{
1760
+ scope: 'global',
1761
+ type: 'fact',
1762
+ content: fact,
1763
+ importance: 0.7,
1764
+ confidence: 0.8,
1765
+ tags: ['manual']
1766
+ }];
1767
+ upsertMemoryItems('global', items, 'admin-command');
1768
+ await reply('Saved to global memory.');
1769
+ return true;
1770
+ }
1771
+ if (command === 'style') {
1772
+ const style = (args[0] || '').toLowerCase();
1773
+ if (!['concise', 'balanced', 'detailed'].includes(style)) {
1774
+ await reply('Usage: /dotclaw style <concise|balanced|detailed>');
1775
+ return true;
1776
+ }
1777
+ const items = [{
1778
+ scope: 'user',
1779
+ subject_id: params.senderId,
1780
+ type: 'preference',
1781
+ conflict_key: 'response_style',
1782
+ content: `Prefers ${style} responses.`,
1783
+ importance: 0.7,
1784
+ confidence: 0.85,
1785
+ tags: [`response_style:${style}`],
1786
+ metadata: { response_style: style }
1787
+ }];
1788
+ upsertMemoryItems(group.folder, items, 'admin-command');
1789
+ await reply(`Response style set to ${style}.`);
1790
+ return true;
1791
+ }
1792
+ if (command === 'tools') {
1793
+ const level = (args[0] || '').toLowerCase();
1794
+ const bias = level === 'proactive' ? 0.7 : level === 'balanced' ? 0.5 : level === 'conservative' ? 0.3 : null;
1795
+ if (bias === null) {
1796
+ await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
1797
+ return true;
1798
+ }
1799
+ const items = [{
1800
+ scope: 'user',
1801
+ subject_id: params.senderId,
1802
+ type: 'preference',
1803
+ conflict_key: 'tool_calling_bias',
1804
+ content: `Prefers ${level} tool usage.`,
1805
+ importance: 0.65,
1806
+ confidence: 0.8,
1807
+ tags: [`tool_calling_bias:${bias}`],
1808
+ metadata: { tool_calling_bias: bias, bias }
1809
+ }];
1810
+ upsertMemoryItems(group.folder, items, 'admin-command');
1811
+ await reply(`Tool usage bias set to ${level}.`);
1812
+ return true;
1813
+ }
1814
+ if (command === 'caution') {
1815
+ const level = (args[0] || '').toLowerCase();
1816
+ const bias = level === 'high' ? 0.7 : level === 'balanced' ? 0.5 : level === 'low' ? 0.35 : null;
1817
+ if (bias === null) {
1818
+ await reply('Usage: /dotclaw caution <low|balanced|high>');
1819
+ return true;
1820
+ }
1821
+ const items = [{
1822
+ scope: 'user',
1823
+ subject_id: params.senderId,
1824
+ type: 'preference',
1825
+ conflict_key: 'caution_bias',
1826
+ content: `Prefers ${level} caution in responses.`,
1827
+ importance: 0.65,
1828
+ confidence: 0.8,
1829
+ tags: [`caution_bias:${bias}`],
1830
+ metadata: { caution_bias: bias, bias }
1831
+ }];
1832
+ upsertMemoryItems(group.folder, items, 'admin-command');
1833
+ await reply(`Caution bias set to ${level}.`);
1834
+ return true;
1835
+ }
1836
+ if (command === 'memory') {
1837
+ const level = (args[0] || '').toLowerCase();
1838
+ const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
1839
+ if (threshold === null) {
1840
+ await reply('Usage: /dotclaw memory <strict|balanced|loose>');
1841
+ return true;
1842
+ }
1843
+ const items = [{
1844
+ scope: 'user',
1845
+ subject_id: params.senderId,
1846
+ type: 'preference',
1847
+ conflict_key: 'memory_importance_threshold',
1848
+ content: `Prefers memory strictness ${level}.`,
1849
+ importance: 0.6,
1850
+ confidence: 0.8,
1851
+ tags: [`memory_importance_threshold:${threshold}`],
1852
+ metadata: { memory_importance_threshold: threshold, threshold }
1853
+ }];
1854
+ upsertMemoryItems(group.folder, items, 'admin-command');
1855
+ await reply(`Memory strictness set to ${level}.`);
1856
+ return true;
1857
+ }
1858
+ await reply('Unknown command. Use `/dotclaw help` for options.');
1859
+ return true;
1860
+ }
1861
+ function setupTelegramHandlers() {
1862
+ // Handle message reactions (👍/👎 for feedback)
1863
+ telegrafBot.on('message_reaction', async (ctx) => {
1864
+ try {
1865
+ const update = ctx.update;
1866
+ const reaction = update.message_reaction;
1867
+ if (!reaction)
1868
+ return;
1869
+ const emoji = reaction.new_reaction?.[0]?.emoji;
1870
+ if (!emoji || (emoji !== '👍' && emoji !== '👎'))
1871
+ return;
1872
+ const chatId = String(reaction.chat.id);
1873
+ const messageId = String(reaction.message_id);
1874
+ const userId = reaction.user?.id ? String(reaction.user.id) : undefined;
1875
+ // Look up the trace ID for this message
1876
+ const traceId = getTraceIdForMessage(messageId, chatId);
1877
+ if (!traceId) {
1878
+ logger.debug({ chatId, messageId }, 'No trace found for reacted message');
1879
+ return;
1880
+ }
1881
+ // Record the feedback
1882
+ const feedbackType = emoji === '👍' ? 'positive' : 'negative';
1883
+ recordUserFeedback({
1884
+ trace_id: traceId,
1885
+ message_id: messageId,
1886
+ chat_jid: chatId,
1887
+ feedback_type: feedbackType,
1888
+ user_id: userId
1889
+ });
1890
+ logger.info({ chatId, messageId, feedbackType, traceId }, 'User feedback recorded');
1891
+ }
1892
+ catch (err) {
1893
+ logger.debug({ err }, 'Error handling message reaction');
1894
+ }
1895
+ });
1896
+ // Handle all text messages
1897
+ telegrafBot.on('message', async (ctx) => {
1898
+ if (!ctx.message || !('text' in ctx.message))
1899
+ return;
1900
+ const chatId = String(ctx.chat.id);
1901
+ const chatType = ctx.chat.type;
1902
+ const isGroup = chatType === 'group' || chatType === 'supergroup';
1903
+ const isPrivate = chatType === 'private';
1904
+ const senderId = String(ctx.from?.id || ctx.chat.id);
1905
+ const senderName = ctx.from?.first_name || ctx.from?.username || 'User';
1906
+ const content = ctx.message.text;
1907
+ const timestamp = new Date(ctx.message.date * 1000).toISOString();
1908
+ const messageId = String(ctx.message.message_id);
1909
+ const rawThreadId = ctx.message.message_thread_id;
1910
+ const messageThreadId = Number.isFinite(rawThreadId) ? Number(rawThreadId) : undefined;
1911
+ logger.info({ chatId, isGroup, senderName }, `Telegram message: ${content.substring(0, 50)}...`);
1912
+ try {
1913
+ // Store message in database
1914
+ storeMessage(String(ctx.message.message_id), chatId, senderId, senderName, content, timestamp, false);
1915
+ }
1916
+ catch (error) {
1917
+ logger.error({ error, chatId }, 'Failed to persist Telegram message');
1918
+ }
1919
+ const botUsername = ctx.me;
1920
+ const botId = ctx.botInfo?.id;
1921
+ const adminHandled = await handleAdminCommand({
1922
+ chatId,
1923
+ senderId,
1924
+ senderName,
1925
+ content,
1926
+ botUsername,
1927
+ messageThreadId
1928
+ });
1929
+ if (adminHandled) {
1930
+ return;
1931
+ }
1932
+ const mentioned = isBotMentioned(content, ctx.message.entities, botUsername, botId);
1933
+ const replied = isBotReplied(ctx.message, botId);
1934
+ const group = registeredGroups[chatId];
1935
+ const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
1936
+ const triggered = Boolean(triggerRegex && triggerRegex.test(content));
1937
+ const shouldProcess = isPrivate || mentioned || replied || triggered;
1938
+ if (!shouldProcess) {
1939
+ return;
1940
+ }
1941
+ // Rate limiting check
1942
+ const rateCheck = checkRateLimit(senderId);
1943
+ if (!rateCheck.allowed) {
1944
+ const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
1945
+ logger.warn({ senderId, retryAfterSec }, 'Rate limit exceeded');
1946
+ await sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { messageThreadId });
1947
+ return;
1948
+ }
1949
+ enqueueMessage({
1950
+ chatId,
1951
+ messageId,
1952
+ senderId,
1953
+ senderName,
1954
+ content,
1955
+ timestamp,
1956
+ isGroup,
1957
+ chatType,
1958
+ messageThreadId
1959
+ });
1960
+ });
1961
+ }
1962
+ function ensureDockerRunning() {
1963
+ try {
1964
+ execSync('docker info', { stdio: 'pipe', timeout: 10000 });
1965
+ logger.debug('Docker daemon is running');
1966
+ }
1967
+ catch {
1968
+ logger.error('Docker daemon is not running');
1969
+ console.error('\n╔════════════════════════════════════════════════════════════════╗');
1970
+ console.error('║ FATAL: Docker is not running ║');
1971
+ console.error('║ ║');
1972
+ console.error('║ Agents cannot run without Docker. To fix: ║');
1973
+ console.error('║ macOS: Start Docker Desktop ║');
1974
+ console.error('║ Linux: sudo systemctl start docker ║');
1975
+ console.error('║ ║');
1976
+ console.error('║ Install from: https://docker.com/products/docker-desktop ║');
1977
+ console.error('╚════════════════════════════════════════════════════════════════╝\n');
1978
+ throw new Error('Docker is required but not running');
1979
+ }
1980
+ }
1981
+ async function main() {
1982
+ // Ensure directory structure exists before anything else
1983
+ const { ensureDirectoryStructure } = await import('./paths.js');
1984
+ ensureDirectoryStructure();
1985
+ try {
1986
+ const envStat = fs.existsSync(ENV_PATH) ? fs.statSync(ENV_PATH) : null;
1987
+ if (!envStat || envStat.size === 0) {
1988
+ logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; set TELEGRAM_BOT_TOKEN and OPENROUTER_API_KEY');
1989
+ }
1990
+ }
1991
+ catch (err) {
1992
+ logger.warn({ envPath: ENV_PATH, err }, 'Failed to check .env file');
1993
+ }
1994
+ // Validate Telegram token
1995
+ if (!process.env.TELEGRAM_BOT_TOKEN) {
1996
+ throw new Error('TELEGRAM_BOT_TOKEN environment variable is required.\n' +
1997
+ 'Create a bot with @BotFather and add the token to your .env file at: ' +
1998
+ ENV_PATH);
1999
+ }
2000
+ ensureDockerRunning();
2001
+ initDatabase();
2002
+ initMemoryStore();
2003
+ startEmbeddingWorker();
2004
+ const expiredMemories = cleanupExpiredMemories();
2005
+ if (expiredMemories > 0) {
2006
+ logger.info({ expiredMemories }, 'Expired memories cleaned up');
2007
+ }
2008
+ logger.info('Database initialized');
2009
+ startMetricsServer();
2010
+ loadState();
2011
+ if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
2012
+ const groups = Object.values(registeredGroups);
2013
+ for (const group of groups) {
2014
+ try {
2015
+ warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
2016
+ logger.info({ group: group.folder }, 'Warmed daemon container');
2017
+ }
2018
+ catch (err) {
2019
+ logger.warn({ group: group.folder, err }, 'Failed to warm daemon container');
2020
+ }
2021
+ }
2022
+ }
2023
+ // Set up Telegram message handlers
2024
+ setupTelegramHandlers();
2025
+ // Start dashboard
2026
+ startDashboard();
2027
+ // Start Telegram bot
2028
+ try {
2029
+ telegrafBot.launch();
2030
+ setTelegramConnected(true);
2031
+ logger.info('Telegram bot started');
2032
+ // Graceful shutdown
2033
+ process.once('SIGINT', () => {
2034
+ logger.info('Shutting down Telegram bot');
2035
+ setTelegramConnected(false);
2036
+ telegrafBot.stop('SIGINT');
2037
+ });
2038
+ process.once('SIGTERM', () => {
2039
+ logger.info('Shutting down Telegram bot');
2040
+ setTelegramConnected(false);
2041
+ telegrafBot.stop('SIGTERM');
2042
+ });
2043
+ // Start scheduler and IPC watcher
2044
+ // Wrapper that matches the scheduler's expected interface (Promise<void>)
2045
+ const sendMessageForScheduler = async (jid, text) => {
2046
+ await sendMessage(jid, text);
2047
+ };
2048
+ startSchedulerLoop({
2049
+ sendMessage: sendMessageForScheduler,
2050
+ registeredGroups: () => registeredGroups,
2051
+ getSessions: () => sessions,
2052
+ setSession: (groupFolder, sessionId) => {
2053
+ sessions[groupFolder] = sessionId;
2054
+ setGroupSession(groupFolder, sessionId);
2055
+ }
2056
+ });
2057
+ startIpcWatcher();
2058
+ startMaintenanceLoop();
2059
+ startHeartbeatLoop();
2060
+ startDaemonHealthCheckLoop(() => registeredGroups, MAIN_GROUP_FOLDER);
2061
+ logger.info('DotClaw running on Telegram (responds to DMs and group mentions/replies)');
2062
+ }
2063
+ catch (error) {
2064
+ logger.error({ error }, 'Failed to start Telegram bot');
2065
+ process.exit(1);
2066
+ }
2067
+ }
2068
+ main().catch(err => {
2069
+ logger.error({ err }, 'Failed to start DotClaw');
2070
+ process.exit(1);
2071
+ });
2072
+ //# sourceMappingURL=index.js.map