@dotsetlabs/dotclaw 1.1.0 → 1.2.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 (54) hide show
  1. package/README.md +1 -0
  2. package/config-examples/runtime.json +5 -0
  3. package/container/agent-runner/package-lock.json +2 -2
  4. package/container/agent-runner/package.json +1 -1
  5. package/container/agent-runner/src/container-protocol.ts +3 -0
  6. package/container/agent-runner/src/index.ts +20 -1
  7. package/container/agent-runner/src/ipc.ts +35 -0
  8. package/container/agent-runner/src/tools.ts +115 -0
  9. package/dist/agent-context.d.ts +1 -0
  10. package/dist/agent-context.d.ts.map +1 -1
  11. package/dist/agent-context.js +8 -1
  12. package/dist/agent-context.js.map +1 -1
  13. package/dist/agent-execution.d.ts +7 -0
  14. package/dist/agent-execution.d.ts.map +1 -1
  15. package/dist/agent-execution.js +12 -6
  16. package/dist/agent-execution.js.map +1 -1
  17. package/dist/background-jobs.d.ts +47 -0
  18. package/dist/background-jobs.d.ts.map +1 -0
  19. package/dist/background-jobs.js +406 -0
  20. package/dist/background-jobs.js.map +1 -0
  21. package/dist/container-protocol.d.ts +3 -0
  22. package/dist/container-protocol.d.ts.map +1 -1
  23. package/dist/container-runner.d.ts +1 -0
  24. package/dist/container-runner.d.ts.map +1 -1
  25. package/dist/container-runner.js +2 -2
  26. package/dist/container-runner.js.map +1 -1
  27. package/dist/dashboard.d.ts +1 -0
  28. package/dist/dashboard.d.ts.map +1 -1
  29. package/dist/dashboard.js +8 -0
  30. package/dist/dashboard.js.map +1 -1
  31. package/dist/db.d.ts +34 -1
  32. package/dist/db.d.ts.map +1 -1
  33. package/dist/db.js +181 -0
  34. package/dist/db.js.map +1 -1
  35. package/dist/index.js +144 -221
  36. package/dist/index.js.map +1 -1
  37. package/dist/metrics.d.ts +1 -0
  38. package/dist/metrics.d.ts.map +1 -1
  39. package/dist/metrics.js +9 -0
  40. package/dist/metrics.js.map +1 -1
  41. package/dist/runtime-config.d.ts +8 -4
  42. package/dist/runtime-config.d.ts.map +1 -1
  43. package/dist/runtime-config.js +15 -5
  44. package/dist/runtime-config.js.map +1 -1
  45. package/dist/task-scheduler.d.ts +5 -0
  46. package/dist/task-scheduler.d.ts.map +1 -1
  47. package/dist/task-scheduler.js +121 -2
  48. package/dist/task-scheduler.js.map +1 -1
  49. package/dist/tool-policy.d.ts.map +1 -1
  50. package/dist/tool-policy.js +6 -0
  51. package/dist/tool-policy.js.map +1 -1
  52. package/dist/types.d.ts +41 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, IPC_POLL_INTERVAL, TIMEZONE, C
7
7
  // Load .env from the canonical location (~/.dotclaw/.env)
8
8
  dotenv.config({ path: ENV_PATH });
9
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';
10
+ import { startSchedulerLoop, runTaskNow } from './task-scheduler.js';
11
+ import { startBackgroundJobLoop, spawnBackgroundJob, getBackgroundJobStatus, listBackgroundJobsForGroup, cancelBackgroundJob, recordBackgroundJobUpdate } from './background-jobs.js';
11
12
  import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
12
13
  import { writeTrace } from './trace-writer.js';
13
14
  import { formatTelegramMessage, TELEGRAM_PARSE_MODE } from './telegram-format.js';
@@ -35,10 +36,6 @@ function buildTriggerRegex(pattern) {
35
36
  return null;
36
37
  }
37
38
  }
38
- function isPreemptedError(err) {
39
- const message = err instanceof Error ? err.message : String(err);
40
- return message.toLowerCase().includes('preempt');
41
- }
42
39
  function buildAvailableGroupsSnapshot() {
43
40
  return Object.entries(registeredGroups).map(([jid, info]) => ({
44
41
  jid,
@@ -158,22 +155,6 @@ const PROGRESS_MESSAGES = runtime.host.progress.messages.length > 0
158
155
  const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
159
156
  const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
160
157
  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
158
  // Initialize Telegram bot with extended timeout for long-running agent tasks
178
159
  const telegrafBot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN, {
179
160
  handlerTimeout: TELEGRAM_HANDLER_TIMEOUT_MS
@@ -186,7 +167,6 @@ let registeredGroups = {};
186
167
  const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
187
168
  const TELEGRAM_SEND_DELAY_MS = 250;
188
169
  const messageQueues = new Map();
189
- const inFlightRuns = new Map();
190
170
  const draftSessions = new Map();
191
171
  function parseTelegramStreamMode(value) {
192
172
  const normalized = value.trim().toLowerCase();
@@ -577,13 +557,6 @@ async function finalizeStreamedMessage(msg, draftId, text) {
577
557
  clearDraftSession(msg.chatId, draftId);
578
558
  }
579
559
  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
560
  const existing = messageQueues.get(msg.chatId);
588
561
  if (existing) {
589
562
  existing.pendingMessage = msg;
@@ -659,18 +632,6 @@ async function processMessage(msg) {
659
632
  ${lines.join('\n')}
660
633
  </messages>`;
661
634
  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
635
  const traceBase = createTraceBase({
675
636
  chatId: msg.chatId,
676
637
  groupFolder: group.folder,
@@ -841,186 +802,6 @@ ${lines.join('\n')}
841
802
  }
842
803
  return true;
843
804
  }
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
805
  function startIpcWatcher() {
1025
806
  const ipcBaseDir = path.join(DATA_DIR, 'ipc');
1026
807
  fs.mkdirSync(ipcBaseDir, { recursive: true });
@@ -1597,6 +1378,139 @@ async function processRequestIpc(data, sourceGroup, isMain) {
1597
1378
  const groups = listRegisteredGroups();
1598
1379
  return { id: requestId, ok: true, result: { groups } };
1599
1380
  }
1381
+ case 'run_task': {
1382
+ const taskId = typeof payload.task_id === 'string' ? payload.task_id : '';
1383
+ if (!taskId) {
1384
+ return { id: requestId, ok: false, error: 'task_id is required.' };
1385
+ }
1386
+ const task = getTaskById(taskId);
1387
+ if (!task) {
1388
+ return { id: requestId, ok: false, error: 'Task not found.' };
1389
+ }
1390
+ if (!isMain && task.group_folder !== sourceGroup) {
1391
+ return { id: requestId, ok: false, error: 'Unauthorized task run attempt.' };
1392
+ }
1393
+ const result = await runTaskNow(taskId, {
1394
+ sendMessage: async (jid, text) => { await sendMessage(jid, text); },
1395
+ registeredGroups: () => registeredGroups,
1396
+ getSessions: () => sessions,
1397
+ setSession: (groupFolder, sessionId) => { sessions[groupFolder] = sessionId; }
1398
+ });
1399
+ return {
1400
+ id: requestId,
1401
+ ok: result.ok,
1402
+ result: { result: result.result ?? null },
1403
+ error: result.ok ? undefined : result.error
1404
+ };
1405
+ }
1406
+ case 'spawn_job': {
1407
+ const prompt = typeof payload.prompt === 'string' ? payload.prompt.trim() : '';
1408
+ if (!prompt) {
1409
+ return { id: requestId, ok: false, error: 'prompt is required.' };
1410
+ }
1411
+ const targetGroup = (typeof payload.target_group === 'string' && isMain)
1412
+ ? payload.target_group
1413
+ : sourceGroup;
1414
+ const groupEntry = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup);
1415
+ if (!groupEntry) {
1416
+ return { id: requestId, ok: false, error: 'Target group not registered.' };
1417
+ }
1418
+ const [chatId, group] = groupEntry;
1419
+ const result = spawnBackgroundJob({
1420
+ prompt,
1421
+ groupFolder: group.folder,
1422
+ chatJid: chatId,
1423
+ contextMode: (payload.context_mode === 'group' || payload.context_mode === 'isolated')
1424
+ ? payload.context_mode
1425
+ : undefined,
1426
+ timeoutMs: typeof payload.timeout_ms === 'number' ? payload.timeout_ms : undefined,
1427
+ maxToolSteps: typeof payload.max_tool_steps === 'number' ? payload.max_tool_steps : undefined,
1428
+ toolAllow: Array.isArray(payload.tool_allow) ? payload.tool_allow : undefined,
1429
+ toolDeny: Array.isArray(payload.tool_deny) ? payload.tool_deny : undefined,
1430
+ modelOverride: typeof payload.model_override === 'string' ? payload.model_override : undefined,
1431
+ priority: typeof payload.priority === 'number' ? payload.priority : undefined,
1432
+ tags: Array.isArray(payload.tags) ? payload.tags : undefined,
1433
+ parentTraceId: typeof payload.parent_trace_id === 'string' ? payload.parent_trace_id : undefined,
1434
+ parentMessageId: typeof payload.parent_message_id === 'string' ? payload.parent_message_id : undefined
1435
+ });
1436
+ return {
1437
+ id: requestId,
1438
+ ok: result.ok,
1439
+ result: result.ok ? { job_id: result.jobId } : undefined,
1440
+ error: result.ok ? undefined : result.error
1441
+ };
1442
+ }
1443
+ case 'job_status': {
1444
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1445
+ if (!jobId) {
1446
+ return { id: requestId, ok: false, error: 'job_id is required.' };
1447
+ }
1448
+ const job = getBackgroundJobStatus(jobId);
1449
+ if (!job) {
1450
+ return { id: requestId, ok: false, error: 'Job not found.' };
1451
+ }
1452
+ if (!isMain && job.group_folder !== sourceGroup) {
1453
+ return { id: requestId, ok: false, error: 'Unauthorized job status request.' };
1454
+ }
1455
+ return { id: requestId, ok: true, result: { job } };
1456
+ }
1457
+ case 'list_jobs': {
1458
+ const targetGroup = (typeof payload.target_group === 'string' && isMain)
1459
+ ? payload.target_group
1460
+ : sourceGroup;
1461
+ const statusRaw = typeof payload.status === 'string' ? payload.status : undefined;
1462
+ const allowedStatuses = ['queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'];
1463
+ const status = statusRaw && allowedStatuses.includes(statusRaw)
1464
+ ? statusRaw
1465
+ : undefined;
1466
+ const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
1467
+ const jobs = listBackgroundJobsForGroup({ groupFolder: targetGroup, status, limit });
1468
+ return { id: requestId, ok: true, result: { jobs } };
1469
+ }
1470
+ case 'cancel_job': {
1471
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1472
+ if (!jobId) {
1473
+ return { id: requestId, ok: false, error: 'job_id is required.' };
1474
+ }
1475
+ const job = getBackgroundJobStatus(jobId);
1476
+ if (!job) {
1477
+ return { id: requestId, ok: false, error: 'Job not found.' };
1478
+ }
1479
+ if (!isMain && job.group_folder !== sourceGroup) {
1480
+ return { id: requestId, ok: false, error: 'Unauthorized job cancel attempt.' };
1481
+ }
1482
+ const result = cancelBackgroundJob(jobId);
1483
+ return { id: requestId, ok: result.ok, error: result.error };
1484
+ }
1485
+ case 'job_update': {
1486
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1487
+ const message = typeof payload.message === 'string' ? payload.message.trim() : '';
1488
+ const levelRaw = typeof payload.level === 'string' ? payload.level : 'progress';
1489
+ const allowedLevels = ['info', 'progress', 'warn', 'error'];
1490
+ const level = allowedLevels.includes(levelRaw)
1491
+ ? levelRaw
1492
+ : 'progress';
1493
+ if (!jobId || !message) {
1494
+ return { id: requestId, ok: false, error: 'job_id and message are required.' };
1495
+ }
1496
+ const job = getBackgroundJobStatus(jobId);
1497
+ if (!job) {
1498
+ return { id: requestId, ok: false, error: 'Job not found.' };
1499
+ }
1500
+ if (!isMain && job.group_folder !== sourceGroup) {
1501
+ return { id: requestId, ok: false, error: 'Unauthorized job update attempt.' };
1502
+ }
1503
+ const result = recordBackgroundJobUpdate({
1504
+ jobId,
1505
+ level,
1506
+ message,
1507
+ data: typeof payload.data === 'object' && payload.data ? payload.data : undefined
1508
+ });
1509
+ if (result.ok && payload.notify === true && job.chat_jid) {
1510
+ await sendMessage(job.chat_jid, `Background job ${job.id} update:\n\n${message}`);
1511
+ }
1512
+ return { id: requestId, ok: result.ok, error: result.error };
1513
+ }
1600
1514
  default:
1601
1515
  return { id: requestId, ok: false, error: `Unknown request type: ${data.type}` };
1602
1516
  }
@@ -2054,6 +1968,15 @@ async function main() {
2054
1968
  setGroupSession(groupFolder, sessionId);
2055
1969
  }
2056
1970
  });
1971
+ startBackgroundJobLoop({
1972
+ sendMessage: sendMessageForScheduler,
1973
+ registeredGroups: () => registeredGroups,
1974
+ getSessions: () => sessions,
1975
+ setSession: (groupFolder, sessionId) => {
1976
+ sessions[groupFolder] = sessionId;
1977
+ setGroupSession(groupFolder, sessionId);
1978
+ }
1979
+ });
2057
1980
  startIpcWatcher();
2058
1981
  startMaintenanceLoop();
2059
1982
  startHeartbeatLoop();